Add SSH inbound, log level. Update MTPROXY. Fixes

This commit is contained in:
Shtorm
2026-06-07 07:59:43 +03:00
parent 6f6af8e902
commit 9f5ccf43d4
115 changed files with 2742 additions and 527 deletions

View File

@@ -1,12 +1,16 @@
-s dir -s dir
--name sing-box --name sing-box-extended
--category net --category net
--license GPL-3.0-or-later --license GPL-3.0-or-later
--description "The universal proxy platform." --description "The universal proxy platform (extended)."
--url "https://sing-box.sagernet.org/" --url "https://sing-box.sagernet.org/"
--maintainer "nekohasekai <contact-git@sekai.icu>" --maintainer "nekohasekai <contact-git@sekai.icu>"
--no-deb-generate-changes --no-deb-generate-changes
--provides sing-box
--conflicts sing-box
--replaces sing-box
--config-files /etc/config/sing-box --config-files /etc/config/sing-box
--config-files /etc/sing-box/config.json --config-files /etc/sing-box/config.json

View File

@@ -27,10 +27,7 @@ fi
PROJECT=$(cd "$(dirname "$0")/.."; pwd) PROJECT=$(cd "$(dirname "$0")/.."; pwd)
# Convert version to APK format: # Convert version to APK format:
# 1.13.0-beta.8 -> 1.13.0_beta8-r0 APK_VERSION=$(echo "$VERSION" | sed -E 's/-([a-z]+)\.([0-9]+)/_\1\2/' | sed -E 's/-[a-z]+-/./g')
# 1.13.0-rc.3 -> 1.13.0_rc3-r0
# 1.13.0 -> 1.13.0-r0
APK_VERSION=$(echo "$VERSION" | sed -E 's/-([a-z]+)\.([0-9]+)/_\1\2/')
APK_VERSION="${APK_VERSION}-r0" APK_VERSION="${APK_VERSION}-r0"
ROOT_DIR=$(mktemp -d) ROOT_DIR=$(mktemp -d)
@@ -78,15 +75,16 @@ done < "$PACKAGES_DIR/.conffiles" > "$PACKAGES_DIR/.conffiles_static"
# Build APK # Build APK
apk --root "$APK_ROOT_DIR" mkpkg \ apk --root "$APK_ROOT_DIR" mkpkg \
--info "name:sing-box" \ --info "name:sing-box-extended" \
--info "version:${APK_VERSION}" \ --info "version:${APK_VERSION}" \
--info "description:The universal proxy platform." \ --info "description:The universal proxy platform (extended)." \
--info "arch:${ARCHITECTURE}" \ --info "arch:${ARCHITECTURE}" \
--info "license:GPL-3.0-or-later" \ --info "license:GPL-3.0-or-later" \
--info "origin:sing-box" \ --info "origin:sing-box-extended" \
--info "url:https://sing-box.sagernet.org/" \ --info "url:https://sing-box.sagernet.org/" \
--info "maintainer:nekohasekai <contact-git@sekai.icu>" \ --info "maintainer:nekohasekai <contact-git@sekai.icu>" \
--info "depends:ca-bundle kmod-inet-diag kmod-tun firewall4 kmod-nft-queue" \ --info "depends:ca-bundle kmod-inet-diag kmod-tun firewall4 kmod-nft-queue" \
--info "provides:sing-box" \
--info "provider-priority:100" \ --info "provider-priority:100" \
--script "pre-deinstall:${PROJECT}/release/config/openwrt.prerm" \ --script "pre-deinstall:${PROJECT}/release/config/openwrt.prerm" \
--files "$ROOT_DIR" \ --files "$ROOT_DIR" \

53
.github/build_openwrt_packages.sh vendored Executable file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -e -o pipefail
VERSION="$1"
TARGET="$2"
BINARY_PATH="$3"
PROJECT=$(cd "$(dirname "$0")/.."; pwd)
DIST="$PROJECT/dist"
case "$TARGET" in
linux_amd64*) ARCHITECTURES="x86_64" ;;
linux_arm64*) ARCHITECTURES="aarch64_cortex-a53 aarch64_cortex-a72 aarch64_cortex-a76 aarch64_generic" ;;
linux_386*softfloat) ARCHITECTURES="i386_pentium-mmx" ;;
linux_386*) ARCHITECTURES="i386_pentium4" ;;
linux_arm_7*) ARCHITECTURES="arm_cortex-a5_vfpv4 arm_cortex-a7_neon-vfpv4 arm_cortex-a7_vfpv4 arm_cortex-a8_vfpv3 arm_cortex-a9_neon arm_cortex-a9_vfpv3-d16 arm_cortex-a15_neon-vfpv4" ;;
linux_arm_6*) ARCHITECTURES="arm_arm1176jzf-s_vfp" ;;
linux_arm_5*) ARCHITECTURES="arm_arm926ej-s arm_cortex-a7 arm_cortex-a9 arm_fa526 arm_xscale" ;;
linux_mips64_*) ARCHITECTURES="mips64_mips64r2 mips64_octeonplus" ;;
linux_mips64le*) ARCHITECTURES="mips64el_mips64r2" ;;
linux_mipsle*hardfloat) ARCHITECTURES="mipsel_24kc_24kf" ;;
linux_mipsle*) ARCHITECTURES="mipsel_24kc mipsel_74kc mipsel_mips32" ;;
linux_mips_*) ARCHITECTURES="mips_24kc mips_4kec mips_mips32" ;;
linux_riscv64*) ARCHITECTURES="riscv64_generic" ;;
linux_loong64*) ARCHITECTURES="loongarch64_generic" ;;
*) echo "Unknown target: $TARGET"; exit 1 ;;
esac
PKG_VERSION="${VERSION//-/\~}"
for ARCH in $ARCHITECTURES; do
cp "$PROJECT/.fpm_openwrt" "$PROJECT/.fpm"
fpm -t deb \
-v "$PKG_VERSION" \
-p "$DIST/_openwrt_tmp.deb" \
--architecture all \
"$BINARY_PATH=/usr/bin/sing-box"
rm -f "$PROJECT/.fpm"
bash "$PROJECT/.github/deb2ipk.sh" \
"$ARCH" \
"$DIST/_openwrt_tmp.deb" \
"$DIST/sing-box-extended_${VERSION}_openwrt_${ARCH}.ipk"
rm -f "$DIST/_openwrt_tmp.deb"
if command -v apk &>/dev/null; then
bash "$PROJECT/.github/build_openwrt_apk.sh" \
"$ARCH" "$VERSION" "$BINARY_PATH" \
"$DIST/sing-box-extended_${VERSION}_openwrt_${ARCH}.apk"
fi
echo "Built: sing-box-extended_${VERSION}_openwrt_${ARCH} (.ipk/.apk)"
done

View File

@@ -393,6 +393,49 @@ builds:
- android_arm64 - android_arm64
- android_386 - android_386
- android_amd64 - android_amd64
- id: openwrt
<<: *template
hooks:
post:
- cmd: bash .github/build_openwrt_packages.sh "{{ .Version }}" "{{ .Target }}" "{{ .Path }}"
targets:
- linux_amd64_v1
- linux_arm64
- linux_386
- linux_arm_7
- linux_arm_6
- linux_riscv64
- linux_loong64
- id: openwrt-mips
<<: *template
tags:
- with_gvisor
- with_quic
- with_dhcp
- with_wireguard
- with_utls
- with_acme
- with_clash_api
- with_tailscale
- with_masque
- with_mtproxy
- with_ccm
- with_ocm
- with_openvpn
- with_trusttunnel
- with_sudoku
- badlinkname
- tfogo_checklinkname0
hooks:
post:
- cmd: bash .github/build_openwrt_packages.sh "{{ .Version }}" "{{ .Target }}" "{{ .Path }}"
targets:
- linux_arm_5
- linux_mips_softfloat
- linux_mips64_softfloat
- linux_mipsle_softfloat
- linux_mipsle_hardfloat
- linux_mips64le
upx: upx:
- enabled: true - enabled: true
ids: ids:
@@ -471,6 +514,12 @@ archives:
- compressed-mips - compressed-mips
- compressed-android - compressed-android
name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}-{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}-compressed' name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}-{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}-compressed'
- id: archive-openwrt
<<: *template
builds:
- openwrt
- openwrt-mips
name_template: '{{ .ProjectName }}-{{ .Version }}-openwrt-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}-{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
source: source:
enabled: false enabled: false
name_template: '{{ .ProjectName }}-{{ .Version }}.source' name_template: '{{ .ProjectName }}-{{ .Version }}.source'
@@ -496,5 +545,5 @@ release:
- archive-naive-purego-windows-amd64 - archive-naive-purego-windows-amd64
- archive-naive-purego-windows-arm64 - archive-naive-purego-windows-arm64
- archive-compressed - archive-compressed
- package - archive-openwrt
skip_upload: true skip_upload: true

View File

@@ -27,7 +27,6 @@ CRONET_GO_PATH ?= $(shell pwd)/cronet-go
.PHONY: test release docs build .PHONY: test release docs build
build: build:
export GOTOOLCHAIN=local && \
go build $(MAIN_PARAMS) $(MAIN) go build $(MAIN_PARAMS) $(MAIN)
build_admin_panel: build_admin_panel:
@@ -94,6 +93,8 @@ release: build_admin_panel build_naive
mkdir dist/release mkdir dist/release
mv dist/*.tar.gz \ mv dist/*.tar.gz \
dist/*.zip \ dist/*.zip \
dist/*.ipk \
dist/*.apk \
dist/release dist/release
ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release
./codeberg-release.sh --replace --draft --prerelease -p 5 "v${VERSION}" dist/release ./codeberg-release.sh --replace --draft --prerelease -p 5 "v${VERSION}" dist/release

View File

@@ -3,6 +3,7 @@
[![license](https://img.shields.io/badge/license-GPLv3-blue.svg)](LICENSE) [![license](https://img.shields.io/badge/license-GPLv3-blue.svg)](LICENSE)
[![go](https://img.shields.io/badge/go-1.26-00ADD8.svg)](go.mod) [![go](https://img.shields.io/badge/go-1.26-00ADD8.svg)](go.mod)
[![codeberg](https://img.shields.io/badge/mirror-codeberg-2185D0.svg)](https://codeberg.org/shtorm-7/sing-box-extended) [![codeberg](https://img.shields.io/badge/mirror-codeberg-2185D0.svg)](https://codeberg.org/shtorm-7/sing-box-extended)
[![telegram](https://img.shields.io/badge/telegram-channel-26A5E4.svg)](https://t.me/sing_box_extended)
Sing-box with extended features. Sing-box with extended features.
@@ -16,6 +17,7 @@ Sing-box with extended features.
- **OpenVPN** — OpenVPN client with tls-auth, tls-crypt and tls-crypt-v2 support - **OpenVPN** — OpenVPN client with tls-auth, tls-crypt and tls-crypt-v2 support
- **TrustTunnel** — AdGuard's obfuscated VPN protocol, indistinguishable from HTTPS traffic - **TrustTunnel** — AdGuard's obfuscated VPN protocol, indistinguishable from HTTPS traffic
- **Sudoku** — Traffic obfuscation protocol based on 4×4 Sudoku puzzles with low-entropy fingerprints - **Sudoku** — Traffic obfuscation protocol based on 4×4 Sudoku puzzles with low-entropy fingerprints
- **SSH** — SSH client and server with certificate authentication and upstream fallback
- **VPN** — Routed tunnel over any TCP sing-box protocol - **VPN** — Routed tunnel over any TCP sing-box protocol
- **Bond** — Link aggregation for increasing throughput - **Bond** — Link aggregation for increasing throughput
- **Fallback** — Outbound group with priority-based switching - **Fallback** — Outbound group with priority-based switching

View File

@@ -233,7 +233,7 @@ func (m *Manager) Remove(tag string) error {
if m.defaultOutbound == outbound { if m.defaultOutbound == outbound {
if len(m.outbounds) > 0 { if len(m.outbounds) > 0 {
m.defaultOutbound = m.outbounds[0] m.defaultOutbound = m.outbounds[0]
m.logger.Info("updated default outbound to ", m.defaultOutbound.Tag()) m.logger.Notice("updated default outbound to ", m.defaultOutbound.Tag())
} else { } else {
m.defaultOutbound = nil m.defaultOutbound = nil
} }
@@ -303,7 +303,7 @@ func (m *Manager) Create(ctx context.Context, router adapter.Router, logger log.
if tag == m.defaultTag || (m.defaultTag == "" && m.defaultOutbound == nil) { if tag == m.defaultTag || (m.defaultTag == "" && m.defaultOutbound == nil) {
m.defaultOutbound = outbound m.defaultOutbound = outbound
if m.started { if m.started {
m.logger.Info("updated default outbound to ", outbound.Tag()) m.logger.Notice("updated default outbound to ", outbound.Tag())
} }
} }
return nil return nil

4
box.go
View File

@@ -458,7 +458,7 @@ func (s *Box) PreStart() error {
s.Close() s.Close()
return err return err
} }
s.logger.Info("sing-box pre-started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)") s.logger.Notice("sing-box pre-started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)")
return nil return nil
} }
@@ -477,7 +477,7 @@ func (s *Box) Start() error {
s.Close() s.Close()
return err return err
} }
s.logger.Info("sing-box started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)") s.logger.Notice("sing-box started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)")
return nil return nil
} }

View File

@@ -179,6 +179,7 @@ func run() error {
for { for {
osSignal := <-osSignals osSignal := <-osSignals
if osSignal == syscall.SIGHUP { if osSignal == syscall.SIGHUP {
log.Notice("received SIGHUP, reloading...")
err = check() err = check()
if err != nil { if err != nil {
log.Error(E.Cause(err, "reload service")) log.Error(E.Cause(err, "reload service"))

View File

@@ -77,7 +77,7 @@ func (l *Listener) ListenTCP() (net.Listener, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
l.logger.Info("tcp server started at ", tcpListener.Addr()) l.logger.Notice("tcp server started at ", tcpListener.Addr())
l.tcpListener = tcpListener l.tcpListener = tcpListener
return tcpListener, err return tcpListener, err
} }

View File

@@ -54,7 +54,7 @@ func (l *Listener) ListenUDP() (net.PacketConn, error) {
} }
l.udpConn = udpConn.(*net.UDPConn) l.udpConn = udpConn.(*net.UDPConn)
l.udpAddr = bindAddr l.udpAddr = bindAddr
l.logger.Info("udp server started at ", udpConn.LocalAddr()) l.logger.Notice("udp server started at ", udpConn.LocalAddr())
return udpConn, err return udpConn, err
} }

View File

@@ -164,7 +164,7 @@ func (c *STDServerConfig) certificateUpdated(path string) error {
config.Certificates = []tls.Certificate{keyPair} config.Certificates = []tls.Certificate{keyPair}
c.config = config c.config = config
c.access.Unlock() c.access.Unlock()
c.logger.Info("reloaded TLS certificate") c.logger.Notice("reloaded TLS certificate")
} else if common.Contains(c.clientCertificatePath, path) { } else if common.Contains(c.clientCertificatePath, path) {
clientCertificateCA := x509.NewCertPool() clientCertificateCA := x509.NewCertPool()
var reloaded bool var reloaded bool
@@ -188,7 +188,7 @@ func (c *STDServerConfig) certificateUpdated(path string) error {
config.ClientCAs = clientCertificateCA config.ClientCAs = clientCertificateCA
c.config = config c.config = config
c.access.Unlock() c.access.Unlock()
c.logger.Info("reloaded client certificates") c.logger.Notice("reloaded client certificates")
} else if path == c.echKeyPath { } else if path == c.echKeyPath {
echKey, err := os.ReadFile(c.echKeyPath) echKey, err := os.ReadFile(c.echKeyPath)
if err != nil { if err != nil {
@@ -198,7 +198,7 @@ func (c *STDServerConfig) certificateUpdated(path string) error {
if err != nil { if err != nil {
return err return err
} }
c.logger.Info("reloaded ECH keys") c.logger.Notice("reloaded ECH keys")
} }
return nil return nil
} }

View File

@@ -20,13 +20,14 @@ const (
type LogLevel int32 type LogLevel int32
const ( const (
LogLevel_PANIC LogLevel = 0 LogLevel_PANIC LogLevel = 0
LogLevel_FATAL LogLevel = 1 LogLevel_FATAL LogLevel = 1
LogLevel_ERROR LogLevel = 2 LogLevel_ERROR LogLevel = 2
LogLevel_WARN LogLevel = 3 LogLevel_WARN LogLevel = 3
LogLevel_INFO LogLevel = 4 LogLevel_NOTICE LogLevel = 4
LogLevel_DEBUG LogLevel = 5 LogLevel_INFO LogLevel = 5
LogLevel_TRACE LogLevel = 6 LogLevel_DEBUG LogLevel = 6
LogLevel_TRACE LogLevel = 7
) )
// Enum value maps for LogLevel. // Enum value maps for LogLevel.
@@ -36,18 +37,20 @@ var (
1: "FATAL", 1: "FATAL",
2: "ERROR", 2: "ERROR",
3: "WARN", 3: "WARN",
4: "INFO", 4: "NOTICE",
5: "DEBUG", 5: "INFO",
6: "TRACE", 6: "DEBUG",
7: "TRACE",
} }
LogLevel_value = map[string]int32{ LogLevel_value = map[string]int32{
"PANIC": 0, "PANIC": 0,
"FATAL": 1, "FATAL": 1,
"ERROR": 2, "ERROR": 2,
"WARN": 3, "WARN": 3,
"INFO": 4, "NOTICE": 4,
"DEBUG": 5, "INFO": 5,
"TRACE": 6, "DEBUG": 6,
"TRACE": 7,
} }
) )

View File

@@ -59,9 +59,10 @@ enum LogLevel {
FATAL = 1; FATAL = 1;
ERROR = 2; ERROR = 2;
WARN = 3; WARN = 3;
INFO = 4; NOTICE = 4;
DEBUG = 5; INFO = 5;
TRACE = 6; DEBUG = 6;
TRACE = 7;
} }
message Log { message Log {

View File

@@ -186,7 +186,7 @@ func (t *Transport) updateServers() error {
return E.Cause(err, "dhcp: prepare interface") return E.Cause(err, "dhcp: prepare interface")
} }
t.logger.Info("dhcp: query DNS servers on ", iface.Name) t.logger.Notice("dhcp: query DNS servers on ", iface.Name)
fetchCtx, cancel := context.WithTimeout(t.ctx, C.DHCPTimeout) fetchCtx, cancel := context.WithTimeout(t.ctx, C.DHCPTimeout)
err = t.fetchServers0(fetchCtx, iface) err = t.fetchServers0(fetchCtx, iface)
cancel() cancel()
@@ -303,7 +303,7 @@ func (t *Transport) recreateServers(iface *control.Interface, dhcpPacket *dhcpv4
return M.SocksaddrFrom(M.AddrFromIP(it), 53) return M.SocksaddrFrom(M.AddrFromIP(it), 53)
}) })
if len(serverAddrs) > 0 && !slices.Equal(t.servers, serverAddrs) { if len(serverAddrs) > 0 && !slices.Equal(t.servers, serverAddrs) {
t.logger.Info("dhcp: updated DNS servers from ", iface.Name, ": [", strings.Join(common.Map(serverAddrs, M.Socksaddr.String), ","), "], search: [", strings.Join(t.search, ","), "]") t.logger.Notice("dhcp: updated DNS servers from ", iface.Name, ": [", strings.Join(common.Map(serverAddrs, M.Socksaddr.String), ","), "], search: [", strings.Join(t.search, ","), "]")
} }
t.servers = serverAddrs t.servers = serverAddrs
return nil return nil

View File

@@ -217,7 +217,7 @@ func (m *TransportManager) Remove(tag string) error {
return E.New("default server cannot be fakeip") return E.New("default server cannot be fakeip")
} }
m.defaultTransport = nextTransport m.defaultTransport = nextTransport
m.logger.Info("updated default server to ", m.defaultTransport.Tag()) m.logger.Notice("updated default server to ", m.defaultTransport.Tag())
} else { } else {
m.defaultTransport = nil m.defaultTransport = nil
} }
@@ -287,7 +287,7 @@ func (m *TransportManager) Create(ctx context.Context, logger log.ContextLogger,
} }
m.defaultTransport = transport m.defaultTransport = transport
if m.started { if m.started {
m.logger.Info("updated default server to ", transport.Tag()) m.logger.Notice("updated default server to ", transport.Tag())
} }
} }
if transport.Type() == C.DNSTypeFakeIP { if transport.Type() == C.DNSTypeFakeIP {

View File

@@ -32,7 +32,7 @@
"tag": "my-manager", "tag": "my-manager",
"database": { "database": {
"driver": "sqlite", "driver": "sqlite",
"dsn": "file:manager.db?_pragma=foreign_keys(on)&_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)" // also supported Postgresql "dsn": "file:manager.db?_pragma=foreign_keys(on)&_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)&_time_format=sqlite" // also supported Postgresql
} }
}, },
{ {

View File

@@ -25,6 +25,8 @@
{ {
"type": "masque", "type": "masque",
"tag": "masque-out", "tag": "masque-out",
"system": false,
"name": "masque0",
"use_http2": false, "use_http2": false,
"use_ipv6": false, "use_ipv6": false,
"profile": { "profile": {

View File

@@ -7,6 +7,9 @@
"type": "mieru", "type": "mieru",
"tag": "mieru-in", "tag": "mieru-in",
"listen_port": 27017, "listen_port": 27017,
"listen_ports": [
"27017-27019"
],
"transport": "TCP", "transport": "TCP",
"users": [ "users": [
{ {

View File

@@ -31,7 +31,15 @@
"packet_encoding": "", "packet_encoding": "",
"transport": { "transport": {
"type": "mkcp", "type": "mkcp",
"mtu": 1500 "mtu": 1350, // 576-1460
"tti": 50, // 10-100, ms
"uplink_capacity": 12, // MB/s
"downlink_capacity": 100, // MB/s
"congestion": false,
"read_buffer_size": 1, // MB
"write_buffer_size": 1, // MB
"header_type": "none", // none, srtp, utp, wechat-video, dtls, wireguard
"seed": "password"
} }
} }
], ],

View File

@@ -24,7 +24,15 @@
], ],
"transport": { "transport": {
"type": "mkcp", "type": "mkcp",
"mtu": 1500 "mtu": 1350, // 576-1460
"tti": 50, // 10-100, ms
"uplink_capacity": 12, // MB/s
"downlink_capacity": 100, // MB/s
"congestion": false,
"read_buffer_size": 1, // MB
"write_buffer_size": 1, // MB
"header_type": "none", // none, srtp, utp, wechat-video, dtls, wireguard
"seed": "password"
} }
} }
], ],

View File

@@ -26,9 +26,9 @@
"concurrency": 8192, "concurrency": 8192,
// domain_fronting_port is a port we use to connect to a fronting domain. // domain_fronting_port is a port we use to connect to a fronting domain.
"domain_fronting_port": 443, "domain_fronting_port": 443,
// domain_fronting_ip is an IP address to use when connecting to the fronting // domain_fronting_host is the address (IP or hostname) to use when connecting
// domain instead of resolving the hostname from the secret via DNS. // to the fronting domain instead of resolving the hostname from the secret via DNS.
"domain_fronting_ip": "", "domain_fronting_host": "",
// domain_fronting_proxy_protocol is used if communication between upstream // domain_fronting_proxy_protocol is used if communication between upstream
// endpoint and sing-box supports proxy protocol. // endpoint and sing-box supports proxy protocol.
"domain_fronting_proxy_protocol": false, "domain_fronting_proxy_protocol": false,

View File

@@ -13,6 +13,8 @@
{ {
"type": "openvpn", "type": "openvpn",
"tag": "openvpn-out", "tag": "openvpn-out",
"system": false,
"name": "openvpn0",
"servers": [ "servers": [
{ {
"server": "vpn.example.com", "server": "vpn.example.com",

View File

@@ -13,6 +13,8 @@
{ {
"type": "openvpn", "type": "openvpn",
"tag": "openvpn-out", "tag": "openvpn-out",
"system": false,
"name": "openvpn0",
"servers": [ "servers": [
{ {
"server": "vpn.example.com", "server": "vpn.example.com",

View File

@@ -13,6 +13,8 @@
{ {
"type": "openvpn", "type": "openvpn",
"tag": "openvpn-out", "tag": "openvpn-out",
"system": false,
"name": "openvpn0",
"servers": [ "servers": [
{ {
"server": "vpn.example.com", "server": "vpn.example.com",

View File

@@ -13,6 +13,8 @@
{ {
"type": "openvpn", "type": "openvpn",
"tag": "openvpn-out", "tag": "openvpn-out",
"system": false,
"name": "openvpn0",
"servers": [ "servers": [
{ {
"server": "vpn.example.com", "server": "vpn.example.com",

52
examples/ssh/client.json Normal file
View File

@@ -0,0 +1,52 @@
{
"log": {
"level": "error"
},
"dns": {
"servers": [
{
"type": "local",
"tag": "default"
}
]
},
"inbounds": [
{
"type": "mixed",
"tag": "mixed-in",
"listen_port": 7897
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct"
},
{
"type": "ssh",
"tag": "ssh-out",
"server": "example.com",
"server_port": 2222,
"user": "user",
// Authentication: password or private key
"password": "password",
"private_key": [
"-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----"
],
// or: "private_key_path": "/path/to/id_ed25519",
"private_key_passphrase": "",
// Pin server host key (optional)
"host_key": [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA..."
],
"host_key_algorithms": ["ssh-ed25519"],
"client_version": "SSH-2.0-OpenSSH_9.6"
// Dial Fields
}
],
"route": {
"final": "ssh-out",
"default_domain_resolver": "default",
"auto_detect_interface": true
}
}

76
examples/ssh/server.json Normal file
View File

@@ -0,0 +1,76 @@
{
"log": {
"level": "info"
},
"dns": {
"servers": [
{
"type": "local",
"tag": "default"
}
]
},
"inbounds": [
{
"type": "ssh",
"tag": "ssh-in",
"listen": "0.0.0.0",
"listen_port": 2222,
"host_key": [
"-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----"
],
// or: "host_key_path": ["/etc/sing-box/ssh_host_ed25519_key"],
"server_version": "SSH-2.0-OpenSSH_9.6",
"max_auth_tries": 3,
"users": [
{
"name": "user1",
"password": "password1"
},
{
"name": "user2",
"authorized_keys": [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... user2@host"
]
}
],
"fallback": {
"server": "10.0.0.2",
"server_port": 22,
"ca": {
"private_key": [
"-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----"
],
// or: "private_key_path": "/etc/sing-box/ca_key",
"private_key_passphrase": ""
},
// Optional: separate CA for issuing upstream certs (defaults to ca)
"issue_ca": {
"private_key": [
"-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----"
],
// or: "private_key_path": "/etc/sing-box/issue_ca_key",
"private_key_passphrase": ""
},
"host_key": [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... upstream-host-key"
],
// or: "host_key_path": ["/etc/sing-box/upstream_host_key.pub"],
"host_key_algorithms": ["ssh-ed25519"],
"client_version": "SSH-2.0-OpenSSH_9.6"
// Dial Fields
}
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct"
}
],
"route": {
"final": "direct",
"default_domain_resolver": "default",
"auto_detect_interface": true
}
}

View File

@@ -30,7 +30,7 @@ func updateExternalUI(server *Server) func(w http.ResponseWriter, r *http.Reques
render.JSON(w, r, newError(err.Error())) render.JSON(w, r, newError(err.Error()))
return return
} }
server.logger.Info("updated external UI") server.logger.Notice("updated external UI")
render.JSON(w, r, render.M{"status": "ok"}) render.JSON(w, r, render.M{"status": "ok"})
} }
} }

View File

@@ -177,7 +177,7 @@ func (s *Server) Start(stage adapter.StartStage) error {
if err != nil { if err != nil {
return E.Cause(err, "external controller listen error") return E.Cause(err, "external controller listen error")
} }
s.logger.Info("restful api listening at ", listener.Addr()) s.logger.Notice("restful api listening at ", listener.Addr())
go func() { go func() {
err = s.httpServer.Serve(listener) err = s.httpServer.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) { if err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -234,7 +234,7 @@ func (s *Server) SetMode(newMode string) {
s.logger.Error(E.Cause(err, "save mode")) s.logger.Error(E.Cause(err, "save mode"))
} }
} }
s.logger.Info("updated mode: ", newMode) s.logger.Notice("updated mode: ", newMode)
} }
func (s *Server) HistoryStorage() adapter.URLTestHistoryStorage { func (s *Server) HistoryStorage() adapter.URLTestHistoryStorage {

View File

@@ -56,7 +56,7 @@ func (s *Server) Start(stage adapter.StartStage) error {
if err != nil { if err != nil {
return err return err
} }
s.logger.Info("grpc server started at ", listener.Addr()) s.logger.Notice("grpc server started at ", listener.Addr())
s.tcpListener = listener s.tcpListener = listener
go func() { go func() {
err = s.grpcServer.Serve(listener) err = s.grpcServer.Serve(listener)

4
go.mod
View File

@@ -229,10 +229,10 @@ replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4.
replace github.com/sagernet/sing-vmess => github.com/shtorm-7/sing-vmess v0.2.7-extended-1.0.0 replace github.com/sagernet/sing-vmess => github.com/shtorm-7/sing-vmess v0.2.7-extended-1.0.0
replace github.com/dolonet/mtg-multi => github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.1 replace github.com/dolonet/mtg-multi => github.com/shtorm-7/mtg-multi v1.11.0-extended-1.0.0
replace github.com/Diniboy1123/connect-ip-go => github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0 replace github.com/Diniboy1123/connect-ip-go => github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0
replace github.com/shtorm-7/go-cache/v2 => github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0 replace github.com/shtorm-7/go-cache/v2 => github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0
replace github.com/sagernet/sing => github.com/shtorm-7/sing v0.8.10-extended-1.0.0 replace github.com/sagernet/sing => github.com/shtorm-7/sing v0.8.10-extended-1.1.0

8
go.sum
View File

@@ -379,10 +379,10 @@ github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 h1:e5s7RKBd2rIPR0StbvZ2vTV
github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0/go.mod h1:WpEFV2uhebXb8Jhes/5/fSdpmhGV8TL22RDaeWwV6hI= github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0/go.mod h1:WpEFV2uhebXb8Jhes/5/fSdpmhGV8TL22RDaeWwV6hI=
github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0 h1:PLZ/YHqnApPx13wt6MX3ItqESp4ueBr1tGSi0bEGqYw= github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0 h1:PLZ/YHqnApPx13wt6MX3ItqESp4ueBr1tGSi0bEGqYw=
github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0/go.mod h1:Ek4yz5OK6stwhLKgLsRRYDI+FA+ZWvRJiWLjsi/vMM4= github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0/go.mod h1:Ek4yz5OK6stwhLKgLsRRYDI+FA+ZWvRJiWLjsi/vMM4=
github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.1 h1:UeJkrCJJmIjTBywErVMx7fCSoBf4gh6QgT9bp9o1ajM= github.com/shtorm-7/mtg-multi v1.11.0-extended-1.0.0 h1:iBLll4ZZG8ULQcHWs6gGslZWtBN72Zo1zjySzMVHF7g=
github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.1/go.mod h1:3rvdhwdPABkwKBdvgMt3VwMn9uSq8hpoHRezZ5jRJU0= github.com/shtorm-7/mtg-multi v1.11.0-extended-1.0.0/go.mod h1:3rvdhwdPABkwKBdvgMt3VwMn9uSq8hpoHRezZ5jRJU0=
github.com/shtorm-7/sing v0.8.10-extended-1.0.0 h1:mAkyycCQOzCttPOR5fcHkJaZvXMQXeu3mbEfr8D+7A8= github.com/shtorm-7/sing v0.8.10-extended-1.1.0 h1:P4JL2cugjvEvnYu8tMmpR30SE1qsS45RcnNEwzDz5as=
github.com/shtorm-7/sing v0.8.10-extended-1.0.0/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/shtorm-7/sing v0.8.10-extended-1.1.0/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 h1:a5OoXr3e2ACbM6vDIaaGL44IdHQ6wPjcSoU13vfC0Sw= github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 h1:a5OoXr3e2ACbM6vDIaaGL44IdHQ6wPjcSoU13vfC0Sw=
github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk=
github.com/shtorm-7/sing-vmess v0.2.7-extended-1.0.0 h1:WVheKmQH5hSQbJU1ZTKthKSutkTLWSb2hp4JuQhJBow= github.com/shtorm-7/sing-vmess v0.2.7-extended-1.0.0 h1:WVheKmQH5hSQbJU1ZTKthKSutkTLWSb2hp4JuQhJBow=

View File

@@ -81,6 +81,7 @@ func InboundRegistry() *inbound.Registry {
vless.RegisterInbound(registry) vless.RegisterInbound(registry)
anytls.RegisterInbound(registry) anytls.RegisterInbound(registry)
mieru.RegisterInbound(registry) mieru.RegisterInbound(registry)
ssh.RegisterInbound(registry)
bond.RegisterInbound(registry) bond.RegisterInbound(registry)
failover.RegisterInbound(registry) failover.RegisterInbound(registry)

View File

@@ -39,6 +39,10 @@ func Info(args ...any) {
std.Info(args...) std.Info(args...)
} }
func Notice(args ...any) {
std.Notice(args...)
}
func Warn(args ...any) { func Warn(args ...any) {
std.Warn(args...) std.Warn(args...)
} }
@@ -67,6 +71,10 @@ func InfoContext(ctx context.Context, args ...any) {
std.InfoContext(ctx, args...) std.InfoContext(ctx, args...)
} }
func NoticeContext(ctx context.Context, args ...any) {
std.NoticeContext(ctx, args...)
}
func WarnContext(ctx context.Context, args ...any) { func WarnContext(ctx context.Context, args ...any) {
std.WarnContext(ctx, args...) std.WarnContext(ctx, args...)
} }

View File

@@ -28,6 +28,8 @@ func (f Formatter) Format(ctx context.Context, level Level, tag string, message
levelString = aurora.White(levelString).String() levelString = aurora.White(levelString).String()
case LevelInfo: case LevelInfo:
levelString = aurora.Cyan(levelString).String() levelString = aurora.Cyan(levelString).String()
case LevelNotice:
levelString = aurora.Green(levelString).String()
case LevelWarn: case LevelWarn:
levelString = aurora.Yellow(levelString).String() levelString = aurora.Yellow(levelString).String()
case LevelError, LevelFatal, LevelPanic: case LevelError, LevelFatal, LevelPanic:
@@ -97,6 +99,8 @@ func (f Formatter) FormatWithSimple(ctx context.Context, level Level, tag string
levelString = aurora.White(levelString).String() levelString = aurora.White(levelString).String()
case LevelInfo: case LevelInfo:
levelString = aurora.Cyan(levelString).String() levelString = aurora.Cyan(levelString).String()
case LevelNotice:
levelString = aurora.Green(levelString).String()
case LevelWarn: case LevelWarn:
levelString = aurora.Yellow(levelString).String() levelString = aurora.Yellow(levelString).String()
case LevelError, LevelFatal, LevelPanic: case LevelError, LevelFatal, LevelPanic:

View File

@@ -11,6 +11,7 @@ const (
LevelFatal LevelFatal
LevelError LevelError
LevelWarn LevelWarn
LevelNotice
LevelInfo LevelInfo
LevelDebug LevelDebug
LevelTrace LevelTrace
@@ -24,6 +25,8 @@ func FormatLevel(level Level) string {
return "debug" return "debug"
case LevelInfo: case LevelInfo:
return "info" return "info"
case LevelNotice:
return "notice"
case LevelWarn: case LevelWarn:
return "warn" return "warn"
case LevelError: case LevelError:
@@ -45,6 +48,8 @@ func ParseLevel(level string) (Level, error) {
return LevelDebug, nil return LevelDebug, nil
case "info": case "info":
return LevelInfo, nil return LevelInfo, nil
case "notice":
return LevelNotice, nil
case "warn", "warning": case "warn", "warning":
return LevelWarn, nil return LevelWarn, nil
case "error": case "error":

View File

@@ -47,6 +47,9 @@ func (f *nopFactory) Debug(args ...any) {
func (f *nopFactory) Info(args ...any) { func (f *nopFactory) Info(args ...any) {
} }
func (f *nopFactory) Notice(args ...any) {
}
func (f *nopFactory) Warn(args ...any) { func (f *nopFactory) Warn(args ...any) {
} }
@@ -68,6 +71,9 @@ func (f *nopFactory) DebugContext(ctx context.Context, args ...any) {
func (f *nopFactory) InfoContext(ctx context.Context, args ...any) { func (f *nopFactory) InfoContext(ctx context.Context, args ...any) {
} }
func (f *nopFactory) NoticeContext(ctx context.Context, args ...any) {
}
func (f *nopFactory) WarnContext(ctx context.Context, args ...any) { func (f *nopFactory) WarnContext(ctx context.Context, args ...any) {
} }

View File

@@ -154,6 +154,10 @@ func (l *observableLogger) Info(args ...any) {
l.InfoContext(context.Background(), args...) l.InfoContext(context.Background(), args...)
} }
func (l *observableLogger) Notice(args ...any) {
l.NoticeContext(context.Background(), args...)
}
func (l *observableLogger) Warn(args ...any) { func (l *observableLogger) Warn(args ...any) {
l.WarnContext(context.Background(), args...) l.WarnContext(context.Background(), args...)
} }
@@ -182,6 +186,10 @@ func (l *observableLogger) InfoContext(ctx context.Context, args ...any) {
l.Log(ctx, LevelInfo, args) l.Log(ctx, LevelInfo, args)
} }
func (l *observableLogger) NoticeContext(ctx context.Context, args ...any) {
l.Log(ctx, LevelNotice, args)
}
func (l *observableLogger) WarnContext(ctx context.Context, args ...any) { func (l *observableLogger) WarnContext(ctx context.Context, args ...any) {
l.Log(ctx, LevelWarn, args) l.Log(ctx, LevelWarn, args)
} }

View File

@@ -6,6 +6,6 @@ type ManagerServiceDatabase struct {
} }
type ManagerServiceOptions struct { type ManagerServiceOptions struct {
Inbounds []string `json:"inbounds"` Inbounds []string `json:"inbounds"`
Database ManagerServiceDatabase `json:"database"` Database ManagerServiceDatabase `json:"database"`
} }

View File

@@ -1,18 +1,23 @@
package option package option
import ( import (
"net/netip"
"github.com/sagernet/sing/common/json/badoption" "github.com/sagernet/sing/common/json/badoption"
) )
type MASQUEOutboundOptions struct { type MASQUEOutboundOptions struct {
DialerOptions DialerOptions
UseHTTP2 bool `json:"use_http2,omitempty"` System bool `json:"system,omitempty"`
UseIPv6 bool `json:"use_ipv6,omitempty"` Name string `json:"name,omitempty"`
Profile CloudflareProfile `json:"profile,omitempty"` AllowedIPs badoption.Listable[netip.Prefix] `json:"allowed_ips,omitempty"`
UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` UseHTTP2 bool `json:"use_http2,omitempty"`
UDPKeepalivePeriod badoption.Duration `json:"udp_keepalive_period,omitempty"` UseIPv6 bool `json:"use_ipv6,omitempty"`
UDPInitialPacketSize uint16 `json:"udp_initial_packet_size,omitempty"` Profile CloudflareProfile `json:"profile,omitempty"`
ReconnectDelay badoption.Duration `json:"reconnect_delay,omitempty"` UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"`
UDPKeepalivePeriod badoption.Duration `json:"udp_keepalive_period,omitempty"`
UDPInitialPacketSize uint16 `json:"udp_initial_packet_size,omitempty"`
ReconnectDelay badoption.Duration `json:"reconnect_delay,omitempty"`
MASQUEOutboundTLSOptionsContainer MASQUEOutboundTLSOptionsContainer
} }

View File

@@ -15,10 +15,11 @@ type MieruOutboundOptions struct {
type MieruInboundOptions struct { type MieruInboundOptions struct {
ListenOptions ListenOptions
Users []MieruUser `json:"users,omitempty"` ListenPorts badoption.Listable[string] `json:"listen_ports,omitempty"`
Transport string `json:"transport,omitempty"` Users []MieruUser `json:"users,omitempty"`
TrafficPattern string `json:"traffic_pattern,omitempty"` Transport string `json:"transport,omitempty"`
UserHintIsMandatory bool `json:"user_hint_is_mandatory,omitempty"` TrafficPattern string `json:"traffic_pattern,omitempty"`
UserHintIsMandatory bool `json:"user_hint_is_mandatory,omitempty"`
} }
type MieruUser struct { type MieruUser struct {

View File

@@ -11,7 +11,7 @@ type MTProxyInboundOptions struct {
Users []MTProxyUser `json:"users,omitempty"` Users []MTProxyUser `json:"users,omitempty"`
Concurrency uint `json:"concurrency,omitempty"` Concurrency uint `json:"concurrency,omitempty"`
DomainFrontingPort uint `json:"domain_fronting_port,omitempty"` DomainFrontingPort uint `json:"domain_fronting_port,omitempty"`
DomainFrontingIP string `json:"domain_fronting_ip,omitempty"` DomainFrontingHost string `json:"domain_fronting_host,omitempty"`
DomainFrontingProxyProtocol bool `json:"domain_fronting_proxy_protocol,omitempty"` DomainFrontingProxyProtocol bool `json:"domain_fronting_proxy_protocol,omitempty"`
PreferIP string `json:"prefer_ip,omitempty"` PreferIP string `json:"prefer_ip,omitempty"`
AutoUpdate bool `json:"auto_update,omitempty"` AutoUpdate bool `json:"auto_update,omitempty"`

View File

@@ -1,11 +1,11 @@
package option package option
type NodeServiceOptions struct { type NodeServiceOptions struct {
UUID string `json:"uuid"` UUID string `json:"uuid"`
Inbounds []string `json:"inbounds"` Inbounds []string `json:"inbounds"`
ConnectionLimiters []string `json:"connection_limiters"` ConnectionLimiters []string `json:"connection_limiters"`
BandwidthLimiters []string `json:"bandwidth_limiters"` BandwidthLimiters []string `json:"bandwidth_limiters"`
TrafficLimiters []string `json:"traffic_limiters"` TrafficLimiters []string `json:"traffic_limiters"`
RateLimiters []string `json:"rate_limiters"` RateLimiters []string `json:"rate_limiters"`
Manager string `json:"manager"` Manager string `json:"manager"`
} }

View File

@@ -1,38 +1,45 @@
package option package option
import "github.com/sagernet/sing/common/json/badoption" import (
"net/netip"
"github.com/sagernet/sing/common/json/badoption"
)
type OpenVPNOutboundOptions struct { type OpenVPNOutboundOptions struct {
DialerOptions DialerOptions
Servers []ServerOptions `json:"servers"` System bool `json:"system,omitempty"`
Proto string `json:"proto,omitempty"` Name string `json:"name,omitempty"`
Cipher string `json:"cipher,omitempty"` AllowedIPs badoption.Listable[netip.Prefix] `json:"allowed_ips,omitempty"`
Auth string `json:"auth,omitempty"` Servers []ServerOptions `json:"servers"`
Username string `json:"username,omitempty"` Proto string `json:"proto,omitempty"`
Password string `json:"password,omitempty"` Cipher string `json:"cipher,omitempty"`
TLSCrypt string `json:"tls_crypt,omitempty"` Auth string `json:"auth,omitempty"`
TLSCryptPath string `json:"tls_crypt_path,omitempty"` Username string `json:"username,omitempty"`
TLSCryptV2 bool `json:"tls_crypt_v2,omitempty"` Password string `json:"password,omitempty"`
TLSAuth string `json:"tls_auth,omitempty"` TLSCrypt string `json:"tls_crypt,omitempty"`
TLSAuthPath string `json:"tls_auth_path,omitempty"` TLSCryptPath string `json:"tls_crypt_path,omitempty"`
KeyDirection int `json:"key_direction,omitempty"` TLSCryptV2 bool `json:"tls_crypt_v2,omitempty"`
ReconnectDelay badoption.Duration `json:"reconnect_delay,omitempty"` TLSAuth string `json:"tls_auth,omitempty"`
PingInterval badoption.Duration `json:"ping_interval,omitempty"` TLSAuthPath string `json:"tls_auth_path,omitempty"`
KeyDirection int `json:"key_direction,omitempty"`
ReconnectDelay badoption.Duration `json:"reconnect_delay,omitempty"`
PingInterval badoption.Duration `json:"ping_interval,omitempty"`
OpenVPNOutboundTLSOptionsContainer OpenVPNOutboundTLSOptionsContainer
} }
type OpenVPNTLSOptions struct { type OpenVPNTLSOptions struct {
Certificate string `json:"certificate,omitempty"` Certificate string `json:"certificate,omitempty"`
CertificatePath string `json:"certificate_path,omitempty"` CertificatePath string `json:"certificate_path,omitempty"`
Key string `json:"key,omitempty"` Key string `json:"key,omitempty"`
KeyPath string `json:"key_path,omitempty"` KeyPath string `json:"key_path,omitempty"`
CA string `json:"ca,omitempty"` CA string `json:"ca,omitempty"`
CAPath string `json:"ca_path,omitempty"` CAPath string `json:"ca_path,omitempty"`
CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"`
VerifyX509Name string `json:"verify_x509_name,omitempty"` VerifyX509Name string `json:"verify_x509_name,omitempty"`
VerifyX509NameMode string `json:"verify_x509_name_mode,omitempty"` VerifyX509NameMode string `json:"verify_x509_name_mode,omitempty"`
KernelTx bool `json:"kernel_tx,omitempty"` KernelTx bool `json:"kernel_tx,omitempty"`
KernelRx bool `json:"kernel_rx,omitempty"` KernelRx bool `json:"kernel_rx,omitempty"`
} }
type OpenVPNOutboundTLSOptionsContainer struct { type OpenVPNOutboundTLSOptionsContainer struct {

View File

@@ -2,6 +2,39 @@ package option
import "github.com/sagernet/sing/common/json/badoption" import "github.com/sagernet/sing/common/json/badoption"
type SSHInboundOptions struct {
ListenOptions
Users []SSHUser `json:"users,omitempty"`
HostKey badoption.Listable[string] `json:"host_key,omitempty"`
HostKeyPath badoption.Listable[string] `json:"host_key_path,omitempty"`
ServerVersion string `json:"server_version,omitempty"`
MaxAuthTries int `json:"max_auth_tries,omitempty"`
Fallback *SSHFallbackServerOptions `json:"fallback,omitempty"`
}
type SSHFallbackServerOptions struct {
DialerOptions
ServerOptions
CA *SSHCAOptions `json:"ca,omitempty"`
IssueCA *SSHCAOptions `json:"issue_ca,omitempty"`
HostKey badoption.Listable[string] `json:"host_key,omitempty"`
HostKeyPath badoption.Listable[string] `json:"host_key_path,omitempty"`
HostKeyAlgorithms badoption.Listable[string] `json:"host_key_algorithms,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
}
type SSHCAOptions struct {
PrivateKey badoption.Listable[string] `json:"private_key,omitempty"`
PrivateKeyPath string `json:"private_key_path,omitempty"`
PrivateKeyPassphrase string `json:"private_key_passphrase,omitempty"`
}
type SSHUser struct {
Name string `json:"name,omitempty"`
Password string `json:"password,omitempty"`
AuthorizedKeys badoption.Listable[string] `json:"authorized_keys,omitempty"`
}
type SSHOutboundOptions struct { type SSHOutboundOptions struct {
DialerOptions DialerOptions
ServerOptions ServerOptions

View File

@@ -22,7 +22,7 @@ func NewBondedConn(conns []net.Conn, downloadRatios, uploadRatios []uint8) *bond
conns: conns, conns: conns,
downloadRatios: downloadRatios, downloadRatios: downloadRatios,
uploadRatios: uploadRatios, uploadRatios: uploadRatios,
readBuffer: bytes.NewBuffer(make([]byte, 0, 65536)), readBuffer: bytes.NewBuffer(make([]byte, 0, 4096)),
} }
} }

View File

@@ -37,13 +37,13 @@ type failoverConn struct {
func NewFailoverConn(ctx context.Context, conn net.Conn, dial dial, onClose func()) *failoverConn { func NewFailoverConn(ctx context.Context, conn net.Conn, dial dial, onClose func()) *failoverConn {
var writeBuffers [BufferSize][]byte var writeBuffers [BufferSize][]byte
for i := range BufferSize { for i := range BufferSize {
writeBuffers[i] = make([]byte, 0, 1000) writeBuffers[i] = make([]byte, 0, 1024)
} }
return &failoverConn{ return &failoverConn{
Conn: conn, Conn: conn,
ctx: ctx, ctx: ctx,
dial: dial, dial: dial,
readBuffer: bytes.NewBuffer(make([]byte, 0, 1000)), readBuffer: bytes.NewBuffer(make([]byte, 0, 1024)),
writeBuffers: writeBuffers, writeBuffers: writeBuffers,
onClose: onClose, onClose: onClose,
} }

View File

@@ -194,8 +194,6 @@ type bwConnEntry struct {
conn net.Conn conn net.Conn
} }
type ManagerBandwidthStrategy struct { type ManagerBandwidthStrategy struct {
strategies map[string]BandwidthStrategy strategies map[string]BandwidthStrategy
conns map[string][]*bwConnEntry conns map[string][]*bwConnEntry

View File

@@ -6,8 +6,8 @@ import (
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/outbound" "github.com/sagernet/sing-box/adapter/outbound"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/common/onclose" "github.com/sagernet/sing-box/common/onclose"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/route" "github.com/sagernet/sing-box/route"
@@ -173,8 +173,6 @@ func (h *Outbound) GetStrategy() ConnectionStrategy {
return h.strategy return h.strategy
} }
func connChecker(ctx context.Context, closeFunc func() error) { func connChecker(ctx context.Context, closeFunc func() error) {
<-ctx.Done() <-ctx.Done()
closeFunc() closeFunc()

View File

@@ -76,8 +76,6 @@ type connEntry struct {
conn net.Conn conn net.Conn
} }
type ManagerTrafficStrategy struct { type ManagerTrafficStrategy struct {
strategies map[string]TrafficStrategy strategies map[string]TrafficStrategy
conns map[string][]*connEntry conns map[string][]*connEntry

View File

@@ -9,7 +9,6 @@ import (
"time" "time"
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing-box/adapter/outbound" "github.com/sagernet/sing-box/adapter/outbound"
"github.com/sagernet/sing-box/common/cloudflare" "github.com/sagernet/sing-box/common/cloudflare"
"github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/common/dialer"
@@ -18,6 +17,7 @@ import (
"github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/transport/masque" "github.com/sagernet/sing-box/transport/masque"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/bufio" "github.com/sagernet/sing/common/bufio"
E "github.com/sagernet/sing/common/exceptions" E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger" "github.com/sagernet/sing/common/logger"
@@ -136,11 +136,19 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
ctx, ctx,
logger, logger,
masque.TunnelOptions{ masque.TunnelOptions{
System: options.System,
Name: options.Name,
CreateDialer: func(interfaceName string) N.Dialer {
return common.Must1(dialer.NewDefault(ctx, option.DialerOptions{
BindInterface: interfaceName,
}))
},
Dialer: outboundDialer, Dialer: outboundDialer,
Address: []netip.Prefix{ Address: []netip.Prefix{
netip.MustParsePrefix(appConfig.IPv4 + "/32"), netip.MustParsePrefix(appConfig.IPv4 + "/32"),
netip.MustParsePrefix(appConfig.IPv6 + "/128"), netip.MustParsePrefix(appConfig.IPv6 + "/128"),
}, },
AllowedAddress: options.AllowedIPs,
Endpoint: endpoint, Endpoint: endpoint,
TLSConfig: tlsConfig, TLSConfig: tlsConfig,
UseHTTP2: options.UseHTTP2, UseHTTP2: options.UseHTTP2,

View File

@@ -87,7 +87,7 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
return fmt.Errorf("failed to start mieru server: %w", err) return fmt.Errorf("failed to start mieru server: %w", err)
} }
h.logger.Info("mieru server is started") h.logger.Notice("mieru server is started")
go h.acceptLoop() go h.acceptLoop()
return nil return nil
} }
@@ -275,14 +275,21 @@ func buildMieruServerConfig(_ context.Context, options option.MieruInboundOption
transportProtocol = mierupb.TransportProtocol_UDP.Enum() transportProtocol = mierupb.TransportProtocol_UDP.Enum()
} }
if options.ListenOptions.ListenPort == 0 { if options.ListenOptions.ListenPort == 0 && len(options.ListenPorts) == 0 {
return nil, nil, E.New("listen_port must be set") return nil, nil, E.New("either listen_port or listen_ports must be set")
} }
portBindings := []*mierupb.PortBinding{ var portBindings []*mierupb.PortBinding
{ if options.ListenOptions.ListenPort != 0 {
portBindings = append(portBindings, &mierupb.PortBinding{
Port: proto.Int32(int32(options.ListenOptions.ListenPort)), Port: proto.Int32(int32(options.ListenOptions.ListenPort)),
Protocol: transportProtocol, Protocol: transportProtocol,
}, })
}
for _, pr := range options.ListenPorts {
portBindings = append(portBindings, &mierupb.PortBinding{
PortRange: proto.String(pr),
Protocol: transportProtocol,
})
} }
var users []*mierupb.User var users []*mierupb.User

View File

@@ -53,7 +53,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
if err := c.Start(); err != nil { if err := c.Start(); err != nil {
return nil, fmt.Errorf("failed to start mieru client: %w", err) return nil, fmt.Errorf("failed to start mieru client: %w", err)
} }
logger.InfoContext(ctx, "mieru client is started") logger.NoticeContext(ctx, "mieru client is started")
return &Outbound{ return &Outbound{
Adapter: outbound.NewAdapterWithDialerOptions(C.TypeMieru, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions), Adapter: outbound.NewAdapterWithDialerOptions(C.TypeMieru, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions),

View File

@@ -63,7 +63,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
Secrets: secrets, Secrets: secrets,
Concurrency: options.GetConcurrency(), Concurrency: options.GetConcurrency(),
DomainFrontingPort: options.GetDomainFrontingPort(), DomainFrontingPort: options.GetDomainFrontingPort(),
DomainFrontingIP: options.DomainFrontingIP, DomainFrontingHost: options.DomainFrontingHost,
DomainFrontingProxyProtocol: options.DomainFrontingProxyProtocol, DomainFrontingProxyProtocol: options.DomainFrontingProxyProtocol,
PreferIP: options.GetPreferIP(), PreferIP: options.GetPreferIP(),
AutoUpdate: options.AutoUpdate, AutoUpdate: options.AutoUpdate,

View File

@@ -227,7 +227,7 @@ func (h *Outbound) Start(stage adapter.StartStage) error {
if err != nil { if err != nil {
return err return err
} }
h.logger.Info("NaiveProxy started, version: ", h.client.Engine().Version()) h.logger.Notice("NaiveProxy started, version: ", h.client.Engine().Version())
return nil return nil
} }

View File

@@ -4,9 +4,9 @@ package openvpn
import ( import (
"context" "context"
"net"
"os" "os"
"time" "time"
"net"
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/outbound" "github.com/sagernet/sing-box/adapter/outbound"
@@ -90,10 +90,18 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
logger: logger, logger: logger,
} }
tunnel, err := ovpn.NewTunnel(ctx, logger, ovpn.TunnelOptions{ tunnel, err := ovpn.NewTunnel(ctx, logger, ovpn.TunnelOptions{
System: options.System,
Name: options.Name,
CreateDialer: func(interfaceName string) N.Dialer {
return common.Must1(dialer.NewDefault(ctx, option.DialerOptions{
BindInterface: interfaceName,
}))
},
Dialer: outboundDialer, Dialer: outboundDialer,
Servers: options.Servers, Servers: options.Servers,
TLSConfig: tlsConfig, TLSConfig: tlsConfig,
Config: clientConfig, Config: clientConfig,
AllowedAddress: options.AllowedIPs,
ReconnectDelay: time.Duration(options.ReconnectDelay), ReconnectDelay: time.Duration(options.ReconnectDelay),
PingInterval: time.Duration(options.PingInterval), PingInterval: time.Duration(options.PingInterval),
}) })

View File

@@ -0,0 +1,91 @@
package ssh
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"os"
"strings"
"time"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/crypto/ssh"
)
func parseCAKey(options *option.SSHCAOptions) (ssh.Signer, error) {
var keyData []byte
var err error
if len(options.PrivateKey) > 0 {
keyData = []byte(strings.Join(options.PrivateKey, "\n"))
} else if options.PrivateKeyPath != "" {
keyData, err = os.ReadFile(os.ExpandEnv(options.PrivateKeyPath))
if err != nil {
return nil, E.Cause(err, "read CA private key")
}
} else {
return nil, E.New("missing CA private key")
}
if options.PrivateKeyPassphrase == "" {
return ssh.ParsePrivateKey(keyData)
}
return ssh.ParsePrivateKeyWithPassphrase(keyData, []byte(options.PrivateKeyPassphrase))
}
func verifyCertificate(signer ssh.Signer, metadata ssh.ConnMetadata, key ssh.PublicKey) bool {
if signer == nil {
return false
}
certificate, ok := key.(*ssh.Certificate)
if !ok {
return false
}
checker := &ssh.CertChecker{
IsUserAuthority: func(auth ssh.PublicKey) bool {
return bytes.Equal(auth.Marshal(), signer.PublicKey().Marshal())
},
}
if !checker.IsUserAuthority(certificate.SignatureKey) {
return false
}
return checker.CheckCert(metadata.User(), certificate) == nil
}
func issueCertificate(signer ssh.Signer, user string) (ssh.Signer, error) {
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
ephemeral, err := ssh.NewSignerFromSigner(privateKey)
if err != nil {
return nil, err
}
now := time.Now()
certificate := &ssh.Certificate{
Key: ephemeral.PublicKey(),
Serial: uint64(now.UnixNano()),
CertType: ssh.UserCert,
KeyId: user,
ValidPrincipals: []string{user},
ValidAfter: uint64(now.Add(-1 * time.Minute).Unix()),
ValidBefore: uint64(now.Add(5 * time.Minute).Unix()),
Permissions: ssh.Permissions{
Extensions: map[string]string{
"permit-pty": "",
"permit-port-forwarding": "",
"permit-agent-forwarding": "",
"permit-X11-forwarding": "",
"permit-user-rc": "",
},
},
}
if err := certificate.SignCert(rand.Reader, signer); err != nil {
return nil, E.Cause(err, "sign certificate")
}
certSigner, err := ssh.NewCertSigner(certificate, ephemeral)
if err != nil {
return nil, E.Cause(err, "create certificate signer")
}
return certSigner, nil
}

322
protocol/ssh/fallback.go Normal file
View File

@@ -0,0 +1,322 @@
package ssh
import (
"bytes"
"context"
"encoding/base64"
"io"
"net"
"os"
"sync"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/onclose"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"golang.org/x/crypto/ssh"
)
var _ Service = (*Fallback)(nil)
type Fallback struct {
Service
ctx context.Context
logger logger.ContextLogger
dialer N.Dialer
serverAddr M.Socksaddr
clientVersion string
mainSigner ssh.Signer
issueSigner ssh.Signer
hostKeys []ssh.PublicKey
keyAlgorithms []string
pending map[string]*upstreamConn
mtx sync.Mutex
}
type upstreamConn struct {
conn net.Conn
client ssh.Conn
channels <-chan ssh.NewChannel
requests <-chan *ssh.Request
}
func NewFallback(ctx context.Context, logger logger.ContextLogger, inner Service, options *option.SSHFallbackServerOptions) (*Fallback, error) {
serverAddr := options.Build()
if serverAddr.Port == 0 {
serverAddr.Port = 22
}
if !serverAddr.Addr.IsValid() && serverAddr.Fqdn == "" {
return nil, E.New("missing upstream server address")
}
upstreamDialer, err := dialer.New(ctx, options.DialerOptions, serverAddr.IsFqdn())
if err != nil {
return nil, err
}
fallback := &Fallback{
Service: inner,
ctx: ctx,
logger: logger,
dialer: upstreamDialer,
serverAddr: serverAddr,
clientVersion: options.ClientVersion,
keyAlgorithms: options.HostKeyAlgorithms,
pending: make(map[string]*upstreamConn),
}
if fallback.clientVersion == "" {
fallback.clientVersion = "SSH-2.0-OpenSSH_9.6"
}
if options.CA != nil {
signer, err := parseCAKey(options.CA)
if err != nil {
return nil, E.Cause(err, "parse CA")
}
fallback.mainSigner = signer
}
if options.IssueCA != nil {
signer, err := parseCAKey(options.IssueCA)
if err != nil {
return nil, E.Cause(err, "parse issue CA")
}
fallback.issueSigner = signer
}
if fallback.issueSigner == nil && fallback.mainSigner != nil {
fallback.issueSigner = fallback.mainSigner
}
for _, hostKey := range options.HostKey {
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(hostKey))
if err != nil {
return nil, E.New("parse upstream host key ", hostKey)
}
fallback.hostKeys = append(fallback.hostKeys, key)
}
for _, hostKeyPath := range options.HostKeyPath {
content, err := os.ReadFile(os.ExpandEnv(hostKeyPath))
if err != nil {
return nil, E.Cause(err, "read upstream host key ", hostKeyPath)
}
key, _, _, _, err := ssh.ParseAuthorizedKey(content)
if err != nil {
return nil, E.Cause(err, "parse upstream host key ", hostKeyPath)
}
fallback.hostKeys = append(fallback.hostKeys, key)
}
return fallback, nil
}
func (f *Fallback) PasswordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
if permissions, err := f.Service.PasswordCallback(conn, password); err == nil {
return permissions, nil
}
if err := f.dial(string(conn.SessionID()), conn.User(), ssh.Password(string(password))); err != nil {
return nil, E.Cause(err, "upstream authentication failed for user ", conn.User())
}
return &ssh.Permissions{Extensions: map[string]string{"user": conn.User(), "fallback": "1"}}, nil
}
func (f *Fallback) PublicKeyCallback(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
if permissions, err := f.Service.PublicKeyCallback(conn, key); err == nil {
return permissions, nil
}
if verifyCertificate(f.mainSigner, conn, key) {
signer, err := issueCertificate(f.issueSigner, conn.User())
if err != nil {
return nil, E.Cause(err, "upstream authentication failed for user ", conn.User())
}
if err := f.dial(string(conn.SessionID()), conn.User(), ssh.PublicKeys(signer)); err != nil {
return nil, E.Cause(err, "upstream authentication failed for user ", conn.User())
}
return &ssh.Permissions{Extensions: map[string]string{"user": conn.User(), "fallback": "1"}}, nil
}
return nil, E.New("public key authentication failed for user ", conn.User())
}
func (f *Fallback) Handle(ctx context.Context, serverConn *ssh.ServerConn, channels <-chan ssh.NewChannel, requests <-chan *ssh.Request, metadata adapter.InboundContext, user string) {
if serverConn.Permissions == nil || serverConn.Permissions.Extensions["fallback"] != "1" {
f.Service.Handle(ctx, serverConn, channels, requests, metadata, user)
return
}
sessionID := string(serverConn.SessionID())
f.mtx.Lock()
upstream := f.pending[sessionID]
delete(f.pending, sessionID)
f.mtx.Unlock()
if upstream == nil {
serverConn.Close()
return
}
f.logger.InfoContext(ctx, "[", user, "] forwarded SSH connection from ", metadata.Source)
go proxyDownstreamRequests(requests, upstream.client)
go proxyGlobalRequests(upstream.requests, serverConn)
go func() {
for newChannel := range upstream.channels {
go proxyChannel(newChannel, serverConn)
}
}()
var wg sync.WaitGroup
for newChannel := range channels {
wg.Go(func() {
proxyChannel(newChannel, upstream.client)
})
}
wg.Wait()
upstream.client.Close()
upstream.conn.Close()
serverConn.Close()
}
func (f *Fallback) Close() error {
f.mtx.Lock()
connections := make([]net.Conn, 0, len(f.pending))
for id, upstream := range f.pending {
if upstream != nil {
connections = append(connections, upstream.conn)
}
delete(f.pending, id)
}
f.mtx.Unlock()
for _, conn := range connections {
conn.Close()
}
return f.Service.Close()
}
func (f *Fallback) dial(sessionID string, user string, auth ssh.AuthMethod) error {
f.mtx.Lock()
if _, attempted := f.pending[sessionID]; attempted {
f.mtx.Unlock()
return E.New("fallback already attempted")
}
f.pending[sessionID] = nil
f.mtx.Unlock()
conn, err := f.dialer.DialContext(f.ctx, N.NetworkTCP, f.serverAddr)
if err != nil {
f.mtx.Lock()
delete(f.pending, sessionID)
f.mtx.Unlock()
return err
}
conn = onclose.NewConn(conn, func() {
f.mtx.Lock()
delete(f.pending, sessionID)
f.mtx.Unlock()
})
config := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{auth},
ClientVersion: f.clientVersion,
HostKeyAlgorithms: f.keyAlgorithms,
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
if len(f.hostKeys) == 0 {
return nil
}
serverKey := key.Marshal()
for _, hostKey := range f.hostKeys {
if bytes.Equal(serverKey, hostKey.Marshal()) {
return nil
}
}
return E.New("upstream host key mismatch, server sent ", key.Type(), " ", base64.StdEncoding.EncodeToString(serverKey))
},
Timeout: C.TCPTimeout,
}
client, channels, requests, err := ssh.NewClientConn(conn, f.serverAddr.String(), config)
if err != nil {
conn.Close()
return err
}
f.mtx.Lock()
f.pending[sessionID] = &upstreamConn{conn: conn, client: client, channels: channels, requests: requests}
f.mtx.Unlock()
return nil
}
func proxyChannel(newChannel ssh.NewChannel, target ssh.Conn) {
targetChannel, targetRequests, err := target.OpenChannel(newChannel.ChannelType(), newChannel.ExtraData())
if err != nil {
if openErr, ok := err.(*ssh.OpenChannelError); ok {
newChannel.Reject(openErr.Reason, openErr.Message)
} else {
newChannel.Reject(ssh.ConnectionFailed, err.Error())
}
return
}
sourceChannel, sourceRequests, err := newChannel.Accept()
if err != nil {
targetChannel.Close()
return
}
go proxyChannelRequests(sourceRequests, targetChannel)
go io.Copy(targetChannel.Stderr(), sourceChannel.Stderr())
go io.Copy(sourceChannel.Stderr(), targetChannel.Stderr())
go func() {
io.Copy(targetChannel, sourceChannel)
targetChannel.CloseWrite()
}()
go func() {
io.Copy(sourceChannel, targetChannel)
sourceChannel.CloseWrite()
}()
proxyChannelRequests(targetRequests, sourceChannel)
sourceChannel.Close()
targetChannel.Close()
}
func proxyGlobalRequests(requests <-chan *ssh.Request, target ssh.Conn) {
for request := range requests {
if request.Type == "hostkeys-00@openssh.com" {
if request.WantReply {
request.Reply(false, nil)
}
continue
}
ok, payload, err := target.SendRequest(request.Type, request.WantReply, request.Payload)
if request.WantReply {
if err != nil {
request.Reply(false, nil)
} else {
request.Reply(ok, payload)
}
}
}
}
func proxyDownstreamRequests(requests <-chan *ssh.Request, target ssh.Conn) {
for request := range requests {
switch request.Type {
case "no-more-sessions@openssh.com", "hostkeys-prove-00@openssh.com":
if request.WantReply {
request.Reply(false, nil)
}
continue
}
ok, payload, err := target.SendRequest(request.Type, request.WantReply, request.Payload)
if request.WantReply {
if err != nil {
request.Reply(false, nil)
} else {
request.Reply(ok, payload)
}
}
}
}
func proxyChannelRequests(requests <-chan *ssh.Request, target ssh.Channel) {
for request := range requests {
ok, err := target.SendRequest(request.Type, request.WantReply, request.Payload)
if request.WantReply {
if err != nil {
request.Reply(false, nil)
} else {
request.Reply(ok, nil)
}
}
}
}

152
protocol/ssh/inbound.go Normal file
View File

@@ -0,0 +1,152 @@
package ssh
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net"
"os"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/inbound"
"github.com/sagernet/sing-box/common/listener"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
"golang.org/x/crypto/ssh"
)
func RegisterInbound(registry *inbound.Registry) {
inbound.Register[option.SSHInboundOptions](registry, C.TypeSSH, NewInbound)
}
var _ adapter.TCPInjectableInbound = (*Inbound)(nil)
type Inbound struct {
inbound.Adapter
logger logger.ContextLogger
listener *listener.Listener
serverConfig *ssh.ServerConfig
service Service
}
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SSHInboundOptions) (adapter.Inbound, error) {
if len(options.Users) == 0 && options.Fallback == nil {
return nil, E.New("missing users")
}
inbound := &Inbound{
Adapter: inbound.NewAdapter(C.TypeSSH, tag),
logger: logger,
}
defaultService := newService(router, logger, options.Users)
if options.Fallback != nil {
fallback, err := NewFallback(ctx, logger, defaultService, options.Fallback)
if err != nil {
return nil, err
}
inbound.service = fallback
} else {
inbound.service = defaultService
}
serverVersion := options.ServerVersion
if serverVersion == "" {
serverVersion = "SSH-2.0-OpenSSH_9.6"
}
serverConfig := &ssh.ServerConfig{
ServerVersion: serverVersion,
MaxAuthTries: options.MaxAuthTries,
PasswordCallback: inbound.service.PasswordCallback,
PublicKeyCallback: inbound.service.PublicKeyCallback,
}
var hostKeys []ssh.Signer
for _, hostKey := range options.HostKey {
signer, err := ssh.ParsePrivateKey([]byte(hostKey))
if err != nil {
return nil, E.Cause(err, "parse host key")
}
hostKeys = append(hostKeys, signer)
}
for _, hostKeyPath := range options.HostKeyPath {
content, err := os.ReadFile(os.ExpandEnv(hostKeyPath))
if err != nil {
return nil, E.Cause(err, "read host key ", hostKeyPath)
}
signer, err := ssh.ParsePrivateKey(content)
if err != nil {
return nil, E.Cause(err, "parse host key ", hostKeyPath)
}
hostKeys = append(hostKeys, signer)
}
if len(hostKeys) == 0 {
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, E.Cause(err, "generate host key")
}
signer, err := ssh.NewSignerFromSigner(privateKey)
if err != nil {
return nil, E.Cause(err, "generate host key")
}
hostKeys = append(hostKeys, signer)
}
for _, hostKey := range hostKeys {
serverConfig.AddHostKey(hostKey)
}
inbound.serverConfig = serverConfig
inbound.listener = listener.New(listener.Options{
Context: ctx,
Logger: logger,
Network: []string{N.NetworkTCP},
Listen: options.ListenOptions,
ConnectionHandler: inbound,
})
return inbound, nil
}
func (h *Inbound) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
return h.listener.Start()
}
func (h *Inbound) Close() error {
return E.Errors(h.service.Close(), h.listener.Close())
}
func (h *Inbound) UpdateUsers(users []option.SSHUser) {
h.service.UpdateUsers(users)
}
func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
metadata.Inbound = h.Tag()
metadata.InboundType = h.Type()
serverConn, channels, requests, err := ssh.NewServerConn(conn, h.serverConfig)
if err != nil {
N.CloseOnHandshakeFailure(conn, onClose, err)
if E.IsClosedOrCanceled(err) {
h.logger.DebugContext(ctx, "connection closed: ", err)
} else {
h.logger.DebugContext(ctx, E.Cause(err, "process connection from ", metadata.Source))
}
return
}
var user string
if serverConn.Permissions != nil {
user = serverConn.Permissions.Extensions["user"]
}
if user == "" {
user = serverConn.User()
}
if user != "" {
metadata.User = user
}
go func() {
serverConn.Wait()
conn.Close()
}()
h.service.Handle(ctx, serverConn, channels, requests, metadata, user)
}

165
protocol/ssh/service.go Normal file
View File

@@ -0,0 +1,165 @@
package ssh
import (
"bytes"
"context"
"net"
"os"
"sync"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/listener"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/bufio/deadline"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"golang.org/x/crypto/ssh"
)
type Service interface {
PasswordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error)
PublicKeyCallback(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error)
UpdateUsers(users []option.SSHUser)
Handle(ctx context.Context, serverConn *ssh.ServerConn, channels <-chan ssh.NewChannel, requests <-chan *ssh.Request, metadata adapter.InboundContext, user string)
Close() error
}
var _ Service = (*service)(nil)
type service struct {
router adapter.ConnectionRouterEx
logger logger.ContextLogger
listener *listener.Listener
users []option.SSHUser
mtx sync.RWMutex
}
func newService(router adapter.ConnectionRouterEx, logger logger.ContextLogger, users []option.SSHUser) *service {
return &service{
router: router,
logger: logger,
users: users,
}
}
func (h *service) PasswordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
h.mtx.RLock()
users := h.users
h.mtx.RUnlock()
for _, user := range users {
if user.Name != "" && user.Name != conn.User() {
continue
}
if user.Password != "" && user.Password == string(password) {
return &ssh.Permissions{Extensions: map[string]string{"user": user.Name}}, nil
}
}
return nil, E.New("password authentication failed for user ", conn.User())
}
func (h *service) PublicKeyCallback(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
h.mtx.RLock()
users := h.users
h.mtx.RUnlock()
for _, user := range users {
if user.Name != "" && user.Name != conn.User() {
continue
}
for _, authorizedKey := range user.AuthorizedKeys {
parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKey))
if err != nil {
continue
}
if bytes.Equal(parsed.Marshal(), key.Marshal()) {
return &ssh.Permissions{Extensions: map[string]string{"user": user.Name}}, nil
}
}
}
return nil, E.New("public key authentication failed for user ", conn.User())
}
func (h *service) UpdateUsers(users []option.SSHUser) {
h.mtx.Lock()
h.users = users
h.mtx.Unlock()
}
func (h *service) Handle(ctx context.Context, serverConn *ssh.ServerConn, channels <-chan ssh.NewChannel, requests <-chan *ssh.Request, metadata adapter.InboundContext, user string) {
h.logger.InfoContext(ctx, "[", user, "] authenticated SSH connection from ", metadata.Source)
go ssh.DiscardRequests(requests)
for newChannel := range channels {
switch newChannel.ChannelType() {
case "direct-tcpip":
go h.handleDirectChannel(ctx, metadata, newChannel)
default:
newChannel.Reject(ssh.UnknownChannelType, "only direct-tcpip is supported")
}
}
}
func (h *service) Close() error {
return nil
}
func (h *service) handleDirectChannel(ctx context.Context, metadata adapter.InboundContext, newChannel ssh.NewChannel) {
var payload directTCPIPData
if err := ssh.Unmarshal(newChannel.ExtraData(), &payload); err != nil {
newChannel.Reject(ssh.ConnectionFailed, "invalid direct-tcpip payload")
h.logger.ErrorContext(ctx, E.Cause(err, "parse direct-tcpip payload"))
return
}
channel, requests, err := newChannel.Accept()
if err != nil {
h.logger.ErrorContext(ctx, E.Cause(err, "accept direct-tcpip channel"))
return
}
go ssh.DiscardRequests(requests)
connMetadata := metadata
connMetadata.Destination = M.ParseSocksaddrHostPort(payload.HostToConnect, uint16(payload.PortToConnect))
conn := deadline.NewConn(&channelConn{
Channel: channel,
localAddr: metadata.OriginDestination.TCPAddr(),
remoteAddr: metadata.Source.TCPAddr(),
})
h.logger.InfoContext(ctx, "[", metadata.User, "] inbound connection to ", connMetadata.Destination)
h.router.RouteConnectionEx(ctx, conn, connMetadata, N.OnceClose(func(it error) {
channel.Close()
}))
}
type directTCPIPData struct {
HostToConnect string
PortToConnect uint32
OriginatorAddress string
OriginatorPort uint32
}
type channelConn struct {
ssh.Channel
localAddr net.Addr
remoteAddr net.Addr
}
func (c *channelConn) LocalAddr() net.Addr {
return c.localAddr
}
func (c *channelConn) RemoteAddr() net.Addr {
return c.remoteAddr
}
func (c *channelConn) SetDeadline(t time.Time) error {
return os.ErrInvalid
}
func (c *channelConn) SetReadDeadline(t time.Time) error {
return os.ErrInvalid
}
func (c *channelConn) SetWriteDeadline(t time.Time) error {
return os.ErrInvalid
}

View File

@@ -151,10 +151,10 @@ func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *n
} }
if len(defaultResolvers) > 0 { if len(defaultResolvers) > 0 {
t.logger.Info("updated ", len(routes), " routes, ", len(hosts), " hosts, default resolvers: ", t.logger.Notice("updated ", len(routes), " routes, ", len(hosts), " hosts, default resolvers: ",
strings.Join(common.Map(dnsConfig.DefaultResolvers, func(it *dnstype.Resolver) string { return it.Addr }), " ")) strings.Join(common.Map(dnsConfig.DefaultResolvers, func(it *dnstype.Resolver) string { return it.Addr }), " "))
} else { } else {
t.logger.Info("updated ", len(routes), " routes, ", len(hosts), " hosts") t.logger.Notice("updated ", len(routes), " routes, ", len(hosts), " hosts")
} }
return nil return nil
} }

View File

@@ -435,7 +435,7 @@ func (t *Endpoint) watchState() {
} }
authURL := localBackend.StatusWithoutPeers().AuthURL authURL := localBackend.StatusWithoutPeers().AuthURL
if authURL != "" { if authURL != "" {
t.logger.Info("Waiting for authentication: ", authURL) t.logger.Notice("Waiting for authentication: ", authURL)
if t.platformInterface != nil { if t.platformInterface != nil {
err := t.platformInterface.SendNotification(&adapter.Notification{ err := t.platformInterface.SendNotification(&adapter.Notification{
Identifier: "tailscale-authentication", Identifier: "tailscale-authentication",

View File

@@ -388,7 +388,7 @@ func (t *Inbound) Start(stage adapter.StartStage) error {
return err return err
} }
t.tunStack = tunStack t.tunStack = tunStack
t.logger.Info("started at ", t.tunOptions.Name) t.logger.Notice("started at ", t.tunOptions.Name)
case adapter.StartStatePostStart: case adapter.StartStatePostStart:
monitor := taskmonitor.New(t.logger, C.StartTimeout) monitor := taskmonitor.New(t.logger, C.StartTimeout)
monitor.Start("starting tun stack") monitor.Start("starting tun stack")

View File

@@ -5,7 +5,6 @@ import (
stdtls "crypto/tls" stdtls "crypto/tls"
"encoding/base64" "encoding/base64"
"net" "net"
"reflect"
"strings" "strings"
"sync" "sync"
@@ -96,7 +95,10 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
return nil, E.New("unknown packet encoding: ", options.PacketEncoding) return nil, E.New("unknown packet encoding: ", options.PacketEncoding)
} }
} }
// Parse encryption configuration muxOpts := common.PtrValueOrDefault(options.Multiplex)
if muxOpts.Enabled {
options.Flow = ""
}
if options.Encryption != "" && options.Encryption != "none" { if options.Encryption != "" && options.Encryption != "none" {
encryptionConfig, err := parseClientEncryption(options.Encryption) encryptionConfig, err := parseClientEncryption(options.Encryption)
if err != nil { if err != nil {
@@ -109,10 +111,6 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
logger.Debug("encryption initialized: keys=", len(encryptionConfig.keys), " xorMode=", encryptionConfig.xorMode, " seconds=", encryptionConfig.seconds, " padding=", encryptionConfig.padding) logger.Debug("encryption initialized: keys=", len(encryptionConfig.keys), " xorMode=", encryptionConfig.xorMode, " seconds=", encryptionConfig.seconds, " padding=", encryptionConfig.padding)
} }
muxOpts := common.PtrValueOrDefault(options.Multiplex)
if muxOpts.Enabled {
options.Flow = ""
}
outbound.client, err = vless.NewClient(options.UUID, options.Flow, logger) outbound.client, err = vless.NewClient(options.UUID, options.Flow, logger)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -191,7 +189,6 @@ func (h *vlessDialer) DialContext(ctx context.Context, network string, destinati
conn, err = h.transport.DialContext(ctx) conn, err = h.transport.DialContext(ctx)
if err == nil && h.vision { if err == nil && h.vision {
if baseConn == nil { if baseConn == nil {
// Only set baseConn if the transport delivered a TLS-capable connection
if isVisionTLSConn(conn) { if isVisionTLSConn(conn) {
h.logger.Warn("Vision enabled but hook was not called by transport, using fallback") h.logger.Warn("Vision enabled but hook was not called by transport, using fallback")
baseConn = conn baseConn = conn
@@ -210,7 +207,6 @@ func (h *vlessDialer) DialContext(ctx context.Context, network string, destinati
return nil, err return nil, err
} }
// Apply encryption if configured
if h.encryption != nil { if h.encryption != nil {
conn, err = h.encryption.Handshake(conn) conn, err = h.encryption.Handshake(conn)
if err != nil { if err != nil {
@@ -218,36 +214,12 @@ func (h *vlessDialer) DialContext(ctx context.Context, network string, destinati
} }
} }
// For Vision: wrap the connection to expose the TLS/encryption connection for vless client var visionBaseConn net.Conn
var visionBaseConn net.Conn // The connection to pass to Vision (TLS or encryption layer)
var visionCanSplice bool var visionCanSplice bool
if h.vision { if h.vision {
isRAWTransport := h.transport == nil conn, visionBaseConn, visionCanSplice, err = h.setupVision(conn, baseConn)
if err != nil {
if baseConn != nil && !isVisionTLSConn(baseConn) { return nil, err
baseConn = nil
}
if baseConn != nil {
// Has TLS/Reality: use baseConn (TLS connection)
visionBaseConn = baseConn
visionCanSplice = isRAWTransport
conn = newVisionConnWrapper(conn, baseConn)
} else if h.encryption != nil {
// Only has encryption (no TLS/Reality): use encryption layer itself
encConn := findEncryptionLayer(conn)
if encConn != nil {
visionBaseConn = encConn
if h.encryption.IsFullRandomXorMode() {
visionCanSplice = false
} else {
visionCanSplice = isRAWTransport
}
conn = newVisionConnWrapper(conn, encConn)
} else {
return nil, E.New("Vision: failed to find encryption layer")
}
} else {
return nil, E.New("Vision requires either TLS/Reality or Encryption")
} }
} }
@@ -255,8 +227,6 @@ func (h *vlessDialer) DialContext(ctx context.Context, network string, destinati
case N.NetworkTCP: case N.NetworkTCP:
h.logger.InfoContext(ctx, "outbound connection to ", destination) h.logger.InfoContext(ctx, "outbound connection to ", destination)
if h.vision && visionBaseConn != nil { if h.vision && visionBaseConn != nil {
// For Vision, we need to pass the base connection (TLS or encryption layer)
// to prepareConn so it can properly initialize VisionConn
return h.client.DialEarlyConnWithOptions(conn, visionBaseConn, destination, visionCanSplice) return h.client.DialEarlyConnWithOptions(conn, visionBaseConn, destination, visionCanSplice)
} }
return h.client.DialEarlyConn(conn, destination) return h.client.DialEarlyConn(conn, destination)
@@ -281,6 +251,29 @@ func (h *vlessDialer) DialContext(ctx context.Context, network string, destinati
} }
} }
func (h *vlessDialer) setupVision(conn net.Conn, baseConn net.Conn) (net.Conn, net.Conn, bool, error) {
isRAWTransport := h.transport == nil
if baseConn != nil && !isVisionTLSConn(baseConn) {
baseConn = nil
}
if baseConn != nil {
return newVisionConnWrapper(conn, baseConn), baseConn, isRAWTransport, nil
}
if h.encryption != nil {
encConn := findEncryptionLayer(conn)
if encConn == nil {
return nil, nil, false, E.New("Vision: failed to find encryption layer")
}
canSplice := isRAWTransport && !h.encryption.IsFullRandomXorMode()
return newVisionConnWrapper(conn, encConn), encConn, canSplice, nil
}
return nil, nil, false, E.New("Vision requires either TLS/Reality or Encryption")
}
func (h *vlessDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { func (h *vlessDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
h.logger.InfoContext(ctx, "outbound packet connection to ", destination) h.logger.InfoContext(ctx, "outbound packet connection to ", destination)
ctx, metadata := adapter.ExtendContext(ctx) ctx, metadata := adapter.ExtendContext(ctx)
@@ -299,7 +292,6 @@ func (h *vlessDialer) ListenPacket(ctx context.Context, destination M.Socksaddr)
common.Close(conn) common.Close(conn)
return nil, err return nil, err
} }
// Apply encryption if configured
if h.encryption != nil { if h.encryption != nil {
conn, err = h.encryption.Handshake(conn) conn, err = h.encryption.Handshake(conn)
if err != nil { if err != nil {
@@ -362,7 +354,6 @@ func (c *visionConnWrapper) WriterReplaceable() bool {
return true return true
} }
// isVisionTLSConn returns true when the provided connection exposes TLS semantics Vision expects.
func isVisionTLSConn(conn net.Conn) bool { func isVisionTLSConn(conn net.Conn) bool {
if conn == nil { if conn == nil {
return false return false
@@ -373,16 +364,6 @@ func isVisionTLSConn(conn net.Conn) bool {
if _, ok := conn.(interface{ Handshake() error }); ok { if _, ok := conn.(interface{ Handshake() error }); ok {
return true return true
} }
connType := reflect.TypeOf(conn)
if connType == nil {
return false
}
if connType.Kind() == reflect.Ptr {
pkgPath := connType.Elem().PkgPath()
if pkgPath == "crypto/tls" || strings.Contains(pkgPath, "utls") || strings.Contains(pkgPath, "shadowtls") {
return true
}
}
return false return false
} }

View File

@@ -218,7 +218,7 @@ func (s *ProviderRemote) fetch(ctx context.Context) error {
} }
} }
} }
s.logger.Info("update outbound provider ", s.Tag(), ": not modified") s.logger.Notice("update outbound provider ", s.Tag(), ": not modified")
return nil return nil
default: default:
return E.New("unexpected status: ", resp.Status) return E.New("unexpected status: ", resp.Status)
@@ -262,7 +262,7 @@ func (s *ProviderRemote) fetch(ctx context.Context) error {
s.logger.Error("save outbound provider cache file: ", err) s.logger.Error("save outbound provider cache file: ", err)
} }
} }
s.logger.Info("updated outbound provider ", s.Tag()) s.logger.Notice("updated outbound provider ", s.Tag())
return nil return nil
} }

View File

@@ -300,7 +300,7 @@ func (r *NetworkManager) UpdateInterfaces() error {
oldInterface.Expensive == newInterface.Expensive && oldInterface.Expensive == newInterface.Expensive &&
oldInterface.Constrained == newInterface.Constrained oldInterface.Constrained == newInterface.Constrained
}) { }) {
r.logger.Info("updated available networks: ", strings.Join(common.Map(newInterfaces, func(it adapter.NetworkInterface) string { r.logger.Notice("updated available networks: ", strings.Join(common.Map(newInterfaces, func(it adapter.NetworkInterface) string {
var options []string var options []string
options = append(options, F.ToString(it.Type)) options = append(options, F.ToString(it.Type))
if it.Expensive { if it.Expensive {
@@ -430,9 +430,9 @@ func (r *NetworkManager) onWIFIStateChanged(state adapter.WIFIState) {
r.wifiState = state r.wifiState = state
r.wifiStateMutex.Unlock() r.wifiStateMutex.Unlock()
if state.SSID != "" { if state.SSID != "" {
r.logger.Info("WIFI state changed: SSID=", state.SSID, ", BSSID=", state.BSSID) r.logger.Notice("WIFI state changed: SSID=", state.SSID, ", BSSID=", state.BSSID)
} else { } else {
r.logger.Info("WIFI disconnected") r.logger.Notice("WIFI disconnected")
} }
} else { } else {
r.wifiStateMutex.Unlock() r.wifiStateMutex.Unlock()
@@ -512,7 +512,7 @@ func (r *NetworkManager) notifyInterfaceUpdate(defaultInterface *control.Interfa
options = append(options, "constrained") options = append(options, "constrained")
} }
} }
r.logger.Info("updated default interface ", defaultInterface.Name, ", ", strings.Join(options, ", ")) r.logger.Notice("updated default interface ", defaultInterface.Name, ", ", strings.Join(options, ", "))
r.UpdateWIFIState() r.UpdateWIFIState()
if !r.started { if !r.started {
@@ -538,5 +538,5 @@ func (r *NetworkManager) notifyWindowsPowerEvent(event int) {
} }
func (r *NetworkManager) OnPackagesUpdated(packages int, sharedUsers int) { func (r *NetworkManager) OnPackagesUpdated(packages int, sharedUsers int) {
r.logger.Info("updated packages list: ", packages, " packages, ", sharedUsers, " shared users") r.logger.Notice("updated packages list: ", packages, " packages, ", sharedUsers, " shared users")
} }

View File

@@ -277,7 +277,7 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPSta
} }
} }
} }
s.logger.Info("update rule-set ", s.options.Tag, ": not modified") s.logger.Notice("update rule-set ", s.options.Tag, ": not modified")
return nil return nil
default: default:
return E.New("unexpected status: ", response.Status) return E.New("unexpected status: ", response.Status)
@@ -308,7 +308,7 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPSta
s.logger.Error("save rule-set cache: ", err) s.logger.Error("save rule-set cache: ", err)
} }
} }
s.logger.Info("updated rule-set ", s.options.Tag) s.logger.Notice("updated rule-set ", s.options.Tag)
return nil return nil
} }

View File

@@ -42,6 +42,7 @@ export type UserType =
| "mtproxy" | "mtproxy"
| "naive" | "naive"
| "socks" | "socks"
| "ssh"
| "trojan" | "trojan"
| "trusttunnel" | "trusttunnel"
| "tuic" | "tuic"
@@ -57,6 +58,7 @@ export interface User {
uuid: string; uuid: string;
password: string; password: string;
secret: string; secret: string;
authorized_keys: string[];
flow: string; flow: string;
alter_id: number; alter_id: number;
created_at: string; created_at: string;
@@ -70,6 +72,7 @@ export interface UserCreate {
uuid?: string; uuid?: string;
password?: string; password?: string;
secret?: string; secret?: string;
authorized_keys?: string[];
flow?: string; flow?: string;
alter_id?: number; alter_id?: number;
} }
@@ -77,6 +80,7 @@ export interface UserUpdate {
uuid?: string; uuid?: string;
password?: string; password?: string;
secret?: string; secret?: string;
authorized_keys?: string[];
flow?: string; flow?: string;
alter_id?: number; alter_id?: number;
} }

View File

@@ -64,7 +64,7 @@ import type { Listable } from "../api/types";
import { notifyApiError, useNotify } from "../notifications/NotificationsProvider"; import { notifyApiError, useNotify } from "../notifications/NotificationsProvider";
import { PageHeader } from "./PageHeader"; import { PageHeader } from "./PageHeader";
export type FieldType = "text" | "number" | "select" | "multiselect" | "ids" | "uuid"; export type FieldType = "text" | "number" | "select" | "multiselect" | "ids" | "uuid" | "string-list";
// FILTER_WIDTH is the fixed CSS width (px) of a single filter cell in the // FILTER_WIDTH is the fixed CSS width (px) of a single filter cell in the
// filter panel. FILTER_GAP is the flex gap between cells (matches the // filter panel. FILTER_GAP is the flex gap between cells (matches the
@@ -126,7 +126,7 @@ export interface FieldSpec<TValue = unknown> {
// spec, matching what `emptyForm` would produce. // spec, matching what `emptyForm` would produce.
function emptyValueForField(f: FieldSpec): unknown { function emptyValueForField(f: FieldSpec): unknown {
if (f.defaultValue !== undefined) return f.defaultValue; if (f.defaultValue !== undefined) return f.defaultValue;
if (f.type === "multiselect") return []; if (f.type === "multiselect" || f.type === "string-list") return [];
return ""; return "";
} }
@@ -575,7 +575,7 @@ function fieldVisible(
// strings, missing selections, and empty arrays for multi-select / ids. // strings, missing selections, and empty arrays for multi-select / ids.
function isFieldEmpty(f: FieldSpec, value: unknown): boolean { function isFieldEmpty(f: FieldSpec, value: unknown): boolean {
if (value === undefined || value === null) return true; if (value === undefined || value === null) return true;
if (f.type === "multiselect" || f.type === "ids") { if (f.type === "multiselect" || f.type === "ids" || f.type === "string-list") {
if (Array.isArray(value)) return value.length === 0; if (Array.isArray(value)) return value.length === 0;
if (typeof value === "string") return value.trim() === ""; if (typeof value === "string") return value.trim() === "";
return true; return true;
@@ -4764,6 +4764,40 @@ function CrudDialog({
/> />
); );
} }
if (f.type === "string-list") {
const arr = Array.isArray(value) ? (value as string[]) : [];
return (
<Box key={f.name} sx={{ mt: -1 }}>
<Typography variant="caption" color={errored ? "error" : "textSecondary"} sx={{ mb: 0.5, ml: "14px", display: "block" }}>
{f.label}{f.required ? " *" : ""}
</Typography>
<Stack spacing={1}>
{arr.map((item, idx) => (
<Stack key={idx} direction="row" spacing={1} alignItems="center">
<TextField
fullWidth
size="small"
value={item}
placeholder={`Key ${idx + 1}`}
onChange={(e) => {
const next = [...arr];
next[idx] = e.target.value;
set(f.name, next);
}}
/>
<IconButton size="small" color="error" onClick={() => set(f.name, arr.filter((_, i) => i !== idx))}>
<DeleteIcon fontSize="small" />
</IconButton>
</Stack>
))}
</Stack>
<Button size="small" startIcon={<AddIcon />} sx={{ mt: 1 }} onClick={() => set(f.name, [...arr, ""])}>
Add
</Button>
{fieldErr && <Typography variant="caption" color="error" sx={{ mt: 0.5, display: "block" }}>{fieldErr}</Typography>}
</Box>
);
}
const isNumber = f.type === "number"; const isNumber = f.type === "number";
// Numeric value of the current cell. Falls back to 0 for the // Numeric value of the current cell. Falls back to 0 for the
// empty state so the up-arrow always has a sensible base to // empty state so the up-arrow always has a sensible base to

View File

@@ -25,6 +25,7 @@ const USER_TYPES: { value: UserType; label: string }[] = [
{ value: "mtproxy", label: "MTProxy" }, { value: "mtproxy", label: "MTProxy" },
{ value: "naive", label: "Naive" }, { value: "naive", label: "Naive" },
{ value: "socks", label: "SOCKS" }, { value: "socks", label: "SOCKS" },
{ value: "ssh", label: "SSH" },
{ value: "trojan", label: "Trojan" }, { value: "trojan", label: "Trojan" },
{ value: "trusttunnel", label: "TrustTunnel" }, { value: "trusttunnel", label: "TrustTunnel" },
{ value: "tuic", label: "TUIC" }, { value: "tuic", label: "TUIC" },
@@ -44,8 +45,9 @@ const FLOW_OPTIONS: { value: string; label: string }[] = [
// same rule up-front (required fields invisible for the current type // same rule up-front (required fields invisible for the current type
// are filtered out before validateRequired runs). // are filtered out before validateRequired runs).
const SHOW_UUID = new Set<UserType>(["vless", "vmess", "tuic"]); const SHOW_UUID = new Set<UserType>(["vless", "vmess", "tuic"]);
const SHOW_PASSWORD = new Set<UserType>(["anytls", "http", "hysteria", "hysteria2", "mixed", "naive", "socks", "trojan", "trusttunnel", "tuic"]); const SHOW_PASSWORD = new Set<UserType>(["anytls", "http", "hysteria", "hysteria2", "mixed", "naive", "socks", "ssh", "trojan", "trusttunnel", "tuic"]);
const SHOW_SECRET = new Set<UserType>(["mtproxy"]); const SHOW_SECRET = new Set<UserType>(["mtproxy"]);
const SHOW_AUTHORIZED_KEYS = new Set<UserType>(["ssh"]);
const SHOW_FLOW = new Set<UserType>(["vless"]); const SHOW_FLOW = new Set<UserType>(["vless"]);
const SHOW_ALTER_ID = new Set<UserType>(["vmess"]); const SHOW_ALTER_ID = new Set<UserType>(["vmess"]);
@@ -103,7 +105,7 @@ export function UsersPage() {
options: USER_TYPES, options: USER_TYPES,
// Switching the user type wipes every credential field so the form // Switching the user type wipes every credential field so the form
// matches the legacy admin's behaviour of starting fresh. // matches the legacy admin's behaviour of starting fresh.
clears: ["uuid", "password", "secret", "flow", "alter_id"], clears: ["uuid", "password", "secret", "authorized_keys", "flow", "alter_id"],
}, },
// Credential fields: the Go struct validator reports "required" for // Credential fields: the Go struct validator reports "required" for
// whichever of these is missing once the type is chosen, so each one // whichever of these is missing once the type is chosen, so each one
@@ -111,8 +113,9 @@ export function UsersPage() {
// are filtered out before validateRequired runs, so e.g. Password is // are filtered out before validateRequired runs, so e.g. Password is
// only enforced for hysteria/hysteria2/trojan/tuic and not for vless. // only enforced for hysteria/hysteria2/trojan/tuic and not for vless.
{ name: "uuid", label: "UUID", type: "uuid", required: true, visibleWhen: showFor(SHOW_UUID) }, { name: "uuid", label: "UUID", type: "uuid", required: true, visibleWhen: showFor(SHOW_UUID) },
{ name: "password", label: "Password", type: "text", required: true, visibleWhen: showFor(SHOW_PASSWORD) }, { name: "password", label: "Password", type: "text", visibleWhen: showFor(SHOW_PASSWORD) },
{ name: "secret", label: "Secret", type: "text", required: true, visibleWhen: showFor(SHOW_SECRET) }, { name: "secret", label: "Secret", type: "text", required: true, visibleWhen: showFor(SHOW_SECRET) },
{ name: "authorized_keys", label: "Authorized Keys", type: "string-list", visibleWhen: showFor(SHOW_AUTHORIZED_KEYS) },
{ {
name: "flow", name: "flow",
label: "Flow", label: "Flow",
@@ -135,6 +138,7 @@ export function UsersPage() {
uuid: u.uuid, uuid: u.uuid,
password: u.password, password: u.password,
secret: u.secret, secret: u.secret,
authorized_keys: u.authorized_keys ?? [],
flow: u.flow, flow: u.flow,
alter_id: u.alter_id, alter_id: u.alter_id,
}), }),
@@ -146,6 +150,7 @@ export function UsersPage() {
uuid: f.uuid ? String(f.uuid).trim() : undefined, uuid: f.uuid ? String(f.uuid).trim() : undefined,
password: f.password ? String(f.password) : undefined, password: f.password ? String(f.password) : undefined,
secret: f.secret ? String(f.secret) : undefined, secret: f.secret ? String(f.secret) : undefined,
authorized_keys: Array.isArray(f.authorized_keys) ? (f.authorized_keys as string[]).filter(Boolean) : undefined,
flow: f.flow ? String(f.flow) : undefined, flow: f.flow ? String(f.flow) : undefined,
alter_id: f.alter_id !== undefined && f.alter_id !== "" ? Number(f.alter_id) : undefined, alter_id: f.alter_id !== undefined && f.alter_id !== "" ? Number(f.alter_id) : undefined,
}), }),
@@ -154,6 +159,7 @@ export function UsersPage() {
if (f.uuid && String(f.uuid).trim() !== "") out.uuid = String(f.uuid).trim(); if (f.uuid && String(f.uuid).trim() !== "") out.uuid = String(f.uuid).trim();
if (f.password !== undefined && f.password !== "") out.password = String(f.password); if (f.password !== undefined && f.password !== "") out.password = String(f.password);
if (f.secret !== undefined && f.secret !== "") out.secret = String(f.secret); if (f.secret !== undefined && f.secret !== "") out.secret = String(f.secret);
if (Array.isArray(f.authorized_keys) && (f.authorized_keys as string[]).filter(Boolean).length > 0) out.authorized_keys = (f.authorized_keys as string[]).filter(Boolean);
if (f.flow !== undefined && f.flow !== "") out.flow = String(f.flow); if (f.flow !== undefined && f.flow !== "") out.flow = String(f.flow);
if (f.alter_id !== undefined && f.alter_id !== "") out.alter_id = Number(f.alter_id); if (f.alter_id !== undefined && f.alter_id !== "") out.alter_id = Number(f.alter_id);
return out; return out;

View File

@@ -49,46 +49,50 @@ type BaseNode struct {
} }
type User struct { type User struct {
ID int `json:"id" validate:"required"` ID int `json:"id" validate:"required"`
SquadIDs []int `json:"squad_ids" validate:"required,min=1"` SquadIDs []int `json:"squad_ids" validate:"required,min=1"`
Username string `json:"username" validate:"required"` Username string `json:"username" validate:"required"`
Inbound string `json:"inbound" validate:"required"` Inbound string `json:"inbound" validate:"required"`
Type string `json:"type" validate:"required"` Type string `json:"type" validate:"required"`
UUID string `json:"uuid" validate:"required"` UUID string `json:"uuid" validate:"required"`
Password string `json:"password" validate:"required"` Password string `json:"password" validate:"required"`
Secret string `json:"secret" validate:"required"` Secret string `json:"secret" validate:"required"`
Flow string `json:"flow" validate:"required"` AuthorizedKeys []string `json:"authorized_keys" validate:"omitempty"`
AlterID int `json:"alter_id" validate:"required"` Flow string `json:"flow" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required"` AlterID int `json:"alter_id" validate:"required"`
UpdatedAt time.Time `json:"updated_at" validate:"required"` CreatedAt time.Time `json:"created_at" validate:"required"`
UpdatedAt time.Time `json:"updated_at" validate:"required"`
} }
type UserCreate struct { type UserCreate struct {
SquadIDs []int `json:"squad_ids" validate:"required,min=1"` SquadIDs []int `json:"squad_ids" validate:"required,min=1"`
Username string `json:"username" validate:"required"` Username string `json:"username" validate:"required"`
Inbound string `json:"inbound" validate:"required"` Inbound string `json:"inbound" validate:"required"`
Type string `json:"type" validate:"required,oneof=anytls http hysteria hysteria2 mixed mtproxy naive socks trojan trusttunnel tuic vless vmess"` Type string `json:"type" validate:"required,oneof=anytls http hysteria hysteria2 mixed mtproxy naive socks ssh trojan trusttunnel tuic vless vmess"`
UUID string `json:"uuid" validate:"omitempty,uuid4"` UUID string `json:"uuid" validate:"omitempty,uuid4"`
Password string `json:"password" validate:"omitempty"` Password string `json:"password" validate:"omitempty"`
Secret string `json:"secret" validate:"omitempty"` Secret string `json:"secret" validate:"omitempty"`
Flow string `json:"flow" validate:"omitempty"` AuthorizedKeys []string `json:"authorized_keys" validate:"omitempty"`
AlterID int `json:"alter_id" validate:"omitempty"` Flow string `json:"flow" validate:"omitempty"`
AlterID int `json:"alter_id" validate:"omitempty"`
} }
type UserUpdate struct { type UserUpdate struct {
UUID string `json:"uuid" validate:"omitempty,uuid4"` UUID string `json:"uuid" validate:"omitempty,uuid4"`
Password string `json:"password" validate:"omitempty"` Password string `json:"password" validate:"omitempty"`
Secret string `json:"secret" validate:"omitempty"` Secret string `json:"secret" validate:"omitempty"`
Flow string `json:"flow" validate:"omitempty"` AuthorizedKeys []string `json:"authorized_keys" validate:"omitempty"`
AlterID int `json:"alter_id" validate:"omitempty"` Flow string `json:"flow" validate:"omitempty"`
AlterID int `json:"alter_id" validate:"omitempty"`
} }
type BaseUser struct { type BaseUser struct {
UUID string `json:"uuid" validate:"omitempty,uuid4"` UUID string `json:"uuid" validate:"omitempty,uuid4"`
Password string `json:"password" validate:"omitempty"` Password string `json:"password" validate:"omitempty"`
Secret string `json:"secret" validate:"omitempty"` Secret string `json:"secret" validate:"omitempty"`
Flow string `json:"flow" validate:"omitempty"` AuthorizedKeys []string `json:"authorized_keys" validate:"omitempty"`
AlterID int `json:"alter_id" validate:"omitempty"` Flow string `json:"flow" validate:"omitempty"`
AlterID int `json:"alter_id" validate:"omitempty"`
} }
type ConnectionLimiter struct { type ConnectionLimiter struct {
@@ -261,5 +265,3 @@ type BaseRateLimiter struct {
Count uint32 `json:"count" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"` Count uint32 `json:"count" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
Interval string `json:"interval" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"` Interval string `json:"interval" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"`
} }

View File

@@ -51,5 +51,3 @@ type Repository interface {
UpdateRateLimiter(id int, limiter RateLimiterUpdate) (RateLimiter, error) UpdateRateLimiter(id int, limiter RateLimiterUpdate) (RateLimiter, error)
DeleteRateLimiter(id int) (RateLimiter, error) DeleteRateLimiter(id int) (RateLimiter, error)
} }

View File

@@ -331,6 +331,12 @@ var migrations = map[string]string{
DROP TABLE IF EXISTS traffic_limiter_to_squad; DROP TABLE IF EXISTS traffic_limiter_to_squad;
DROP TABLE IF EXISTS traffic_limiters; DROP TABLE IF EXISTS traffic_limiters;
`, `,
"3_add_authorized_keys.up.sql": `
ALTER TABLE users ADD COLUMN authorized_keys JSONB NOT NULL DEFAULT '[]'::jsonb;
`,
"3_add_authorized_keys.down.sql": `
ALTER TABLE users DROP COLUMN authorized_keys;
`,
} }
func Migrate(db *sql.DB) error { func Migrate(db *sql.DB) error {

View File

@@ -515,6 +515,11 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us
} }
defer tx.Rollback(r.ctx) defer tx.Rollback(r.ctx)
now := time.Now() now := time.Now()
authorizedKeysJSON, err := marshalStringSlice(user.AuthorizedKeys)
if err != nil {
return u, err
}
var authorizedKeys stringSliceJSON
err = tx.QueryRow( err = tx.QueryRow(
r.ctx, ` r.ctx, `
INSERT INTO users ( INSERT INTO users (
@@ -524,12 +529,13 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us
uuid, uuid,
password, password,
secret, secret,
authorized_keys,
flow, flow,
alter_id, alter_id,
created_at, created_at,
updated_at updated_at
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING RETURNING
id, id,
username, username,
@@ -538,6 +544,7 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us
uuid, uuid,
password, password,
secret, secret,
authorized_keys,
flow, flow,
alter_id, alter_id,
created_at, created_at,
@@ -549,6 +556,7 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us
user.UUID, user.UUID,
user.Password, user.Password,
user.Secret, user.Secret,
authorizedKeysJSON,
user.Flow, user.Flow,
user.AlterID, user.AlterID,
now, now,
@@ -561,6 +569,7 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us
&u.UUID, &u.UUID,
&u.Password, &u.Password,
&u.Secret, &u.Secret,
&authorizedKeys,
&u.Flow, &u.Flow,
&u.AlterID, &u.AlterID,
&u.CreatedAt, &u.CreatedAt,
@@ -569,6 +578,7 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us
if err != nil { if err != nil {
return u, err return u, err
} }
u.AuthorizedKeys = []string(authorizedKeys)
rows := make([][]any, len(user.SquadIDs)) rows := make([][]any, len(user.SquadIDs))
for i, squadID := range user.SquadIDs { for i, squadID := range user.SquadIDs {
rows[i] = []any{u.ID, squadID} rows[i] = []any{u.ID, squadID}
@@ -605,6 +615,7 @@ func (r *PostgreSQLRepository) GetUsers(filters map[string][]string) ([]constant
"uuid", "uuid",
"password", "password",
"secret", "secret",
"authorized_keys",
"flow", "flow",
"alter_id", "alter_id",
"created_at", "created_at",
@@ -636,6 +647,7 @@ func (r *PostgreSQLRepository) GetUsers(filters map[string][]string) ([]constant
&u.UUID, &u.UUID,
&u.Password, &u.Password,
&u.Secret, &u.Secret,
&u.AuthorizedKeys,
&u.Flow, &u.Flow,
&u.AlterID, &u.AlterID,
&u.CreatedAt, &u.CreatedAt,
@@ -681,6 +693,7 @@ func (r *PostgreSQLRepository) GetUser(id int) (constant.User, error) {
uuid, uuid,
password, password,
secret, secret,
authorized_keys,
flow, flow,
alter_id, alter_id,
created_at, created_at,
@@ -696,6 +709,7 @@ func (r *PostgreSQLRepository) GetUser(id int) (constant.User, error) {
&u.UUID, &u.UUID,
&u.Password, &u.Password,
&u.Secret, &u.Secret,
&u.AuthorizedKeys,
&u.Flow, &u.Flow,
&u.AlterID, &u.AlterID,
&u.CreatedAt, &u.CreatedAt,
@@ -706,17 +720,22 @@ func (r *PostgreSQLRepository) GetUser(id int) (constant.User, error) {
func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (constant.User, error) { func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (constant.User, error) {
var u constant.User var u constant.User
err := r.db.QueryRow( authorizedKeysJSON, err := marshalStringSlice(user.AuthorizedKeys)
if err != nil {
return u, err
}
err = r.db.QueryRow(
r.ctx, ` r.ctx, `
UPDATE users UPDATE users
SET SET
uuid = $1, uuid = $1,
password = $2, password = $2,
secret = $3, secret = $3,
flow = $4, authorized_keys = $4,
alter_id = $5, flow = $5,
updated_at = $6 alter_id = $6,
WHERE id = $7 updated_at = $7
WHERE id = $8
RETURNING RETURNING
id, id,
ARRAY( ARRAY(
@@ -730,6 +749,7 @@ func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (con
uuid, uuid,
password, password,
secret, secret,
authorized_keys,
flow, flow,
alter_id, alter_id,
created_at, created_at,
@@ -738,6 +758,7 @@ func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (con
user.UUID, user.UUID,
user.Password, user.Password,
user.Secret, user.Secret,
authorizedKeysJSON,
user.Flow, user.Flow,
user.AlterID, user.AlterID,
time.Now(), time.Now(),
@@ -751,6 +772,7 @@ func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (con
&u.UUID, &u.UUID,
&u.Password, &u.Password,
&u.Secret, &u.Secret,
&u.AuthorizedKeys,
&u.Flow, &u.Flow,
&u.AlterID, &u.AlterID,
&u.CreatedAt, &u.CreatedAt,
@@ -777,6 +799,7 @@ func (r *PostgreSQLRepository) DeleteUser(id int) (constant.User, error) {
uuid, uuid,
password, password,
secret, secret,
authorized_keys,
flow, flow,
alter_id, alter_id,
created_at, created_at,
@@ -790,6 +813,7 @@ func (r *PostgreSQLRepository) DeleteUser(id int) (constant.User, error) {
&u.UUID, &u.UUID,
&u.Password, &u.Password,
&u.Secret, &u.Secret,
&u.AuthorizedKeys,
&u.Flow, &u.Flow,
&u.AlterID, &u.AlterID,
&u.CreatedAt, &u.CreatedAt,
@@ -2143,11 +2167,11 @@ func init() {
"updated_at_end": LessThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"),
"sort_asc": ReplacedSortAscFilter( "sort_asc": ReplacedSortAscFilter(
map[string]string{"speed": "raw_speed"}, map[string]string{"speed": "raw_speed"},
[]string{"id", "username", "outbound", "strategy", "mode", "raw_speed", "created_at", "updated_at"}, []string{"id", "username", "outbound", "strategy", "connection_type", "mode", "raw_speed", "created_at", "updated_at"},
), ),
"sort_desc": ReplacedSortDescFilter( "sort_desc": ReplacedSortDescFilter(
map[string]string{"speed": "raw_speed"}, map[string]string{"speed": "raw_speed"},
[]string{"id", "username", "outbound", "strategy", "mode", "raw_speed", "created_at", "updated_at"}, []string{"id", "username", "outbound", "strategy", "connection_type", "mode", "raw_speed", "created_at", "updated_at"},
), ),
"offset": OffsetFilter(), "offset": OffsetFilter(),
"limit": LimitFilter(), "limit": LimitFilter(),

View File

@@ -213,6 +213,12 @@ var migrations = map[string]string{
DROP TABLE IF EXISTS nodes; DROP TABLE IF EXISTS nodes;
DROP TABLE IF EXISTS squads; DROP TABLE IF EXISTS squads;
`, `,
"2_add_authorized_keys.up.sql": `
ALTER TABLE users ADD COLUMN authorized_keys TEXT NOT NULL DEFAULT '[]';
`,
"2_add_authorized_keys.down.sql": `
ALTER TABLE users DROP COLUMN authorized_keys;
`,
} }
func Migrate(db *sql.DB) error { func Migrate(db *sql.DB) error {

View File

@@ -510,6 +510,11 @@ func (r *SQLiteRepository) CreateUser(user constant.UserCreate) (constant.User,
} }
defer tx.Rollback() defer tx.Rollback()
now := time.Now() now := time.Now()
authorizedKeysJSON, err := marshalStringSlice(user.AuthorizedKeys)
if err != nil {
return u, err
}
var authorizedKeys stringSliceJSON
err = tx.QueryRowContext( err = tx.QueryRowContext(
r.ctx, ` r.ctx, `
INSERT INTO users ( INSERT INTO users (
@@ -519,12 +524,13 @@ func (r *SQLiteRepository) CreateUser(user constant.UserCreate) (constant.User,
uuid, uuid,
password, password,
secret, secret,
authorized_keys,
flow, flow,
alter_id, alter_id,
created_at, created_at,
updated_at updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING RETURNING
id, id,
username, username,
@@ -533,6 +539,7 @@ func (r *SQLiteRepository) CreateUser(user constant.UserCreate) (constant.User,
uuid, uuid,
password, password,
secret, secret,
authorized_keys,
flow, flow,
alter_id, alter_id,
created_at, created_at,
@@ -544,6 +551,7 @@ func (r *SQLiteRepository) CreateUser(user constant.UserCreate) (constant.User,
user.UUID, user.UUID,
user.Password, user.Password,
user.Secret, user.Secret,
authorizedKeysJSON,
user.Flow, user.Flow,
user.AlterID, user.AlterID,
now, now,
@@ -556,6 +564,7 @@ func (r *SQLiteRepository) CreateUser(user constant.UserCreate) (constant.User,
&u.UUID, &u.UUID,
&u.Password, &u.Password,
&u.Secret, &u.Secret,
&authorizedKeys,
&u.Flow, &u.Flow,
&u.AlterID, &u.AlterID,
&u.CreatedAt, &u.CreatedAt,
@@ -564,6 +573,7 @@ func (r *SQLiteRepository) CreateUser(user constant.UserCreate) (constant.User,
if err != nil { if err != nil {
return u, err return u, err
} }
u.AuthorizedKeys = []string(authorizedKeys)
stmt, err := tx.PrepareContext(r.ctx, `INSERT INTO user_to_squad (user_id, squad_id) VALUES (?, ?)`) stmt, err := tx.PrepareContext(r.ctx, `INSERT INTO user_to_squad (user_id, squad_id) VALUES (?, ?)`)
if err != nil { if err != nil {
return u, err return u, err
@@ -596,6 +606,7 @@ func (r *SQLiteRepository) GetUsers(filters map[string][]string) ([]constant.Use
"uuid", "uuid",
"password", "password",
"secret", "secret",
"authorized_keys",
"flow", "flow",
"alter_id", "alter_id",
"created_at", "created_at",
@@ -619,6 +630,7 @@ func (r *SQLiteRepository) GetUsers(filters map[string][]string) ([]constant.Use
for rows.Next() { for rows.Next() {
var u constant.User var u constant.User
var squadIDs intSliceJSON var squadIDs intSliceJSON
var authorizedKeys stringSliceJSON
if err := rows.Scan( if err := rows.Scan(
&u.ID, &u.ID,
&squadIDs, &squadIDs,
@@ -628,6 +640,7 @@ func (r *SQLiteRepository) GetUsers(filters map[string][]string) ([]constant.Use
&u.UUID, &u.UUID,
&u.Password, &u.Password,
&u.Secret, &u.Secret,
&authorizedKeys,
&u.Flow, &u.Flow,
&u.AlterID, &u.AlterID,
&u.CreatedAt, &u.CreatedAt,
@@ -636,6 +649,7 @@ func (r *SQLiteRepository) GetUsers(filters map[string][]string) ([]constant.Use
return nil, err return nil, err
} }
u.SquadIDs = []int(squadIDs) u.SquadIDs = []int(squadIDs)
u.AuthorizedKeys = []string(authorizedKeys)
result = append(result, u) result = append(result, u)
} }
return result, rows.Err() return result, rows.Err()
@@ -661,6 +675,7 @@ func (r *SQLiteRepository) GetUsersCount(filters map[string][]string) (int, erro
func (r *SQLiteRepository) GetUser(id int) (constant.User, error) { func (r *SQLiteRepository) GetUser(id int) (constant.User, error) {
var u constant.User var u constant.User
var squadIDs intSliceJSON var squadIDs intSliceJSON
var authorizedKeys stringSliceJSON
err := r.db.QueryRowContext(r.ctx, ` err := r.db.QueryRowContext(r.ctx, `
SELECT SELECT
id, id,
@@ -675,6 +690,7 @@ func (r *SQLiteRepository) GetUser(id int) (constant.User, error) {
uuid, uuid,
password, password,
secret, secret,
authorized_keys,
flow, flow,
alter_id, alter_id,
created_at, created_at,
@@ -690,25 +706,33 @@ func (r *SQLiteRepository) GetUser(id int) (constant.User, error) {
&u.UUID, &u.UUID,
&u.Password, &u.Password,
&u.Secret, &u.Secret,
&authorizedKeys,
&u.Flow, &u.Flow,
&u.AlterID, &u.AlterID,
&u.CreatedAt, &u.CreatedAt,
&u.UpdatedAt, &u.UpdatedAt,
) )
u.SquadIDs = []int(squadIDs) u.SquadIDs = []int(squadIDs)
u.AuthorizedKeys = []string(authorizedKeys)
return u, notFoundErr(err) return u, notFoundErr(err)
} }
func (r *SQLiteRepository) UpdateUser(id int, user constant.UserUpdate) (constant.User, error) { func (r *SQLiteRepository) UpdateUser(id int, user constant.UserUpdate) (constant.User, error) {
var u constant.User var u constant.User
var squadIDs intSliceJSON var squadIDs intSliceJSON
err := r.db.QueryRowContext( var authorizedKeys stringSliceJSON
authorizedKeysJSON, err := marshalStringSlice(user.AuthorizedKeys)
if err != nil {
return u, err
}
err = r.db.QueryRowContext(
r.ctx, ` r.ctx, `
UPDATE users UPDATE users
SET SET
uuid = ?, uuid = ?,
password = ?, password = ?,
secret = ?, secret = ?,
authorized_keys = ?,
flow = ?, flow = ?,
alter_id = ?, alter_id = ?,
updated_at = ? updated_at = ?
@@ -726,6 +750,7 @@ func (r *SQLiteRepository) UpdateUser(id int, user constant.UserUpdate) (constan
uuid, uuid,
password, password,
secret, secret,
authorized_keys,
flow, flow,
alter_id, alter_id,
created_at, created_at,
@@ -734,6 +759,7 @@ func (r *SQLiteRepository) UpdateUser(id int, user constant.UserUpdate) (constan
user.UUID, user.UUID,
user.Password, user.Password,
user.Secret, user.Secret,
authorizedKeysJSON,
user.Flow, user.Flow,
user.AlterID, user.AlterID,
time.Now(), time.Now(),
@@ -747,18 +773,21 @@ func (r *SQLiteRepository) UpdateUser(id int, user constant.UserUpdate) (constan
&u.UUID, &u.UUID,
&u.Password, &u.Password,
&u.Secret, &u.Secret,
&authorizedKeys,
&u.Flow, &u.Flow,
&u.AlterID, &u.AlterID,
&u.CreatedAt, &u.CreatedAt,
&u.UpdatedAt, &u.UpdatedAt,
) )
u.SquadIDs = []int(squadIDs) u.SquadIDs = []int(squadIDs)
u.AuthorizedKeys = []string(authorizedKeys)
return u, err return u, err
} }
func (r *SQLiteRepository) DeleteUser(id int) (constant.User, error) { func (r *SQLiteRepository) DeleteUser(id int) (constant.User, error) {
var u constant.User var u constant.User
var squadIDs intSliceJSON var squadIDs intSliceJSON
var authorizedKeys stringSliceJSON
err := r.db.QueryRowContext(r.ctx, ` err := r.db.QueryRowContext(r.ctx, `
DELETE FROM users DELETE FROM users
WHERE id = ? WHERE id = ?
@@ -775,6 +804,7 @@ func (r *SQLiteRepository) DeleteUser(id int) (constant.User, error) {
uuid, uuid,
password, password,
secret, secret,
authorized_keys,
flow, flow,
alter_id, alter_id,
created_at, created_at,
@@ -788,12 +818,14 @@ func (r *SQLiteRepository) DeleteUser(id int) (constant.User, error) {
&u.UUID, &u.UUID,
&u.Password, &u.Password,
&u.Secret, &u.Secret,
&authorizedKeys,
&u.Flow, &u.Flow,
&u.AlterID, &u.AlterID,
&u.CreatedAt, &u.CreatedAt,
&u.UpdatedAt, &u.UpdatedAt,
) )
u.SquadIDs = []int(squadIDs) u.SquadIDs = []int(squadIDs)
u.AuthorizedKeys = []string(authorizedKeys)
return u, err return u, err
} }
@@ -2160,11 +2192,11 @@ func init() {
"updated_at_end": LessThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"),
"sort_asc": ReplacedSortAscFilter( "sort_asc": ReplacedSortAscFilter(
map[string]string{"speed": "raw_speed"}, map[string]string{"speed": "raw_speed"},
[]string{"id", "username", "outbound", "strategy", "mode", "raw_speed", "created_at", "updated_at"}, []string{"id", "username", "outbound", "strategy", "connection_type", "mode", "raw_speed", "created_at", "updated_at"},
), ),
"sort_desc": ReplacedSortDescFilter( "sort_desc": ReplacedSortDescFilter(
map[string]string{"speed": "raw_speed"}, map[string]string{"speed": "raw_speed"},
[]string{"id", "username", "outbound", "strategy", "mode", "raw_speed", "created_at", "updated_at"}, []string{"id", "username", "outbound", "strategy", "connection_type", "mode", "raw_speed", "created_at", "updated_at"},
), ),
"offset": OffsetFilter(), "offset": OffsetFilter(),
"limit": LimitFilter(), "limit": LimitFilter(),

View File

@@ -32,10 +32,10 @@ func RegisterService(registry *boxService.Registry) {
type Service struct { type Service struct {
boxService.Adapter boxService.Adapter
ctx context.Context ctx context.Context
logger log.ContextLogger logger log.ContextLogger
repository constant.Repository repository constant.Repository
nodes map[string]constant.ConnectedNode nodes map[string]constant.ConnectedNode
limiterLocks map[int]map[string]*cache.Cache[string, struct{}] limiterLocks map[int]map[string]*cache.Cache[string, struct{}]
trafficUsage map[int]*TrafficUsage trafficUsage map[int]*TrafficUsage
@@ -93,6 +93,10 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
if user.Password == "" { if user.Password == "" {
sl.ReportError(user.Password, "password", "Password", "required", "") sl.ReportError(user.Password, "password", "Password", "required", "")
} }
case "ssh":
if user.Password == "" && len(user.AuthorizedKeys) == 0 {
sl.ReportError(user.Password, "password", "Password", "required_without", "")
}
case "mtproxy": case "mtproxy":
if user.Secret == "" { if user.Secret == "" {
sl.ReportError(user.Secret, "secret", "Secret", "required", "") sl.ReportError(user.Secret, "secret", "Secret", "required", "")

View File

@@ -59,18 +59,19 @@ func convertNode(v *pb.Node) CM.Node {
func convertUser(v *pb.User) CM.User { func convertUser(v *pb.User) CM.User {
return CM.User{ return CM.User{
ID: int(v.GetId()), ID: int(v.GetId()),
SquadIDs: toIntSlice(v.GetSquadIds()), SquadIDs: toIntSlice(v.GetSquadIds()),
Username: v.GetUsername(), Username: v.GetUsername(),
Inbound: v.GetInbound(), Inbound: v.GetInbound(),
Type: v.GetType(), Type: v.GetType(),
UUID: v.GetUuid(), UUID: v.GetUuid(),
Password: v.GetPassword(), Password: v.GetPassword(),
Secret: v.GetSecret(), Secret: v.GetSecret(),
Flow: v.GetFlow(), AuthorizedKeys: v.GetAuthorizedKeys(),
AlterID: int(v.GetAlterId()), Flow: v.GetFlow(),
CreatedAt: timeFromNano(v.GetCreatedAt()), AlterID: int(v.GetAlterId()),
UpdatedAt: timeFromNano(v.GetUpdatedAt()), CreatedAt: timeFromNano(v.GetCreatedAt()),
UpdatedAt: timeFromNano(v.GetUpdatedAt()),
} }
} }
@@ -83,7 +84,7 @@ func convertBandwidthLimiter(v *pb.BandwidthLimiter) CM.BandwidthLimiter {
Strategy: v.GetStrategy(), Strategy: v.GetStrategy(),
ConnectionType: v.GetConnectionType(), ConnectionType: v.GetConnectionType(),
Mode: v.GetMode(), Mode: v.GetMode(),
FlowKeys: v.GetFlowKeys(), FlowKeys: v.GetFlowKeys(),
Speed: v.GetSpeed(), Speed: v.GetSpeed(),
RawSpeed: v.GetRawSpeed(), RawSpeed: v.GetRawSpeed(),
CreatedAt: timeFromNano(v.GetCreatedAt()), CreatedAt: timeFromNano(v.GetCreatedAt()),

View File

@@ -188,15 +188,16 @@ func (s *Client) CreateUser(in CM.UserCreate) (CM.User, error) {
return CM.User{}, err return CM.User{}, err
} }
reply, err := c.CreateUser(s.callContext(), &pb.UserCreate{ reply, err := c.CreateUser(s.callContext(), &pb.UserCreate{
SquadIds: toInt32Slice(in.SquadIDs), SquadIds: toInt32Slice(in.SquadIDs),
Username: in.Username, Username: in.Username,
Inbound: in.Inbound, Inbound: in.Inbound,
Type: in.Type, Type: in.Type,
Uuid: in.UUID, Uuid: in.UUID,
Password: in.Password, Password: in.Password,
Secret: in.Secret, Secret: in.Secret,
Flow: in.Flow, AuthorizedKeys: in.AuthorizedKeys,
AlterId: int32(in.AlterID), Flow: in.Flow,
AlterId: int32(in.AlterID),
}) })
if err != nil { if err != nil {
return CM.User{}, mapError(err) return CM.User{}, mapError(err)
@@ -252,11 +253,12 @@ func (s *Client) UpdateUser(id int, in CM.UserUpdate) (CM.User, error) {
reply, err := c.UpdateUser(s.callContext(), &pb.UserUpdateRequest{ reply, err := c.UpdateUser(s.callContext(), &pb.UserUpdateRequest{
Id: int32(id), Id: int32(id),
Update: &pb.UserUpdate{ Update: &pb.UserUpdate{
Uuid: in.UUID, Uuid: in.UUID,
Password: in.Password, Password: in.Password,
Secret: in.Secret, Secret: in.Secret,
Flow: in.Flow, AuthorizedKeys: in.AuthorizedKeys,
AlterId: int32(in.AlterID), Flow: in.Flow,
AlterId: int32(in.AlterID),
}, },
}) })
if err != nil { if err != nil {
@@ -289,7 +291,7 @@ func (s *Client) CreateBandwidthLimiter(in CM.BandwidthLimiterCreate) (CM.Bandwi
Strategy: in.Strategy, Strategy: in.Strategy,
ConnectionType: in.ConnectionType, ConnectionType: in.ConnectionType,
Mode: in.Mode, Mode: in.Mode,
FlowKeys: in.FlowKeys, FlowKeys: in.FlowKeys,
Speed: in.Speed, Speed: in.Speed,
}) })
if err != nil { if err != nil {
@@ -351,7 +353,7 @@ func (s *Client) UpdateBandwidthLimiter(id int, in CM.BandwidthLimiterUpdate) (C
Strategy: in.Strategy, Strategy: in.Strategy,
ConnectionType: in.ConnectionType, ConnectionType: in.ConnectionType,
Mode: in.Mode, Mode: in.Mode,
FlowKeys: in.FlowKeys, FlowKeys: in.FlowKeys,
Speed: in.Speed, Speed: in.Speed,
}, },
}) })

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v6.31.1 // protoc v7.34.1
// source: service/manager_api/grpc/manager/manager.proto // source: service/manager_api/grpc/manager/manager.proto
package manager package manager
@@ -550,21 +550,22 @@ func (x *NodeUpdateRequest) GetUpdate() *NodeUpdate {
} }
type User struct { type User struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
SquadIds []int32 `protobuf:"varint,2,rep,packed,name=squad_ids,json=squadIds,proto3" json:"squad_ids,omitempty"` SquadIds []int32 `protobuf:"varint,2,rep,packed,name=squad_ids,json=squadIds,proto3" json:"squad_ids,omitempty"`
Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"`
Inbound string `protobuf:"bytes,4,opt,name=inbound,proto3" json:"inbound,omitempty"` Inbound string `protobuf:"bytes,4,opt,name=inbound,proto3" json:"inbound,omitempty"`
Type string `protobuf:"bytes,5,opt,name=type,proto3" json:"type,omitempty"` Type string `protobuf:"bytes,5,opt,name=type,proto3" json:"type,omitempty"`
Uuid string `protobuf:"bytes,6,opt,name=uuid,proto3" json:"uuid,omitempty"` Uuid string `protobuf:"bytes,6,opt,name=uuid,proto3" json:"uuid,omitempty"`
Password string `protobuf:"bytes,7,opt,name=password,proto3" json:"password,omitempty"` Password string `protobuf:"bytes,7,opt,name=password,proto3" json:"password,omitempty"`
Secret string `protobuf:"bytes,8,opt,name=secret,proto3" json:"secret,omitempty"` Secret string `protobuf:"bytes,8,opt,name=secret,proto3" json:"secret,omitempty"`
Flow string `protobuf:"bytes,9,opt,name=flow,proto3" json:"flow,omitempty"` AuthorizedKeys []string `protobuf:"bytes,13,rep,name=authorized_keys,json=authorizedKeys,proto3" json:"authorized_keys,omitempty"`
AlterId int32 `protobuf:"varint,10,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"` Flow string `protobuf:"bytes,9,opt,name=flow,proto3" json:"flow,omitempty"`
CreatedAt int64 `protobuf:"varint,11,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` AlterId int32 `protobuf:"varint,10,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"`
UpdatedAt int64 `protobuf:"varint,12,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` CreatedAt int64 `protobuf:"varint,11,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
unknownFields protoimpl.UnknownFields UpdatedAt int64 `protobuf:"varint,12,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
} }
func (x *User) Reset() { func (x *User) Reset() {
@@ -653,6 +654,13 @@ func (x *User) GetSecret() string {
return "" return ""
} }
func (x *User) GetAuthorizedKeys() []string {
if x != nil {
return x.AuthorizedKeys
}
return nil
}
func (x *User) GetFlow() string { func (x *User) GetFlow() string {
if x != nil { if x != nil {
return x.Flow return x.Flow
@@ -682,18 +690,19 @@ func (x *User) GetUpdatedAt() int64 {
} }
type UserCreate struct { type UserCreate struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
SquadIds []int32 `protobuf:"varint,1,rep,packed,name=squad_ids,json=squadIds,proto3" json:"squad_ids,omitempty"` SquadIds []int32 `protobuf:"varint,1,rep,packed,name=squad_ids,json=squadIds,proto3" json:"squad_ids,omitempty"`
Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"`
Inbound string `protobuf:"bytes,3,opt,name=inbound,proto3" json:"inbound,omitempty"` Inbound string `protobuf:"bytes,3,opt,name=inbound,proto3" json:"inbound,omitempty"`
Type string `protobuf:"bytes,4,opt,name=type,proto3" json:"type,omitempty"` Type string `protobuf:"bytes,4,opt,name=type,proto3" json:"type,omitempty"`
Uuid string `protobuf:"bytes,5,opt,name=uuid,proto3" json:"uuid,omitempty"` Uuid string `protobuf:"bytes,5,opt,name=uuid,proto3" json:"uuid,omitempty"`
Password string `protobuf:"bytes,6,opt,name=password,proto3" json:"password,omitempty"` Password string `protobuf:"bytes,6,opt,name=password,proto3" json:"password,omitempty"`
Secret string `protobuf:"bytes,7,opt,name=secret,proto3" json:"secret,omitempty"` Secret string `protobuf:"bytes,7,opt,name=secret,proto3" json:"secret,omitempty"`
Flow string `protobuf:"bytes,8,opt,name=flow,proto3" json:"flow,omitempty"` AuthorizedKeys []string `protobuf:"bytes,10,rep,name=authorized_keys,json=authorizedKeys,proto3" json:"authorized_keys,omitempty"`
AlterId int32 `protobuf:"varint,9,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"` Flow string `protobuf:"bytes,8,opt,name=flow,proto3" json:"flow,omitempty"`
unknownFields protoimpl.UnknownFields AlterId int32 `protobuf:"varint,9,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"`
sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
} }
func (x *UserCreate) Reset() { func (x *UserCreate) Reset() {
@@ -775,6 +784,13 @@ func (x *UserCreate) GetSecret() string {
return "" return ""
} }
func (x *UserCreate) GetAuthorizedKeys() []string {
if x != nil {
return x.AuthorizedKeys
}
return nil
}
func (x *UserCreate) GetFlow() string { func (x *UserCreate) GetFlow() string {
if x != nil { if x != nil {
return x.Flow return x.Flow
@@ -790,14 +806,15 @@ func (x *UserCreate) GetAlterId() int32 {
} }
type UserUpdate struct { type UserUpdate struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Uuid string `protobuf:"bytes,1,opt,name=uuid,proto3" json:"uuid,omitempty"` Uuid string `protobuf:"bytes,1,opt,name=uuid,proto3" json:"uuid,omitempty"`
Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"`
Secret string `protobuf:"bytes,3,opt,name=secret,proto3" json:"secret,omitempty"` Secret string `protobuf:"bytes,3,opt,name=secret,proto3" json:"secret,omitempty"`
Flow string `protobuf:"bytes,4,opt,name=flow,proto3" json:"flow,omitempty"` AuthorizedKeys []string `protobuf:"bytes,6,rep,name=authorized_keys,json=authorizedKeys,proto3" json:"authorized_keys,omitempty"`
AlterId int32 `protobuf:"varint,5,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"` Flow string `protobuf:"bytes,4,opt,name=flow,proto3" json:"flow,omitempty"`
unknownFields protoimpl.UnknownFields AlterId int32 `protobuf:"varint,5,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"`
sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
} }
func (x *UserUpdate) Reset() { func (x *UserUpdate) Reset() {
@@ -851,6 +868,13 @@ func (x *UserUpdate) GetSecret() string {
return "" return ""
} }
func (x *UserUpdate) GetAuthorizedKeys() []string {
if x != nil {
return x.AuthorizedKeys
}
return nil
}
func (x *UserUpdate) GetFlow() string { func (x *UserUpdate) GetFlow() string {
if x != nil { if x != nil {
return x.Flow return x.Flow
@@ -2878,7 +2902,7 @@ const file_service_manager_api_grpc_manager_manager_proto_rawDesc = "" +
"\x06values\x18\x01 \x03(\v2\x14.manager_api.v1.NodeR\x06values\"[\n" + "\x06values\x18\x01 \x03(\v2\x14.manager_api.v1.NodeR\x06values\"[\n" +
"\x11NodeUpdateRequest\x12\x12\n" + "\x11NodeUpdateRequest\x12\x12\n" +
"\x04uuid\x18\x01 \x01(\tR\x04uuid\x122\n" + "\x04uuid\x18\x01 \x01(\tR\x04uuid\x122\n" +
"\x06update\x18\x02 \x01(\v2\x1a.manager_api.v1.NodeUpdateR\x06update\"\xb2\x02\n" + "\x06update\x18\x02 \x01(\v2\x1a.manager_api.v1.NodeUpdateR\x06update\"\xdb\x02\n" +
"\x04User\x12\x0e\n" + "\x04User\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x05R\x02id\x12\x1b\n" + "\x02id\x18\x01 \x01(\x05R\x02id\x12\x1b\n" +
"\tsquad_ids\x18\x02 \x03(\x05R\bsquadIds\x12\x1a\n" + "\tsquad_ids\x18\x02 \x03(\x05R\bsquadIds\x12\x1a\n" +
@@ -2887,14 +2911,15 @@ const file_service_manager_api_grpc_manager_manager_proto_rawDesc = "" +
"\x04type\x18\x05 \x01(\tR\x04type\x12\x12\n" + "\x04type\x18\x05 \x01(\tR\x04type\x12\x12\n" +
"\x04uuid\x18\x06 \x01(\tR\x04uuid\x12\x1a\n" + "\x04uuid\x18\x06 \x01(\tR\x04uuid\x12\x1a\n" +
"\bpassword\x18\a \x01(\tR\bpassword\x12\x16\n" + "\bpassword\x18\a \x01(\tR\bpassword\x12\x16\n" +
"\x06secret\x18\b \x01(\tR\x06secret\x12\x12\n" + "\x06secret\x18\b \x01(\tR\x06secret\x12'\n" +
"\x0fauthorized_keys\x18\r \x03(\tR\x0eauthorizedKeys\x12\x12\n" +
"\x04flow\x18\t \x01(\tR\x04flow\x12\x19\n" + "\x04flow\x18\t \x01(\tR\x04flow\x12\x19\n" +
"\balter_id\x18\n" + "\balter_id\x18\n" +
" \x01(\x05R\aalterId\x12\x1d\n" + " \x01(\x05R\aalterId\x12\x1d\n" +
"\n" + "\n" +
"created_at\x18\v \x01(\x03R\tcreatedAt\x12\x1d\n" + "created_at\x18\v \x01(\x03R\tcreatedAt\x12\x1d\n" +
"\n" + "\n" +
"updated_at\x18\f \x01(\x03R\tupdatedAt\"\xea\x01\n" + "updated_at\x18\f \x01(\x03R\tupdatedAt\"\x93\x02\n" +
"\n" + "\n" +
"UserCreate\x12\x1b\n" + "UserCreate\x12\x1b\n" +
"\tsquad_ids\x18\x01 \x03(\x05R\bsquadIds\x12\x1a\n" + "\tsquad_ids\x18\x01 \x03(\x05R\bsquadIds\x12\x1a\n" +
@@ -2903,14 +2928,17 @@ const file_service_manager_api_grpc_manager_manager_proto_rawDesc = "" +
"\x04type\x18\x04 \x01(\tR\x04type\x12\x12\n" + "\x04type\x18\x04 \x01(\tR\x04type\x12\x12\n" +
"\x04uuid\x18\x05 \x01(\tR\x04uuid\x12\x1a\n" + "\x04uuid\x18\x05 \x01(\tR\x04uuid\x12\x1a\n" +
"\bpassword\x18\x06 \x01(\tR\bpassword\x12\x16\n" + "\bpassword\x18\x06 \x01(\tR\bpassword\x12\x16\n" +
"\x06secret\x18\a \x01(\tR\x06secret\x12\x12\n" + "\x06secret\x18\a \x01(\tR\x06secret\x12'\n" +
"\x0fauthorized_keys\x18\n" +
" \x03(\tR\x0eauthorizedKeys\x12\x12\n" +
"\x04flow\x18\b \x01(\tR\x04flow\x12\x19\n" + "\x04flow\x18\b \x01(\tR\x04flow\x12\x19\n" +
"\balter_id\x18\t \x01(\x05R\aalterId\"\x83\x01\n" + "\balter_id\x18\t \x01(\x05R\aalterId\"\xac\x01\n" +
"\n" + "\n" +
"UserUpdate\x12\x12\n" + "UserUpdate\x12\x12\n" +
"\x04uuid\x18\x01 \x01(\tR\x04uuid\x12\x1a\n" + "\x04uuid\x18\x01 \x01(\tR\x04uuid\x12\x1a\n" +
"\bpassword\x18\x02 \x01(\tR\bpassword\x12\x16\n" + "\bpassword\x18\x02 \x01(\tR\bpassword\x12\x16\n" +
"\x06secret\x18\x03 \x01(\tR\x06secret\x12\x12\n" + "\x06secret\x18\x03 \x01(\tR\x06secret\x12'\n" +
"\x0fauthorized_keys\x18\x06 \x03(\tR\x0eauthorizedKeys\x12\x12\n" +
"\x04flow\x18\x04 \x01(\tR\x04flow\x12\x19\n" + "\x04flow\x18\x04 \x01(\tR\x04flow\x12\x19\n" +
"\balter_id\x18\x05 \x01(\x05R\aalterId\"8\n" + "\balter_id\x18\x05 \x01(\x05R\aalterId\"8\n" +
"\bUserList\x12,\n" + "\bUserList\x12,\n" +

View File

@@ -116,6 +116,7 @@ message User {
string uuid = 6; string uuid = 6;
string password = 7; string password = 7;
string secret = 8; string secret = 8;
repeated string authorized_keys = 13;
string flow = 9; string flow = 9;
int32 alter_id = 10; int32 alter_id = 10;
int64 created_at = 11; int64 created_at = 11;
@@ -130,6 +131,7 @@ message UserCreate {
string uuid = 5; string uuid = 5;
string password = 6; string password = 6;
string secret = 7; string secret = 7;
repeated string authorized_keys = 10;
string flow = 8; string flow = 8;
int32 alter_id = 9; int32 alter_id = 9;
} }
@@ -138,6 +140,7 @@ message UserUpdate {
string uuid = 1; string uuid = 1;
string password = 2; string password = 2;
string secret = 3; string secret = 3;
repeated string authorized_keys = 6;
string flow = 4; string flow = 4;
int32 alter_id = 5; int32 alter_id = 5;
} }

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.2
// - protoc v6.31.1 // - protoc v7.34.1
// source: service/manager_api/grpc/manager/manager.proto // source: service/manager_api/grpc/manager/manager.proto
package manager package manager

View File

@@ -42,18 +42,19 @@ func convertNode(v CM.Node) *pb.Node {
func convertUser(v CM.User) *pb.User { func convertUser(v CM.User) *pb.User {
return &pb.User{ return &pb.User{
Id: int32(v.ID), Id: int32(v.ID),
SquadIds: toInt32Slice(v.SquadIDs), SquadIds: toInt32Slice(v.SquadIDs),
Username: v.Username, Username: v.Username,
Inbound: v.Inbound, Inbound: v.Inbound,
Type: v.Type, Type: v.Type,
Uuid: v.UUID, Uuid: v.UUID,
Password: v.Password, Password: v.Password,
Secret: v.Secret, Secret: v.Secret,
Flow: v.Flow, AuthorizedKeys: v.AuthorizedKeys,
AlterId: int32(v.AlterID), Flow: v.Flow,
CreatedAt: v.CreatedAt.UnixNano(), AlterId: int32(v.AlterID),
UpdatedAt: v.UpdatedAt.UnixNano(), CreatedAt: v.CreatedAt.UnixNano(),
UpdatedAt: v.UpdatedAt.UnixNano(),
} }
} }
@@ -66,7 +67,7 @@ func convertBandwidthLimiter(v CM.BandwidthLimiter) *pb.BandwidthLimiter {
Strategy: v.Strategy, Strategy: v.Strategy,
ConnectionType: v.ConnectionType, ConnectionType: v.ConnectionType,
Mode: v.Mode, Mode: v.Mode,
FlowKeys: v.FlowKeys, FlowKeys: v.FlowKeys,
Speed: v.Speed, Speed: v.Speed,
RawSpeed: v.RawSpeed, RawSpeed: v.RawSpeed,
CreatedAt: v.CreatedAt.UnixNano(), CreatedAt: v.CreatedAt.UnixNano(),

View File

@@ -125,15 +125,16 @@ func (s *Server) DeleteNode(_ context.Context, req *pb.UuidRequest) (*pb.Node, e
func (s *Server) CreateUser(_ context.Context, req *pb.UserCreate) (*pb.User, error) { func (s *Server) CreateUser(_ context.Context, req *pb.UserCreate) (*pb.User, error) {
v, err := s.manager.CreateUser(CM.UserCreate{ v, err := s.manager.CreateUser(CM.UserCreate{
SquadIDs: toIntSlice(req.GetSquadIds()), SquadIDs: toIntSlice(req.GetSquadIds()),
Username: req.GetUsername(), Username: req.GetUsername(),
Inbound: req.GetInbound(), Inbound: req.GetInbound(),
Type: req.GetType(), Type: req.GetType(),
UUID: req.GetUuid(), UUID: req.GetUuid(),
Password: req.GetPassword(), Password: req.GetPassword(),
Secret: req.GetSecret(), Secret: req.GetSecret(),
Flow: req.GetFlow(), AuthorizedKeys: req.GetAuthorizedKeys(),
AlterID: int(req.GetAlterId()), Flow: req.GetFlow(),
AlterID: int(req.GetAlterId()),
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -172,11 +173,12 @@ func (s *Server) GetUser(_ context.Context, req *pb.IdRequest) (*pb.User, error)
func (s *Server) UpdateUser(_ context.Context, req *pb.UserUpdateRequest) (*pb.User, error) { func (s *Server) UpdateUser(_ context.Context, req *pb.UserUpdateRequest) (*pb.User, error) {
u := req.GetUpdate() u := req.GetUpdate()
v, err := s.manager.UpdateUser(int(req.GetId()), CM.UserUpdate{ v, err := s.manager.UpdateUser(int(req.GetId()), CM.UserUpdate{
UUID: u.GetUuid(), UUID: u.GetUuid(),
Password: u.GetPassword(), Password: u.GetPassword(),
Secret: u.GetSecret(), Secret: u.GetSecret(),
Flow: u.GetFlow(), AuthorizedKeys: u.GetAuthorizedKeys(),
AlterID: int(u.GetAlterId()), Flow: u.GetFlow(),
AlterID: int(u.GetAlterId()),
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -200,7 +202,7 @@ func (s *Server) CreateBandwidthLimiter(_ context.Context, req *pb.BandwidthLimi
Strategy: req.GetStrategy(), Strategy: req.GetStrategy(),
ConnectionType: req.GetConnectionType(), ConnectionType: req.GetConnectionType(),
Mode: req.GetMode(), Mode: req.GetMode(),
FlowKeys: req.GetFlowKeys(), FlowKeys: req.GetFlowKeys(),
Speed: req.GetSpeed(), Speed: req.GetSpeed(),
}) })
if err != nil { if err != nil {
@@ -245,7 +247,7 @@ func (s *Server) UpdateBandwidthLimiter(_ context.Context, req *pb.BandwidthLimi
Strategy: u.GetStrategy(), Strategy: u.GetStrategy(),
ConnectionType: u.GetConnectionType(), ConnectionType: u.GetConnectionType(),
Mode: u.GetMode(), Mode: u.GetMode(),
FlowKeys: u.GetFlowKeys(), FlowKeys: u.GetFlowKeys(),
Speed: u.GetSpeed(), Speed: u.GetSpeed(),
}) })
if err != nil { if err != nil {

View File

@@ -194,7 +194,7 @@ paths:
- {in: query, name: id, schema: {type: integer, format: int32}} - {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: username, schema: {type: string}} - {in: query, name: username, schema: {type: string}}
- {in: query, name: inbound, schema: {type: string}} - {in: query, name: inbound, schema: {type: string}}
- {in: query, name: type, schema: {type: string, enum: [hysteria, hysteria2, mtproxy, trojan, tuic, vless, vmess]}} - {in: query, name: type, schema: {type: string, enum: [anytls, http, hysteria, hysteria2, mixed, mtproxy, naive, socks, ssh, trojan, trusttunnel, tuic, vless, vmess]}}
- {$ref: "#/components/parameters/FilterSquadIdIn"} - {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"} - {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"} - {$ref: "#/components/parameters/FilterCreatedAtEnd"}
@@ -210,6 +210,15 @@ paths:
post: post:
tags: [Users] tags: [Users]
summary: Create user summary: Create user
description: |
Required fields depend on `type`:
- **vless**: uuid (flow optional)
- **vmess**: uuid, alter_id
- **trojan, hysteria, hysteria2**: password
- **tuic**: uuid, password
- **mtproxy**: secret
- **ssh**: password OR authorized_keys (at least one)
- **anytls, http, mixed, naive, socks, trusttunnel**: password
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/UserCreate"}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/UserCreate"}}}}
responses: responses:
"201": {content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}} "201": {content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}}
@@ -222,7 +231,7 @@ paths:
- {in: query, name: id, schema: {type: integer, format: int32}} - {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: username, schema: {type: string}} - {in: query, name: username, schema: {type: string}}
- {in: query, name: inbound, schema: {type: string}} - {in: query, name: inbound, schema: {type: string}}
- {in: query, name: type, schema: {type: string, enum: [hysteria, hysteria2, mtproxy, trojan, tuic, vless, vmess]}} - {in: query, name: type, schema: {type: string, enum: [anytls, http, hysteria, hysteria2, mixed, mtproxy, naive, socks, ssh, trojan, trusttunnel, tuic, vless, vmess]}}
- {$ref: "#/components/parameters/FilterSquadIdIn"} - {$ref: "#/components/parameters/FilterSquadIdIn"}
- {$ref: "#/components/parameters/FilterCreatedAtStart"} - {$ref: "#/components/parameters/FilterCreatedAtStart"}
- {$ref: "#/components/parameters/FilterCreatedAtEnd"} - {$ref: "#/components/parameters/FilterCreatedAtEnd"}
@@ -247,6 +256,15 @@ paths:
put: put:
tags: [Users] tags: [Users]
summary: Update user summary: Update user
description: |
Required fields depend on user `type`:
- **vless**: uuid (flow optional)
- **vmess**: uuid, alter_id
- **trojan, hysteria, hysteria2**: password
- **tuic**: uuid, password
- **mtproxy**: secret
- **ssh**: password OR authorized_keys (at least one)
- **anytls, http, mixed, naive, socks, trusttunnel**: password
requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/UserUpdate"}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/UserUpdate"}}}}
responses: responses:
"200": {content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}} "200": {content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}}
@@ -266,7 +284,7 @@ paths:
summary: List bandwidth limiters summary: List bandwidth limiters
parameters: parameters:
- {in: query, name: id, schema: {type: integer, format: int32}} - {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [global, connection]}} - {in: query, name: strategy, schema: {type: string, enum: [global, connection, bypass]}}
- {in: query, name: mode, schema: {type: string, enum: [upload, download, bidirectional]}} - {in: query, name: mode, schema: {type: string, enum: [upload, download, bidirectional]}}
- {in: query, name: type, schema: {type: string}} - {in: query, name: type, schema: {type: string}}
- {in: query, name: username, schema: {type: string}} - {in: query, name: username, schema: {type: string}}
@@ -299,7 +317,7 @@ paths:
summary: Count bandwidth limiters summary: Count bandwidth limiters
parameters: parameters:
- {in: query, name: id, schema: {type: integer, format: int32}} - {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [global, connection]}} - {in: query, name: strategy, schema: {type: string, enum: [global, connection, bypass]}}
- {in: query, name: mode, schema: {type: string, enum: [upload, download, bidirectional]}} - {in: query, name: mode, schema: {type: string, enum: [upload, download, bidirectional]}}
- {in: query, name: type, schema: {type: string}} - {in: query, name: type, schema: {type: string}}
- {in: query, name: username, schema: {type: string}} - {in: query, name: username, schema: {type: string}}
@@ -430,7 +448,7 @@ paths:
summary: List connection limiters summary: List connection limiters
parameters: parameters:
- {in: query, name: id, schema: {type: integer, format: int32}} - {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [connection]}} - {in: query, name: strategy, schema: {type: string, enum: [connection, bypass]}}
- {in: query, name: username, schema: {type: string}} - {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}} - {in: query, name: outbound, schema: {type: string}}
- {in: query, name: connection_type, schema: {type: string, enum: [default, hwid, mux, ip]}} - {in: query, name: connection_type, schema: {type: string, enum: [default, hwid, mux, ip]}}
@@ -460,7 +478,7 @@ paths:
summary: Count connection limiters summary: Count connection limiters
parameters: parameters:
- {in: query, name: id, schema: {type: integer, format: int32}} - {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [connection]}} - {in: query, name: strategy, schema: {type: string, enum: [connection, bypass]}}
- {in: query, name: username, schema: {type: string}} - {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}} - {in: query, name: outbound, schema: {type: string}}
- {in: query, name: connection_type, schema: {type: string, enum: [default, hwid, mux, ip]}} - {in: query, name: connection_type, schema: {type: string, enum: [default, hwid, mux, ip]}}
@@ -507,7 +525,7 @@ paths:
summary: List rate limiters summary: List rate limiters
parameters: parameters:
- {in: query, name: id, schema: {type: integer, format: int32}} - {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]}} - {in: query, name: strategy, schema: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket, bypass]}}
- {in: query, name: username, schema: {type: string}} - {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}} - {in: query, name: outbound, schema: {type: string}}
- {in: query, name: connection_type, schema: {type: string, enum: [hwid, mux, ip, default]}} - {in: query, name: connection_type, schema: {type: string, enum: [hwid, mux, ip, default]}}
@@ -539,7 +557,7 @@ paths:
summary: Count rate limiters summary: Count rate limiters
parameters: parameters:
- {in: query, name: id, schema: {type: integer, format: int32}} - {in: query, name: id, schema: {type: integer, format: int32}}
- {in: query, name: strategy, schema: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]}} - {in: query, name: strategy, schema: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket, bypass]}}
- {in: query, name: username, schema: {type: string}} - {in: query, name: username, schema: {type: string}}
- {in: query, name: outbound, schema: {type: string}} - {in: query, name: outbound, schema: {type: string}}
- {in: query, name: connection_type, schema: {type: string, enum: [hwid, mux, ip, default]}} - {in: query, name: connection_type, schema: {type: string, enum: [hwid, mux, ip, default]}}
@@ -686,16 +704,17 @@ components:
User: User:
type: object type: object
required: [id, squad_ids, username, inbound, type, uuid, password, secret, flow, alter_id, created_at, updated_at] required: [id, squad_ids, username, inbound, type, uuid, password, secret, authorized_keys, flow, alter_id, created_at, updated_at]
properties: properties:
id: {type: integer, format: int32} id: {type: integer, format: int32}
squad_ids: {$ref: "#/components/schemas/SquadIDs"} squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string, example: "alice"} username: {type: string, example: "alice"}
inbound: {type: string, example: "vless-in"} inbound: {type: string, example: "vless-in"}
type: {type: string, enum: [hysteria, hysteria2, mtproxy, trojan, tuic, vless, vmess]} type: {type: string, enum: [anytls, http, hysteria, hysteria2, mixed, mtproxy, naive, socks, ssh, trojan, trusttunnel, tuic, vless, vmess]}
uuid: {type: string} uuid: {type: string}
password: {type: string} password: {type: string}
secret: {type: string} secret: {type: string}
authorized_keys: {type: array, items: {type: string}}
flow: {type: string} flow: {type: string}
alter_id: {type: integer, format: int32} alter_id: {type: integer, format: int32}
created_at: {type: string, format: date-time} created_at: {type: string, format: date-time}
@@ -703,24 +722,44 @@ components:
UserCreate: UserCreate:
type: object type: object
required: [squad_ids, username, inbound, type] required: [squad_ids, username, inbound, type]
description: |
Required fields depend on `type`:
- vless: uuid (flow optional)
- vmess: uuid, alter_id
- trojan, shadowsocks, hysteria, hysteria2: password
- tuic: uuid, password
- mtproxy: secret
- ssh: password OR authorized_keys (at least one)
- anytls, http, mixed, naive, socks, trusttunnel: password
properties: properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"} squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string, example: "alice"} username: {type: string, example: "alice"}
inbound: {type: string, example: "vless-in"} inbound: {type: string, example: "vless-in"}
type: type:
type: string type: string
enum: [hysteria, hysteria2, mtproxy, trojan, tuic, vless, vmess] enum: [anytls, http, hysteria, hysteria2, mixed, mtproxy, naive, socks, ssh, trojan, trusttunnel, tuic, vless, vmess]
uuid: {type: string, format: uuid} uuid: {type: string, format: uuid}
password: {type: string} password: {type: string}
secret: {type: string} secret: {type: string}
authorized_keys: {type: array, items: {type: string}}
flow: {type: string} flow: {type: string}
alter_id: {type: integer, format: int32} alter_id: {type: integer, format: int32}
UserUpdate: UserUpdate:
type: object type: object
description: |
All fields are optional. Validation rules match UserCreate by type:
- vless: uuid (flow optional)
- vmess: uuid, alter_id
- trojan, shadowsocks, hysteria, hysteria2: password
- tuic: uuid, password
- mtproxy: secret
- ssh: password OR authorized_keys (at least one)
- anytls, http, mixed, naive, socks, trusttunnel: password
properties: properties:
uuid: {type: string, format: uuid} uuid: {type: string, format: uuid}
password: {type: string} password: {type: string}
secret: {type: string} secret: {type: string}
authorized_keys: {type: array, items: {type: string}}
flow: {type: string} flow: {type: string}
alter_id: {type: integer, format: int32} alter_id: {type: integer, format: int32}
@@ -732,41 +771,43 @@ components:
squad_ids: {$ref: "#/components/schemas/SquadIDs"} squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string} username: {type: string}
outbound: {type: string, example: "direct"} outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [global, connection]} strategy: {type: string, enum: [global, connection, bypass]}
connection_type: {type: string, enum: [default, hwid, mux, ip]} connection_type: {type: string}
mode: {type: string, enum: [upload, download, bidirectional]} mode: {type: string, enum: [upload, download, bidirectional]}
flow_keys: {type: array, items: {type: string, enum: [user, destination, ip, hwid, mux]}} flow_keys: {type: array, items: {type: string, enum: [user, source_ip, hwid, mux, protocol, destination]}}
speed: {type: string, example: "10mbit"} speed: {type: string, example: "10mbit"}
raw_speed: {type: integer, format: int64} raw_speed: {type: integer, format: int64}
created_at: {type: string, format: date-time} created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time} updated_at: {type: string, format: date-time}
BandwidthLimiterCreate: BandwidthLimiterCreate:
type: object type: object
required: [squad_ids, outbound, strategy, mode, speed] required: [squad_ids, outbound, strategy]
description: "mode, speed, flow_keys, connection_type are required/relevant unless strategy=bypass"
properties: properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"} squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string} username: {type: string}
outbound: {type: string, example: "direct"} outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [global, connection]} strategy: {type: string, enum: [global, connection, bypass]}
connection_type: {type: string, enum: [default, hwid, mux, ip]} connection_type: {type: string}
mode: {type: string, enum: [upload, download, bidirectional]} mode: {type: string, enum: [upload, download, bidirectional]}
flow_keys: {type: array, items: {type: string, enum: [user, destination, ip, hwid, mux]}} flow_keys: {type: array, items: {type: string, enum: [user, source_ip, hwid, mux, protocol, destination]}}
speed: {type: string, example: "10mbit"} speed: {type: string, example: "10mbit"}
BandwidthLimiterUpdate: BandwidthLimiterUpdate:
type: object type: object
required: [outbound, strategy, mode, speed] required: [outbound, strategy]
description: "mode, speed, flow_keys, connection_type are required/relevant unless strategy=bypass"
properties: properties:
username: {type: string} username: {type: string}
outbound: {type: string} outbound: {type: string}
strategy: {type: string, enum: [global, connection]} strategy: {type: string, enum: [global, connection, bypass]}
connection_type: {type: string, enum: [default, hwid, mux, ip]} connection_type: {type: string}
mode: {type: string, enum: [upload, download, bidirectional]} mode: {type: string, enum: [upload, download, bidirectional]}
flow_keys: {type: array, items: {type: string, enum: [user, destination, ip, hwid, mux]}} flow_keys: {type: array, items: {type: string, enum: [user, source_ip, hwid, mux, protocol, destination]}}
speed: {type: string} speed: {type: string}
TrafficLimiter: TrafficLimiter:
type: object type: object
required: [id, squad_ids, outbound, strategy, mode, raw_used, quota, raw_quota, created_at, updated_at] required: [id, squad_ids, outbound, strategy, mode, raw_used, quota, raw_quota, usage, created_at, updated_at]
properties: properties:
id: {type: integer, format: int32} id: {type: integer, format: int32}
squad_ids: {$ref: "#/components/schemas/SquadIDs"} squad_ids: {$ref: "#/components/schemas/SquadIDs"}
@@ -777,11 +818,13 @@ components:
raw_used: {type: integer, format: int64} raw_used: {type: integer, format: int64}
quota: {type: string, example: "10gb"} quota: {type: string, example: "10gb"}
raw_quota: {type: integer, format: int64} raw_quota: {type: integer, format: int64}
usage: {type: integer, format: int32, description: "Usage percentage 0-100"}
created_at: {type: string, format: date-time} created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time} updated_at: {type: string, format: date-time}
TrafficLimiterCreate: TrafficLimiterCreate:
type: object type: object
required: [squad_ids, outbound, strategy, mode, quota] required: [squad_ids, outbound, strategy]
description: "mode, quota are required unless strategy=bypass"
properties: properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"} squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string} username: {type: string}
@@ -791,7 +834,8 @@ components:
quota: {type: string, example: "10gb"} quota: {type: string, example: "10gb"}
TrafficLimiterUpdate: TrafficLimiterUpdate:
type: object type: object
required: [outbound, strategy, mode, quota] required: [outbound, strategy]
description: "mode, quota are required unless strategy=bypass"
properties: properties:
username: {type: string} username: {type: string}
outbound: {type: string} outbound: {type: string}
@@ -807,7 +851,7 @@ components:
squad_ids: {$ref: "#/components/schemas/SquadIDs"} squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string} username: {type: string}
outbound: {type: string, example: "direct"} outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [connection]} strategy: {type: string, enum: [connection, bypass]}
connection_type: {type: string, enum: [default, hwid, mux, ip]} connection_type: {type: string, enum: [default, hwid, mux, ip]}
lock_type: {type: string, enum: [manager, default]} lock_type: {type: string, enum: [manager, default]}
count: {type: integer, format: int64} count: {type: integer, format: int64}
@@ -815,22 +859,24 @@ components:
updated_at: {type: string, format: date-time} updated_at: {type: string, format: date-time}
ConnectionLimiterCreate: ConnectionLimiterCreate:
type: object type: object
required: [squad_ids, outbound, strategy, lock_type, count] required: [squad_ids, outbound, strategy]
description: "lock_type, connection_type, count are required unless strategy=bypass"
properties: properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"} squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string} username: {type: string}
outbound: {type: string, example: "direct"} outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [connection]} strategy: {type: string, enum: [connection, bypass]}
connection_type: {type: string, enum: [default, hwid, mux, ip]} connection_type: {type: string, enum: [default, hwid, mux, ip]}
lock_type: {type: string, enum: [manager, default]} lock_type: {type: string, enum: [manager, default]}
count: {type: integer, format: int64} count: {type: integer, format: int64}
ConnectionLimiterUpdate: ConnectionLimiterUpdate:
type: object type: object
required: [outbound, strategy, lock_type, count] required: [outbound, strategy]
description: "lock_type, connection_type, count are required unless strategy=bypass"
properties: properties:
username: {type: string} username: {type: string}
outbound: {type: string} outbound: {type: string}
strategy: {type: string, enum: [connection]} strategy: {type: string, enum: [connection, bypass]}
connection_type: {type: string, enum: [default, hwid, mux, ip]} connection_type: {type: string, enum: [default, hwid, mux, ip]}
lock_type: {type: string, enum: [manager, default]} lock_type: {type: string, enum: [manager, default]}
count: {type: integer, format: int64} count: {type: integer, format: int64}
@@ -843,7 +889,7 @@ components:
squad_ids: {$ref: "#/components/schemas/SquadIDs"} squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string} username: {type: string}
outbound: {type: string, example: "direct"} outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]} strategy: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket, bypass]}
connection_type: {type: string, enum: [hwid, mux, ip, default]} connection_type: {type: string, enum: [hwid, mux, ip, default]}
count: {type: integer, format: int64} count: {type: integer, format: int64}
interval: {type: string, example: "1s"} interval: {type: string, example: "1s"}
@@ -851,22 +897,24 @@ components:
updated_at: {type: string, format: date-time} updated_at: {type: string, format: date-time}
RateLimiterCreate: RateLimiterCreate:
type: object type: object
required: [squad_ids, outbound, strategy, connection_type, count, interval] required: [squad_ids, outbound, strategy]
description: "connection_type, count, interval are required unless strategy=bypass"
properties: properties:
squad_ids: {$ref: "#/components/schemas/SquadIDs"} squad_ids: {$ref: "#/components/schemas/SquadIDs"}
username: {type: string} username: {type: string}
outbound: {type: string, example: "direct"} outbound: {type: string, example: "direct"}
strategy: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]} strategy: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket, bypass]}
connection_type: {type: string, enum: [hwid, mux, ip, default]} connection_type: {type: string, enum: [hwid, mux, ip, default]}
count: {type: integer, format: int64} count: {type: integer, format: int64}
interval: {type: string, example: "1s"} interval: {type: string, example: "1s"}
RateLimiterUpdate: RateLimiterUpdate:
type: object type: object
required: [outbound, strategy, connection_type, count, interval] required: [outbound, strategy]
description: "connection_type, count, interval are required unless strategy=bypass"
properties: properties:
username: {type: string} username: {type: string}
outbound: {type: string} outbound: {type: string}
strategy: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket]} strategy: {type: string, enum: [fixed_window, sliding_window, token_bucket, leaky_bucket, bypass]}
connection_type: {type: string, enum: [hwid, mux, ip, default]} connection_type: {type: string, enum: [hwid, mux, ip, default]}
count: {type: integer, format: int64} count: {type: integer, format: int64}
interval: {type: string} interval: {type: string}

View File

@@ -0,0 +1,97 @@
package inbound
import (
"sync"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/protocol/ssh"
CM "github.com/sagernet/sing-box/service/manager/constant"
"github.com/sagernet/sing-box/service/node/constant"
)
type SSHManager struct {
inbounds map[string]*SSHUserManager
mtx sync.Mutex
}
func NewSSHManager() *SSHManager {
return &SSHManager{
inbounds: make(map[string]*SSHUserManager),
}
}
func (m *SSHManager) AddUserManager(inbound adapter.Inbound) error {
m.mtx.Lock()
defer m.mtx.Unlock()
m.inbounds[inbound.Tag()] = &SSHUserManager{
inbound: inbound.(*ssh.Inbound),
usersMap: make(map[string]option.SSHUser),
}
return nil
}
func (m *SSHManager) GetUserManager(tag string) (constant.UserManager, bool) {
m.mtx.Lock()
defer m.mtx.Unlock()
inbound, ok := m.inbounds[tag]
return inbound, ok
}
func (m *SSHManager) GetUserManagerTags() []string {
m.mtx.Lock()
defer m.mtx.Unlock()
tags := make([]string, 0, len(m.inbounds))
for tag := range m.inbounds {
tags = append(tags, tag)
}
return tags
}
type SSHUserManager struct {
inbound *ssh.Inbound
usersMap map[string]option.SSHUser
mtx sync.Mutex
}
func (i *SSHUserManager) postUpdate() {
users := make([]option.SSHUser, 0, len(i.usersMap))
for _, user := range i.usersMap {
users = append(users, user)
}
i.inbound.UpdateUsers(users)
}
func convertSSHUser(user CM.User) option.SSHUser {
return option.SSHUser{
Name: user.Username,
Password: user.Password,
AuthorizedKeys: user.AuthorizedKeys,
}
}
func (i *SSHUserManager) UpdateUser(user CM.User) {
i.mtx.Lock()
defer i.mtx.Unlock()
i.usersMap[user.Username] = convertSSHUser(user)
i.postUpdate()
}
func (i *SSHUserManager) UpdateUsers(users []CM.User) {
i.mtx.Lock()
defer i.mtx.Unlock()
clear(i.usersMap)
for _, user := range users {
i.usersMap[user.Username] = convertSSHUser(user)
}
i.postUpdate()
}
func (i *SSHUserManager) DeleteUser(username string) {
i.mtx.Lock()
defer i.mtx.Unlock()
delete(i.usersMap, username)
i.postUpdate()
}

View File

@@ -33,7 +33,7 @@ type Service struct {
rateManager constant.RateLimiterManager rateManager constant.RateLimiterManager
options option.NodeServiceOptions options option.NodeServiceOptions
nodeManager CM.NodeManager nodeManager CM.NodeManager
mtx sync.Mutex mtx sync.Mutex
} }
@@ -79,6 +79,7 @@ func (s *Service) Start(stage adapter.StartStage) error {
"tuic": inbound.NewTUICManager(), "tuic": inbound.NewTUICManager(),
"vless": inbound.NewVLESSManager(), "vless": inbound.NewVLESSManager(),
"vmess": inbound.NewVMessManager(), "vmess": inbound.NewVMessManager(),
"ssh": inbound.NewSSHManager(),
} }
s.connectionManager = limiter.NewConnectionLimiterManager(s.ctx, nodeManager, s.logger) s.connectionManager = limiter.NewConnectionLimiterManager(s.ctx, nodeManager, s.logger)
s.bandwidthManager = limiter.NewBandwidthLimiterManager(s.ctx, nodeManager, s.logger) s.bandwidthManager = limiter.NewBandwidthLimiterManager(s.ctx, nodeManager, s.logger)

View File

@@ -281,15 +281,16 @@ func (s *APIClient) handler(node CM.ConnectedNode, stream grpc.ServerStreamingCl
func (s *APIClient) convertUser(user *pb.User) CM.User { func (s *APIClient) convertUser(user *pb.User) CM.User {
return CM.User{ return CM.User{
ID: int(user.Id), ID: int(user.Id),
Username: user.Username, Username: user.Username,
Inbound: user.Inbound, Inbound: user.Inbound,
Type: user.Type, Type: user.Type,
UUID: user.Uuid, UUID: user.Uuid,
Password: user.Password, Password: user.Password,
Secret: user.Secret, Secret: user.Secret,
Flow: user.Flow, AuthorizedKeys: user.AuthorizedKeys,
AlterID: int(user.AlterId), Flow: user.Flow,
AlterID: int(user.AlterId),
} }
} }
@@ -301,7 +302,7 @@ func (s *APIClient) convertBandwidthLimiter(limiter *pb.BandwidthLimiter) CM.Ban
Strategy: limiter.Strategy, Strategy: limiter.Strategy,
ConnectionType: limiter.ConnectionType, ConnectionType: limiter.ConnectionType,
Mode: limiter.Mode, Mode: limiter.Mode,
FlowKeys: limiter.FlowKeys, FlowKeys: limiter.FlowKeys,
Speed: limiter.Speed, Speed: limiter.Speed,
RawSpeed: limiter.RawSpeed, RawSpeed: limiter.RawSpeed,
} }

View File

@@ -151,18 +151,19 @@ func (x *Node) GetUuid() string {
} }
type User struct { type User struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"`
Inbound string `protobuf:"bytes,3,opt,name=inbound,proto3" json:"inbound,omitempty"` Inbound string `protobuf:"bytes,3,opt,name=inbound,proto3" json:"inbound,omitempty"`
Type string `protobuf:"bytes,4,opt,name=type,proto3" json:"type,omitempty"` Type string `protobuf:"bytes,4,opt,name=type,proto3" json:"type,omitempty"`
Uuid string `protobuf:"bytes,5,opt,name=uuid,proto3" json:"uuid,omitempty"` Uuid string `protobuf:"bytes,5,opt,name=uuid,proto3" json:"uuid,omitempty"`
Password string `protobuf:"bytes,6,opt,name=password,proto3" json:"password,omitempty"` Password string `protobuf:"bytes,6,opt,name=password,proto3" json:"password,omitempty"`
Secret string `protobuf:"bytes,7,opt,name=secret,proto3" json:"secret,omitempty"` Secret string `protobuf:"bytes,7,opt,name=secret,proto3" json:"secret,omitempty"`
Flow string `protobuf:"bytes,8,opt,name=flow,proto3" json:"flow,omitempty"` Flow string `protobuf:"bytes,8,opt,name=flow,proto3" json:"flow,omitempty"`
AlterId int32 `protobuf:"varint,9,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"` AlterId int32 `protobuf:"varint,9,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"`
unknownFields protoimpl.UnknownFields AuthorizedKeys []string `protobuf:"bytes,10,rep,name=authorized_keys,json=authorizedKeys,proto3" json:"authorized_keys,omitempty"`
sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
} }
func (x *User) Reset() { func (x *User) Reset() {
@@ -258,6 +259,13 @@ func (x *User) GetAlterId() int32 {
return 0 return 0
} }
func (x *User) GetAuthorizedKeys() []string {
if x != nil {
return x.AuthorizedKeys
}
return nil
}
type UserList struct { type UserList struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Values []*User `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` Values []*User `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"`
@@ -1338,7 +1346,7 @@ const file_service_node_manager_api_manager_manager_proto_rawDesc = "" +
"\n" + "\n" +
".service/node_manager_api/manager/manager.proto\x12\x13node_manager_api.v1\"\x1a\n" + ".service/node_manager_api/manager/manager.proto\x12\x13node_manager_api.v1\"\x1a\n" +
"\x04Node\x12\x12\n" + "\x04Node\x12\x12\n" +
"\x04uuid\x18\x01 \x01(\tR\x04uuid\"\xd7\x01\n" + "\x04uuid\x18\x01 \x01(\tR\x04uuid\"\x80\x02\n" +
"\x04User\x12\x0e\n" + "\x04User\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x05R\x02id\x12\x1a\n" + "\x02id\x18\x01 \x01(\x05R\x02id\x12\x1a\n" +
"\busername\x18\x02 \x01(\tR\busername\x12\x18\n" + "\busername\x18\x02 \x01(\tR\busername\x12\x18\n" +
@@ -1348,7 +1356,9 @@ const file_service_node_manager_api_manager_manager_proto_rawDesc = "" +
"\bpassword\x18\x06 \x01(\tR\bpassword\x12\x16\n" + "\bpassword\x18\x06 \x01(\tR\bpassword\x12\x16\n" +
"\x06secret\x18\a \x01(\tR\x06secret\x12\x12\n" + "\x06secret\x18\a \x01(\tR\x06secret\x12\x12\n" +
"\x04flow\x18\b \x01(\tR\x04flow\x12\x19\n" + "\x04flow\x18\b \x01(\tR\x04flow\x12\x19\n" +
"\balter_id\x18\t \x01(\x05R\aalterId\"=\n" + "\balter_id\x18\t \x01(\x05R\aalterId\x12'\n" +
"\x0fauthorized_keys\x18\n" +
" \x03(\tR\x0eauthorizedKeys\"=\n" +
"\bUserList\x121\n" + "\bUserList\x121\n" +
"\x06values\x18\x01 \x03(\v2\x19.node_manager_api.v1.UserR\x06values\"\x83\x02\n" + "\x06values\x18\x01 \x03(\v2\x19.node_manager_api.v1.UserR\x06values\"\x83\x02\n" +
"\x10BandwidthLimiter\x12\x0e\n" + "\x10BandwidthLimiter\x12\x0e\n" +

View File

@@ -48,6 +48,7 @@ message User {
string secret = 7; string secret = 7;
string flow = 8; string flow = 8;
int32 alter_id = 9; int32 alter_id = 9;
repeated string authorized_keys = 10;
} }
message UserList { message UserList {

View File

@@ -239,15 +239,16 @@ func (s *RemoteNode) close(err error) {
func (s *RemoteNode) convertUser(user CS.User) *pb.User { func (s *RemoteNode) convertUser(user CS.User) *pb.User {
return &pb.User{ return &pb.User{
Id: int32(user.ID), Id: int32(user.ID),
Username: user.Username, Username: user.Username,
Inbound: user.Inbound, Inbound: user.Inbound,
Type: user.Type, Type: user.Type,
Uuid: user.UUID, Uuid: user.UUID,
Password: user.Password, Password: user.Password,
Secret: user.Secret, Secret: user.Secret,
Flow: user.Flow, AuthorizedKeys: user.AuthorizedKeys,
AlterId: int32(user.AlterID), Flow: user.Flow,
AlterId: int32(user.AlterID),
} }
} }

View File

@@ -98,12 +98,12 @@ func (s *Service) Start(stage adapter.StartStage) error {
s.adaptiveTimer = newAdaptiveTimer(s.logger, s.router, s.timerConfig) s.adaptiveTimer = newAdaptiveTimer(s.logger, s.router, s.timerConfig)
s.adaptiveTimer.start(false) s.adaptiveTimer.start(false)
if s.memoryLimit > 0 { if s.memoryLimit > 0 {
s.logger.Info("started memory monitor with limit: ", s.memoryLimit/(1024*1024), " MiB") s.logger.Notice("started memory monitor with limit: ", s.memoryLimit/(1024*1024), " MiB")
} else { } else {
s.logger.Info("started memory monitor with available memory detection") s.logger.Notice("started memory monitor with available memory detection")
} }
} else { } else {
s.logger.Info("started memory pressure monitor") s.logger.Notice("started memory pressure monitor")
} }
globalAccess.Lock() globalAccess.Lock()

View File

@@ -66,9 +66,9 @@ func (s *Service) Start(stage adapter.StartStage) error {
s.adaptiveTimer = newAdaptiveTimer(s.logger, s.router, s.timerConfig) s.adaptiveTimer = newAdaptiveTimer(s.logger, s.router, s.timerConfig)
s.adaptiveTimer.start(false) s.adaptiveTimer.start(false)
if s.useAvailable { if s.useAvailable {
s.logger.Info("started memory monitor with available memory detection") s.logger.Notice("started memory monitor with available memory detection")
} else { } else {
s.logger.Info("started memory monitor with limit: ", s.memoryLimit/(1024*1024), " MiB") s.logger.Notice("started memory monitor with limit: ", s.memoryLimit/(1024*1024), " MiB")
} }
return nil return nil
} }

View File

@@ -26,7 +26,10 @@ import (
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
goleak.VerifyTestMain(m) goleak.VerifyTestMain(m,
goleak.IgnoreTopFunction("github.com/panjf2000/ants/v2.(*poolCommon).purgeStaleWorkers"),
goleak.IgnoreTopFunction("github.com/panjf2000/ants/v2.(*poolCommon).ticktock"),
)
} }
var globalCtx context.Context var globalCtx context.Context

View File

@@ -36,6 +36,7 @@ require (
github.com/spyzhov/ajson v0.9.4 github.com/spyzhov/ajson v0.9.4
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.uber.org/goleak v1.3.0 go.uber.org/goleak v1.3.0
golang.org/x/crypto v0.49.0
golang.org/x/net v0.52.0 golang.org/x/net v0.52.0
) )
@@ -220,7 +221,6 @@ require (
go.uber.org/zap/exp v0.3.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/mod v0.34.0 // indirect golang.org/x/mod v0.34.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect

View File

@@ -18,16 +18,23 @@ type Device interface {
} }
type DeviceOptions struct { type DeviceOptions struct {
Context context.Context Context context.Context
Logger logger.ContextLogger Logger logger.ContextLogger
Handler tun.Handler System bool
UDPTimeout time.Duration UDPTimeout time.Duration
CreateDialer func(interfaceName string) N.Dialer CreateDialer func(interfaceName string) N.Dialer
Name string Name string
MTU uint32 MTU uint32
Address []netip.Prefix Address []netip.Prefix
AllowedAddress []netip.Prefix
} }
func NewDevice(options DeviceOptions) (Device, error) { func NewDevice(options DeviceOptions) (Device, error) {
return newStackDevice(options) if !options.System {
return newStackDevice(options)
} else if !tun.WithGVisor {
return newSystemDevice(options)
} else {
return newSystemStackDevice(options)
}
} }

View File

@@ -7,6 +7,7 @@ import (
"net" "net"
"net/netip" "net/netip"
"os" "os"
"sync"
"github.com/sagernet/gvisor/pkg/buffer" "github.com/sagernet/gvisor/pkg/buffer"
"github.com/sagernet/gvisor/pkg/tcpip" "github.com/sagernet/gvisor/pkg/tcpip"
@@ -15,9 +16,6 @@ import (
"github.com/sagernet/gvisor/pkg/tcpip/network/ipv4" "github.com/sagernet/gvisor/pkg/tcpip/network/ipv4"
"github.com/sagernet/gvisor/pkg/tcpip/network/ipv6" "github.com/sagernet/gvisor/pkg/tcpip/network/ipv6"
"github.com/sagernet/gvisor/pkg/tcpip/stack" "github.com/sagernet/gvisor/pkg/tcpip/stack"
"github.com/sagernet/gvisor/pkg/tcpip/transport/icmp"
"github.com/sagernet/gvisor/pkg/tcpip/transport/tcp"
"github.com/sagernet/gvisor/pkg/tcpip/transport/udp"
"github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/transport/wireguard" "github.com/sagernet/sing-box/transport/wireguard"
"github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun"
@@ -29,15 +27,15 @@ import (
) )
type stackDevice struct { type stackDevice struct {
ctx context.Context ctx context.Context
logger log.ContextLogger logger log.ContextLogger
stack *stack.Stack stack *stack.Stack
mtu uint32 mtu uint32
events chan wgTun.Event events chan wgTun.Event
wgTun.Device
outbound chan *stack.PacketBuffer outbound chan *stack.PacketBuffer
packetOutbound chan *buf.Buffer packetOutbound chan *buf.Buffer
done chan struct{} done chan struct{}
closeOnce sync.Once
dispatcher stack.NetworkDispatcher dispatcher stack.NetworkDispatcher
inet4Address netip.Addr inet4Address netip.Addr
inet6Address netip.Addr inet6Address netip.Addr
@@ -84,13 +82,6 @@ func newStackDevice(options DeviceOptions) (*stackDevice, error) {
} }
} }
tunDevice.stack = ipStack tunDevice.stack = ipStack
if options.Handler != nil {
ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket)
ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout).HandlePacket)
icmpForwarder := tun.NewICMPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout)
ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket)
ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket)
}
return tunDevice, nil return tunDevice, nil
} }
@@ -141,11 +132,17 @@ func (w *stackDevice) ListenPacket(ctx context.Context, destination M.Socksaddr)
} }
var networkProtocol tcpip.NetworkProtocolNumber var networkProtocol tcpip.NetworkProtocolNumber
if destination.IsIPv4() { if destination.IsIPv4() {
if !w.inet4Address.IsValid() {
return nil, E.New("missing IPv4 local address")
}
networkProtocol = header.IPv4ProtocolNumber networkProtocol = header.IPv4ProtocolNumber
bind.Addr = tun.AddressFromAddr(w.inet4Address) bind.Addr = tun.AddressFromAddr(w.inet4Address)
} else { } else {
if !w.inet6Address.IsValid() {
return nil, E.New("missing IPv6 local address")
}
networkProtocol = header.IPv6ProtocolNumber networkProtocol = header.IPv6ProtocolNumber
bind.Addr = tun.AddressFromAddr(w.inet4Address) bind.Addr = tun.AddressFromAddr(w.inet6Address)
} }
udpConn, err := gonet.DialUDP(w.stack, &bind, nil, networkProtocol) udpConn, err := gonet.DialUDP(w.stack, &bind, nil, networkProtocol)
if err != nil { if err != nil {
@@ -228,13 +225,15 @@ func (w *stackDevice) Events() <-chan wgTun.Event {
} }
func (w *stackDevice) Close() error { func (w *stackDevice) Close() error {
close(w.done) w.closeOnce.Do(func() {
close(w.events) close(w.done)
w.stack.Close() close(w.events)
for _, endpoint := range w.stack.CleanupEndpoints() { w.stack.Close()
endpoint.Abort() for _, endpoint := range w.stack.CleanupEndpoints() {
} endpoint.Abort()
w.stack.Wait() }
w.stack.Wait()
})
return nil return nil
} }

View File

@@ -0,0 +1,191 @@
package masque
import (
"context"
"errors"
"net"
"net/netip"
"os"
"runtime"
"sync"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-tun"
"github.com/sagernet/sing/common"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/service"
wgTun "github.com/sagernet/wireguard-go/tun"
)
var _ Device = (*systemDevice)(nil)
type systemDevice struct {
options DeviceOptions
dialer N.Dialer
device tun.Tun
batchDevice tun.LinuxTUN
events chan wgTun.Event
closeOnce sync.Once
inet4Address netip.Addr
inet6Address netip.Addr
}
func newSystemDevice(options DeviceOptions) (*systemDevice, error) {
if options.Name == "" {
options.Name = tun.CalculateInterfaceName("masque")
}
var inet4Address netip.Addr
var inet6Address netip.Addr
if len(options.Address) > 0 {
if prefix := common.Find(options.Address, func(it netip.Prefix) bool {
return it.Addr().Is4()
}); prefix.IsValid() {
inet4Address = prefix.Addr()
}
}
if len(options.Address) > 0 {
if prefix := common.Find(options.Address, func(it netip.Prefix) bool {
return it.Addr().Is6()
}); prefix.IsValid() {
inet6Address = prefix.Addr()
}
}
return &systemDevice{
options: options,
dialer: options.CreateDialer(options.Name),
events: make(chan wgTun.Event, 1),
inet4Address: inet4Address,
inet6Address: inet6Address,
}, nil
}
func (w *systemDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
return w.dialer.DialContext(ctx, network, destination)
}
func (w *systemDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
return w.dialer.ListenPacket(ctx, destination)
}
func (w *systemDevice) Inet4Address() netip.Addr {
return w.inet4Address
}
func (w *systemDevice) Inet6Address() netip.Addr {
return w.inet6Address
}
func (w *systemDevice) Start() error {
networkManager := service.FromContext[adapter.NetworkManager](w.options.Context)
tunOptions := tun.Options{
Name: w.options.Name,
Inet4Address: common.Filter(w.options.Address, func(it netip.Prefix) bool {
return it.Addr().Is4()
}),
Inet6Address: common.Filter(w.options.Address, func(it netip.Prefix) bool {
return it.Addr().Is6()
}),
MTU: w.options.MTU,
GSO: true,
InterfaceScope: true,
Inet4RouteAddress: common.Filter(w.options.AllowedAddress, func(it netip.Prefix) bool {
return it.Addr().Is4()
}),
Inet6RouteAddress: common.Filter(w.options.AllowedAddress, func(it netip.Prefix) bool {
return it.Addr().Is6()
}),
InterfaceMonitor: networkManager.InterfaceMonitor(),
InterfaceFinder: networkManager.InterfaceFinder(),
Logger: w.options.Logger,
}
if runtime.GOOS == "darwin" {
tunOptions.AutoRoute = true
}
tunInterface, err := tun.New(tunOptions)
if err != nil {
return err
}
err = tunInterface.Start()
if err != nil {
tunInterface.Close()
return err
}
w.options.Logger.Notice("started at ", w.options.Name)
w.device = tunInterface
batchTUN, isBatchTUN := tunInterface.(tun.LinuxTUN)
if isBatchTUN && batchTUN.BatchSize() > 1 {
w.batchDevice = batchTUN
}
w.events <- wgTun.EventUp
return nil
}
func (w *systemDevice) File() *os.File {
return nil
}
func (w *systemDevice) Read(bufs [][]byte, sizes []int, offset int) (count int, err error) {
if w.batchDevice != nil {
count, err = w.batchDevice.BatchRead(bufs, offset-tun.PacketOffset, sizes)
} else {
sizes[0], err = w.device.Read(bufs[0][offset-tun.PacketOffset:])
if err == nil {
count = 1
} else if errors.Is(err, tun.ErrTooManySegments) {
err = wgTun.ErrTooManySegments
}
}
return
}
func (w *systemDevice) Write(bufs [][]byte, offset int) (count int, err error) {
if w.batchDevice != nil {
return w.batchDevice.BatchWrite(bufs, offset)
}
for _, packet := range bufs {
if tun.PacketOffset > 0 {
clear(packet[offset-tun.PacketOffset : offset])
tun.PacketFillHeader(packet[offset-tun.PacketOffset:], tun.PacketIPVersion(packet[offset:]))
}
_, err = w.device.Write(packet[offset-tun.PacketOffset:])
if err != nil {
return
}
}
return
}
func (w *systemDevice) Flush() error {
return nil
}
func (w *systemDevice) MTU() (int, error) {
return int(w.options.MTU), nil
}
func (w *systemDevice) Name() (string, error) {
return w.options.Name, nil
}
func (w *systemDevice) Events() <-chan wgTun.Event {
return w.events
}
func (w *systemDevice) Close() error {
var err error
w.closeOnce.Do(func() {
close(w.events)
if w.device != nil {
err = w.device.Close()
}
})
return err
}
func (w *systemDevice) BatchSize() int {
if w.batchDevice != nil {
return w.batchDevice.BatchSize()
}
return 1
}

View File

@@ -0,0 +1,200 @@
//go:build with_gvisor
package masque
import (
"net/netip"
"sync"
"github.com/sagernet/gvisor/pkg/buffer"
"github.com/sagernet/gvisor/pkg/tcpip"
"github.com/sagernet/gvisor/pkg/tcpip/header"
"github.com/sagernet/gvisor/pkg/tcpip/network/ipv4"
"github.com/sagernet/gvisor/pkg/tcpip/network/ipv6"
"github.com/sagernet/gvisor/pkg/tcpip/stack"
"github.com/sagernet/sing-tun"
E "github.com/sagernet/sing/common/exceptions"
)
var _ Device = (*systemStackDevice)(nil)
type systemStackDevice struct {
*systemDevice
stack *stack.Stack
endpoint *systemStackEndpoint
writeBufs [][]byte
closeOnce sync.Once
}
func newSystemStackDevice(options DeviceOptions) (*systemStackDevice, error) {
system, err := newSystemDevice(options)
if err != nil {
return nil, err
}
endpoint := &systemStackEndpoint{
mtu: options.MTU,
done: make(chan struct{}),
}
ipStack, err := tun.NewGVisorStackWithOptions(endpoint, stack.NICOptions{}, true)
if err != nil {
return nil, err
}
for _, prefix := range options.Address {
addr := tun.AddressFromAddr(prefix.Addr())
protoAddr := tcpip.ProtocolAddress{
AddressWithPrefix: tcpip.AddressWithPrefix{
Address: addr,
PrefixLen: prefix.Bits(),
},
}
if prefix.Addr().Is4() {
protoAddr.Protocol = ipv4.ProtocolNumber
} else {
protoAddr.Protocol = ipv6.ProtocolNumber
}
gErr := ipStack.AddProtocolAddress(tun.DefaultNIC, protoAddr, stack.AddressProperties{})
if gErr != nil {
return nil, E.New("parse local address ", protoAddr.AddressWithPrefix, ": ", gErr.String())
}
}
sd := &systemStackDevice{
systemDevice: system,
stack: ipStack,
endpoint: endpoint,
}
endpoint.device = sd
return sd, nil
}
func (w *systemStackDevice) Write(bufs [][]byte, offset int) (count int, err error) {
if w.batchDevice != nil {
w.writeBufs = w.writeBufs[:0]
for _, packet := range bufs {
if !w.writeStack(packet[offset:]) {
w.writeBufs = append(w.writeBufs, packet)
}
}
if len(w.writeBufs) > 0 {
return w.batchDevice.BatchWrite(w.writeBufs, offset)
}
} else {
for _, packet := range bufs {
if !w.writeStack(packet[offset:]) {
if tun.PacketOffset > 0 {
clear(packet[offset-tun.PacketOffset : offset])
tun.PacketFillHeader(packet[offset-tun.PacketOffset:], tun.PacketIPVersion(packet[offset:]))
}
_, err = w.device.Write(packet[offset-tun.PacketOffset:])
}
if err != nil {
return
}
}
}
return
}
func (w *systemStackDevice) Close() error {
var err error
w.closeOnce.Do(func() {
close(w.endpoint.done)
w.stack.Close()
for _, endpoint := range w.stack.CleanupEndpoints() {
endpoint.Abort()
}
w.stack.Wait()
err = w.systemDevice.Close()
})
return err
}
func (w *systemStackDevice) writeStack(packet []byte) bool {
var (
networkProtocol tcpip.NetworkProtocolNumber
destination netip.Addr
)
switch header.IPVersion(packet) {
case header.IPv4Version:
networkProtocol = header.IPv4ProtocolNumber
destination = netip.AddrFrom4(header.IPv4(packet).DestinationAddress().As4())
case header.IPv6Version:
networkProtocol = header.IPv6ProtocolNumber
destination = netip.AddrFrom16(header.IPv6(packet).DestinationAddress().As16())
}
for _, prefix := range w.options.Address {
if prefix.Contains(destination) {
return false
}
}
packetBuffer := stack.NewPacketBuffer(stack.PacketBufferOptions{
Payload: buffer.MakeWithData(packet),
})
w.endpoint.dispatcher.DeliverNetworkPacket(networkProtocol, packetBuffer)
packetBuffer.DecRef()
return true
}
type systemStackEndpoint struct {
mtu uint32
done chan struct{}
device Device
dispatcher stack.NetworkDispatcher
}
func (ep *systemStackEndpoint) MTU() uint32 {
return ep.mtu
}
func (ep *systemStackEndpoint) SetMTU(mtu uint32) {
}
func (ep *systemStackEndpoint) MaxHeaderLength() uint16 {
return 0
}
func (ep *systemStackEndpoint) LinkAddress() tcpip.LinkAddress {
return ""
}
func (ep *systemStackEndpoint) SetLinkAddress(addr tcpip.LinkAddress) {
}
func (ep *systemStackEndpoint) Capabilities() stack.LinkEndpointCapabilities {
return stack.CapabilityRXChecksumOffload
}
func (ep *systemStackEndpoint) Attach(dispatcher stack.NetworkDispatcher) {
ep.dispatcher = dispatcher
}
func (ep *systemStackEndpoint) IsAttached() bool {
return ep.dispatcher != nil
}
func (ep *systemStackEndpoint) Wait() {
}
func (ep *systemStackEndpoint) ARPHardwareType() header.ARPHardwareType {
return header.ARPHardwareNone
}
func (ep *systemStackEndpoint) AddHeader(buffer *stack.PacketBuffer) {
}
func (ep *systemStackEndpoint) ParseHeader(ptr *stack.PacketBuffer) bool {
return true
}
func (ep *systemStackEndpoint) WritePackets(list stack.PacketBufferList) (int, tcpip.Error) {
for _, packetBuffer := range list.AsSlice() {
packet := packetBuffer.ToView().AsSlice()
ep.device.Write([][]byte{packet}, 0)
}
return list.Len(), nil
}
func (ep *systemStackEndpoint) Close() {
}
func (ep *systemStackEndpoint) SetOnCloseAction(f func()) {
}

Some files were not shown because too many files have changed in this diff Show More