From 9f5ccf43d467a13d68b7713e6e4406b5d2f2775b Mon Sep 17 00:00:00 2001 From: Shtorm <108103062+shtorm-7@users.noreply.github.com> Date: Sun, 7 Jun 2026 07:59:43 +0300 Subject: [PATCH] Add SSH inbound, log level. Update MTPROXY. Fixes --- .fpm_openwrt | 8 +- .github/build_openwrt_apk.sh | 12 +- .github/build_openwrt_packages.sh | 53 +++ .goreleaser.yaml | 51 ++- Makefile | 3 +- README.md | 2 + adapter/outbound/manager.go | 4 +- box.go | 4 +- cmd/sing-box/cmd_run.go | 1 + common/listener/listener_tcp.go | 2 +- common/listener/listener_udp.go | 2 +- common/tls/std_server.go | 6 +- daemon/started_service.pb.go | 37 +- daemon/started_service.proto | 7 +- dns/transport/dhcp/dhcp.go | 4 +- dns/transport_manager.go | 4 +- .../admin_panel-manager-node/manager.json | 2 +- examples/masque/client.json | 2 + examples/mieru/server.json | 3 + examples/mkcp/client.json | 10 +- examples/mkcp/server.json | 10 +- examples/mtproxy/server.json | 6 +- examples/openvpn/auth-user-pass.json | 2 + examples/openvpn/tls-auth.json | 2 + examples/openvpn/tls-crypt-v2.json | 2 + examples/openvpn/tls-crypt.json | 2 + examples/ssh/client.json | 52 +++ examples/ssh/server.json | 76 +++++ experimental/clashapi/api_meta_upgrade.go | 2 +- experimental/clashapi/server.go | 4 +- experimental/v2rayapi/server.go | 2 +- go.mod | 4 +- go.sum | 8 +- include/registry.go | 1 + log/export.go | 8 + log/format.go | 4 + log/level.go | 5 + log/nop.go | 6 + log/observable.go | 8 + option/manager.go | 2 +- option/masque.go | 19 +- option/mieru.go | 9 +- option/mtproxy.go | 2 +- option/node.go | 14 +- option/openvpn.go | 59 ++-- option/ssh.go | 33 ++ protocol/bond/conn.go | 2 +- protocol/failover/conn.go | 4 +- protocol/limiter/bandwidth/strategy.go | 2 - protocol/limiter/connection/outbound.go | 4 +- protocol/limiter/traffic/strategy.go | 2 - protocol/masque/outbound.go | 10 +- protocol/mieru/inbound.go | 19 +- protocol/mieru/outbound.go | 2 +- protocol/mtproxy/inbound.go | 2 +- protocol/naive/outbound.go | 2 +- protocol/openvpn/outbound.go | 10 +- protocol/ssh/certificate.go | 91 +++++ protocol/ssh/fallback.go | 322 ++++++++++++++++++ protocol/ssh/inbound.go | 152 +++++++++ protocol/ssh/service.go | 165 +++++++++ protocol/tailscale/dns_transport.go | 4 +- protocol/tailscale/endpoint.go | 2 +- protocol/tun/inbound.go | 2 +- protocol/vless/outbound.go | 81 ++--- provider/remote/provider.go | 4 +- route/network.go | 10 +- route/rule/rule_set_remote.go | 4 +- service/admin_panel/web/src/api/types.ts | 4 + .../web/src/components/CrudPage.tsx | 40 ++- .../admin_panel/web/src/pages/UsersPage.tsx | 12 +- service/manager/constant/dto.go | 68 ++-- service/manager/constant/repository.go | 2 - .../repository/postgresql/migration.go | 6 + .../repository/postgresql/repository.go | 40 ++- .../manager/repository/sqlite/migration.go | 6 + .../manager/repository/sqlite/repository.go | 40 ++- service/manager/service.go | 12 +- service/manager_api/grpc/client/converter.go | 27 +- service/manager_api/grpc/client/manager.go | 34 +- .../manager_api/grpc/manager/manager.pb.go | 112 +++--- .../manager_api/grpc/manager/manager.proto | 3 + .../grpc/manager/manager_grpc.pb.go | 4 +- service/manager_api/grpc/server/converter.go | 27 +- service/manager_api/grpc/server/rpc.go | 34 +- service/manager_api/http/server/openapi.yaml | 118 +++++-- service/node/inbound/ssh.go | 97 ++++++ service/node/service.go | 3 +- service/node_manager_api/client/client.go | 21 +- .../node_manager_api/manager/manager.pb.go | 38 ++- .../node_manager_api/manager/manager.proto | 1 + service/node_manager_api/server/node.go | 19 +- service/oomkiller/service.go | 6 +- service/oomkiller/service_stub.go | 4 +- test/box_test.go | 5 +- test/go.mod | 2 +- transport/masque/device.go | 25 +- transport/masque/device_stack.go | 47 ++- transport/masque/device_system.go | 191 +++++++++++ transport/masque/device_system_stack.go | 200 +++++++++++ transport/masque/options.go | 6 +- transport/masque/tunnel.go | 36 +- transport/openvpn/control.go | 4 +- transport/openvpn/data.go | 8 +- transport/openvpn/device.go | 25 +- transport/openvpn/device_stack.go | 45 ++- transport/openvpn/device_system.go | 191 +++++++++++ transport/openvpn/device_system_stack.go | 200 +++++++++++ transport/openvpn/tunnel.go | 23 +- transport/sudoku/obfs/httpmask/tunnel.go | 1 - transport/sudoku/uot.go | 1 - transport/trusttunnel/protocol.go | 6 +- transport/v2rayxhttp/client.go | 6 + transport/v2rayxhttp/server.go | 12 +- transport/wireguard/device_system.go | 2 +- 115 files changed, 2742 insertions(+), 527 deletions(-) create mode 100755 .github/build_openwrt_packages.sh create mode 100644 examples/ssh/client.json create mode 100644 examples/ssh/server.json create mode 100644 protocol/ssh/certificate.go create mode 100644 protocol/ssh/fallback.go create mode 100644 protocol/ssh/inbound.go create mode 100644 protocol/ssh/service.go create mode 100644 service/node/inbound/ssh.go create mode 100644 transport/masque/device_system.go create mode 100644 transport/masque/device_system_stack.go create mode 100644 transport/openvpn/device_system.go create mode 100644 transport/openvpn/device_system_stack.go diff --git a/.fpm_openwrt b/.fpm_openwrt index 3223ec8a..7d46d346 100644 --- a/.fpm_openwrt +++ b/.fpm_openwrt @@ -1,12 +1,16 @@ -s dir ---name sing-box +--name sing-box-extended --category net --license GPL-3.0-or-later ---description "The universal proxy platform." +--description "The universal proxy platform (extended)." --url "https://sing-box.sagernet.org/" --maintainer "nekohasekai " --no-deb-generate-changes +--provides sing-box +--conflicts sing-box +--replaces sing-box + --config-files /etc/config/sing-box --config-files /etc/sing-box/config.json diff --git a/.github/build_openwrt_apk.sh b/.github/build_openwrt_apk.sh index 59f07fd8..0ed1575c 100755 --- a/.github/build_openwrt_apk.sh +++ b/.github/build_openwrt_apk.sh @@ -27,10 +27,7 @@ fi PROJECT=$(cd "$(dirname "$0")/.."; pwd) # Convert version to APK format: -# 1.13.0-beta.8 -> 1.13.0_beta8-r0 -# 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=$(echo "$VERSION" | sed -E 's/-([a-z]+)\.([0-9]+)/_\1\2/' | sed -E 's/-[a-z]+-/./g') APK_VERSION="${APK_VERSION}-r0" ROOT_DIR=$(mktemp -d) @@ -78,15 +75,16 @@ done < "$PACKAGES_DIR/.conffiles" > "$PACKAGES_DIR/.conffiles_static" # Build APK apk --root "$APK_ROOT_DIR" mkpkg \ - --info "name:sing-box" \ + --info "name:sing-box-extended" \ --info "version:${APK_VERSION}" \ - --info "description:The universal proxy platform." \ + --info "description:The universal proxy platform (extended)." \ --info "arch:${ARCHITECTURE}" \ --info "license:GPL-3.0-or-later" \ - --info "origin:sing-box" \ + --info "origin:sing-box-extended" \ --info "url:https://sing-box.sagernet.org/" \ --info "maintainer:nekohasekai " \ --info "depends:ca-bundle kmod-inet-diag kmod-tun firewall4 kmod-nft-queue" \ + --info "provides:sing-box" \ --info "provider-priority:100" \ --script "pre-deinstall:${PROJECT}/release/config/openwrt.prerm" \ --files "$ROOT_DIR" \ diff --git a/.github/build_openwrt_packages.sh b/.github/build_openwrt_packages.sh new file mode 100755 index 00000000..65b41d2c --- /dev/null +++ b/.github/build_openwrt_packages.sh @@ -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 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 814c1845..dcf170fa 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -393,6 +393,49 @@ builds: - android_arm64 - android_386 - 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: - enabled: true ids: @@ -471,6 +514,12 @@ archives: - compressed-mips - 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' + - 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: enabled: false name_template: '{{ .ProjectName }}-{{ .Version }}.source' @@ -496,5 +545,5 @@ release: - archive-naive-purego-windows-amd64 - archive-naive-purego-windows-arm64 - archive-compressed - - package + - archive-openwrt skip_upload: true diff --git a/Makefile b/Makefile index 9aa87900..40d16193 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,6 @@ CRONET_GO_PATH ?= $(shell pwd)/cronet-go .PHONY: test release docs build build: - export GOTOOLCHAIN=local && \ go build $(MAIN_PARAMS) $(MAIN) build_admin_panel: @@ -94,6 +93,8 @@ release: build_admin_panel build_naive mkdir dist/release mv dist/*.tar.gz \ dist/*.zip \ + dist/*.ipk \ + dist/*.apk \ dist/release ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release ./codeberg-release.sh --replace --draft --prerelease -p 5 "v${VERSION}" dist/release diff --git a/README.md b/README.md index d31b18bc..a048f1db 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![license](https://img.shields.io/badge/license-GPLv3-blue.svg)](LICENSE) [![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) +[![telegram](https://img.shields.io/badge/telegram-channel-26A5E4.svg)](https://t.me/sing_box_extended) 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 - **TrustTunnel** — AdGuard's obfuscated VPN protocol, indistinguishable from HTTPS traffic - **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 - **Bond** — Link aggregation for increasing throughput - **Fallback** — Outbound group with priority-based switching diff --git a/adapter/outbound/manager.go b/adapter/outbound/manager.go index 1bbad69e..b6120776 100644 --- a/adapter/outbound/manager.go +++ b/adapter/outbound/manager.go @@ -233,7 +233,7 @@ func (m *Manager) Remove(tag string) error { if m.defaultOutbound == outbound { if len(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 { 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) { m.defaultOutbound = outbound if m.started { - m.logger.Info("updated default outbound to ", outbound.Tag()) + m.logger.Notice("updated default outbound to ", outbound.Tag()) } } return nil diff --git a/box.go b/box.go index f99dbdb2..e7f6813b 100644 --- a/box.go +++ b/box.go @@ -458,7 +458,7 @@ func (s *Box) PreStart() error { s.Close() 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 } @@ -477,7 +477,7 @@ func (s *Box) Start() error { s.Close() 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 } diff --git a/cmd/sing-box/cmd_run.go b/cmd/sing-box/cmd_run.go index f31db9dc..c2b70ad6 100644 --- a/cmd/sing-box/cmd_run.go +++ b/cmd/sing-box/cmd_run.go @@ -179,6 +179,7 @@ func run() error { for { osSignal := <-osSignals if osSignal == syscall.SIGHUP { + log.Notice("received SIGHUP, reloading...") err = check() if err != nil { log.Error(E.Cause(err, "reload service")) diff --git a/common/listener/listener_tcp.go b/common/listener/listener_tcp.go index 54d84a6b..8f6b9acc 100644 --- a/common/listener/listener_tcp.go +++ b/common/listener/listener_tcp.go @@ -77,7 +77,7 @@ func (l *Listener) ListenTCP() (net.Listener, error) { if err != nil { return nil, err } - l.logger.Info("tcp server started at ", tcpListener.Addr()) + l.logger.Notice("tcp server started at ", tcpListener.Addr()) l.tcpListener = tcpListener return tcpListener, err } diff --git a/common/listener/listener_udp.go b/common/listener/listener_udp.go index e689c8bb..5effc4d7 100644 --- a/common/listener/listener_udp.go +++ b/common/listener/listener_udp.go @@ -54,7 +54,7 @@ func (l *Listener) ListenUDP() (net.PacketConn, error) { } l.udpConn = udpConn.(*net.UDPConn) l.udpAddr = bindAddr - l.logger.Info("udp server started at ", udpConn.LocalAddr()) + l.logger.Notice("udp server started at ", udpConn.LocalAddr()) return udpConn, err } diff --git a/common/tls/std_server.go b/common/tls/std_server.go index a1a2a611..a96db65e 100644 --- a/common/tls/std_server.go +++ b/common/tls/std_server.go @@ -164,7 +164,7 @@ func (c *STDServerConfig) certificateUpdated(path string) error { config.Certificates = []tls.Certificate{keyPair} c.config = config c.access.Unlock() - c.logger.Info("reloaded TLS certificate") + c.logger.Notice("reloaded TLS certificate") } else if common.Contains(c.clientCertificatePath, path) { clientCertificateCA := x509.NewCertPool() var reloaded bool @@ -188,7 +188,7 @@ func (c *STDServerConfig) certificateUpdated(path string) error { config.ClientCAs = clientCertificateCA c.config = config c.access.Unlock() - c.logger.Info("reloaded client certificates") + c.logger.Notice("reloaded client certificates") } else if path == c.echKeyPath { echKey, err := os.ReadFile(c.echKeyPath) if err != nil { @@ -198,7 +198,7 @@ func (c *STDServerConfig) certificateUpdated(path string) error { if err != nil { return err } - c.logger.Info("reloaded ECH keys") + c.logger.Notice("reloaded ECH keys") } return nil } diff --git a/daemon/started_service.pb.go b/daemon/started_service.pb.go index 40e48639..b6f5de14 100644 --- a/daemon/started_service.pb.go +++ b/daemon/started_service.pb.go @@ -20,13 +20,14 @@ const ( type LogLevel int32 const ( - LogLevel_PANIC LogLevel = 0 - LogLevel_FATAL LogLevel = 1 - LogLevel_ERROR LogLevel = 2 - LogLevel_WARN LogLevel = 3 - LogLevel_INFO LogLevel = 4 - LogLevel_DEBUG LogLevel = 5 - LogLevel_TRACE LogLevel = 6 + LogLevel_PANIC LogLevel = 0 + LogLevel_FATAL LogLevel = 1 + LogLevel_ERROR LogLevel = 2 + LogLevel_WARN LogLevel = 3 + LogLevel_NOTICE LogLevel = 4 + LogLevel_INFO LogLevel = 5 + LogLevel_DEBUG LogLevel = 6 + LogLevel_TRACE LogLevel = 7 ) // Enum value maps for LogLevel. @@ -36,18 +37,20 @@ var ( 1: "FATAL", 2: "ERROR", 3: "WARN", - 4: "INFO", - 5: "DEBUG", - 6: "TRACE", + 4: "NOTICE", + 5: "INFO", + 6: "DEBUG", + 7: "TRACE", } LogLevel_value = map[string]int32{ - "PANIC": 0, - "FATAL": 1, - "ERROR": 2, - "WARN": 3, - "INFO": 4, - "DEBUG": 5, - "TRACE": 6, + "PANIC": 0, + "FATAL": 1, + "ERROR": 2, + "WARN": 3, + "NOTICE": 4, + "INFO": 5, + "DEBUG": 6, + "TRACE": 7, } ) diff --git a/daemon/started_service.proto b/daemon/started_service.proto index 8a76081a..a518cbcf 100644 --- a/daemon/started_service.proto +++ b/daemon/started_service.proto @@ -59,9 +59,10 @@ enum LogLevel { FATAL = 1; ERROR = 2; WARN = 3; - INFO = 4; - DEBUG = 5; - TRACE = 6; + NOTICE = 4; + INFO = 5; + DEBUG = 6; + TRACE = 7; } message Log { diff --git a/dns/transport/dhcp/dhcp.go b/dns/transport/dhcp/dhcp.go index 8dc22c49..c0988766 100644 --- a/dns/transport/dhcp/dhcp.go +++ b/dns/transport/dhcp/dhcp.go @@ -186,7 +186,7 @@ func (t *Transport) updateServers() error { 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) err = t.fetchServers0(fetchCtx, iface) cancel() @@ -303,7 +303,7 @@ func (t *Transport) recreateServers(iface *control.Interface, dhcpPacket *dhcpv4 return M.SocksaddrFrom(M.AddrFromIP(it), 53) }) 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 return nil diff --git a/dns/transport_manager.go b/dns/transport_manager.go index e289ccea..2ed6e8bd 100644 --- a/dns/transport_manager.go +++ b/dns/transport_manager.go @@ -217,7 +217,7 @@ func (m *TransportManager) Remove(tag string) error { return E.New("default server cannot be fakeip") } m.defaultTransport = nextTransport - m.logger.Info("updated default server to ", m.defaultTransport.Tag()) + m.logger.Notice("updated default server to ", m.defaultTransport.Tag()) } else { m.defaultTransport = nil } @@ -287,7 +287,7 @@ func (m *TransportManager) Create(ctx context.Context, logger log.ContextLogger, } m.defaultTransport = transport 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 { diff --git a/examples/admin_panel-manager-node/manager.json b/examples/admin_panel-manager-node/manager.json index bf9451cd..68e33cb2 100644 --- a/examples/admin_panel-manager-node/manager.json +++ b/examples/admin_panel-manager-node/manager.json @@ -32,7 +32,7 @@ "tag": "my-manager", "database": { "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 } }, { diff --git a/examples/masque/client.json b/examples/masque/client.json index 7bc61fd9..e858af6f 100644 --- a/examples/masque/client.json +++ b/examples/masque/client.json @@ -25,6 +25,8 @@ { "type": "masque", "tag": "masque-out", + "system": false, + "name": "masque0", "use_http2": false, "use_ipv6": false, "profile": { diff --git a/examples/mieru/server.json b/examples/mieru/server.json index b3617d80..f147835e 100644 --- a/examples/mieru/server.json +++ b/examples/mieru/server.json @@ -7,6 +7,9 @@ "type": "mieru", "tag": "mieru-in", "listen_port": 27017, + "listen_ports": [ + "27017-27019" + ], "transport": "TCP", "users": [ { diff --git a/examples/mkcp/client.json b/examples/mkcp/client.json index f0d25ebb..f0c9893d 100644 --- a/examples/mkcp/client.json +++ b/examples/mkcp/client.json @@ -31,7 +31,15 @@ "packet_encoding": "", "transport": { "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" } } ], diff --git a/examples/mkcp/server.json b/examples/mkcp/server.json index 0686a5d9..a238b8d2 100644 --- a/examples/mkcp/server.json +++ b/examples/mkcp/server.json @@ -24,7 +24,15 @@ ], "transport": { "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" } } ], diff --git a/examples/mtproxy/server.json b/examples/mtproxy/server.json index 5f4de8e8..0b013b94 100644 --- a/examples/mtproxy/server.json +++ b/examples/mtproxy/server.json @@ -26,9 +26,9 @@ "concurrency": 8192, // domain_fronting_port is a port we use to connect to a fronting domain. "domain_fronting_port": 443, - // domain_fronting_ip is an IP address to use when connecting to the fronting - // domain instead of resolving the hostname from the secret via DNS. - "domain_fronting_ip": "", + // domain_fronting_host is the address (IP or hostname) to use when connecting + // to the fronting domain instead of resolving the hostname from the secret via DNS. + "domain_fronting_host": "", // domain_fronting_proxy_protocol is used if communication between upstream // endpoint and sing-box supports proxy protocol. "domain_fronting_proxy_protocol": false, diff --git a/examples/openvpn/auth-user-pass.json b/examples/openvpn/auth-user-pass.json index 1b22a4ac..fd8557b4 100644 --- a/examples/openvpn/auth-user-pass.json +++ b/examples/openvpn/auth-user-pass.json @@ -13,6 +13,8 @@ { "type": "openvpn", "tag": "openvpn-out", + "system": false, + "name": "openvpn0", "servers": [ { "server": "vpn.example.com", diff --git a/examples/openvpn/tls-auth.json b/examples/openvpn/tls-auth.json index 8b86a89b..c868ca85 100644 --- a/examples/openvpn/tls-auth.json +++ b/examples/openvpn/tls-auth.json @@ -13,6 +13,8 @@ { "type": "openvpn", "tag": "openvpn-out", + "system": false, + "name": "openvpn0", "servers": [ { "server": "vpn.example.com", diff --git a/examples/openvpn/tls-crypt-v2.json b/examples/openvpn/tls-crypt-v2.json index ed4d3f4f..40e96079 100644 --- a/examples/openvpn/tls-crypt-v2.json +++ b/examples/openvpn/tls-crypt-v2.json @@ -13,6 +13,8 @@ { "type": "openvpn", "tag": "openvpn-out", + "system": false, + "name": "openvpn0", "servers": [ { "server": "vpn.example.com", diff --git a/examples/openvpn/tls-crypt.json b/examples/openvpn/tls-crypt.json index 04599ece..ce1ff4de 100644 --- a/examples/openvpn/tls-crypt.json +++ b/examples/openvpn/tls-crypt.json @@ -13,6 +13,8 @@ { "type": "openvpn", "tag": "openvpn-out", + "system": false, + "name": "openvpn0", "servers": [ { "server": "vpn.example.com", diff --git a/examples/ssh/client.json b/examples/ssh/client.json new file mode 100644 index 00000000..28e6d409 --- /dev/null +++ b/examples/ssh/client.json @@ -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 + } +} diff --git a/examples/ssh/server.json b/examples/ssh/server.json new file mode 100644 index 00000000..24289737 --- /dev/null +++ b/examples/ssh/server.json @@ -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 + } +} diff --git a/experimental/clashapi/api_meta_upgrade.go b/experimental/clashapi/api_meta_upgrade.go index df70088e..9935db47 100644 --- a/experimental/clashapi/api_meta_upgrade.go +++ b/experimental/clashapi/api_meta_upgrade.go @@ -30,7 +30,7 @@ func updateExternalUI(server *Server) func(w http.ResponseWriter, r *http.Reques render.JSON(w, r, newError(err.Error())) return } - server.logger.Info("updated external UI") + server.logger.Notice("updated external UI") render.JSON(w, r, render.M{"status": "ok"}) } } diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index a1171855..09466655 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -177,7 +177,7 @@ func (s *Server) Start(stage adapter.StartStage) error { if err != nil { 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() { err = s.httpServer.Serve(listener) 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.Info("updated mode: ", newMode) + s.logger.Notice("updated mode: ", newMode) } func (s *Server) HistoryStorage() adapter.URLTestHistoryStorage { diff --git a/experimental/v2rayapi/server.go b/experimental/v2rayapi/server.go index 8ebae1c4..3fc4feba 100644 --- a/experimental/v2rayapi/server.go +++ b/experimental/v2rayapi/server.go @@ -56,7 +56,7 @@ func (s *Server) Start(stage adapter.StartStage) error { if err != nil { return err } - s.logger.Info("grpc server started at ", listener.Addr()) + s.logger.Notice("grpc server started at ", listener.Addr()) s.tcpListener = listener go func() { err = s.grpcServer.Serve(listener) diff --git a/go.mod b/go.mod index 46501dc3..6fde228f 100644 --- a/go.mod +++ b/go.mod @@ -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/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/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 diff --git a/go.sum b/go.sum index d39aeb33..4ebaca11 100644 --- a/go.sum +++ b/go.sum @@ -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/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/mtg-multi v1.8.0-extended-1.0.1 h1:UeJkrCJJmIjTBywErVMx7fCSoBf4gh6QgT9bp9o1ajM= -github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.1/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.0.0/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/shtorm-7/mtg-multi v1.11.0-extended-1.0.0 h1:iBLll4ZZG8ULQcHWs6gGslZWtBN72Zo1zjySzMVHF7g= +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.1.0 h1:P4JL2cugjvEvnYu8tMmpR30SE1qsS45RcnNEwzDz5as= +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/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= github.com/shtorm-7/sing-vmess v0.2.7-extended-1.0.0 h1:WVheKmQH5hSQbJU1ZTKthKSutkTLWSb2hp4JuQhJBow= diff --git a/include/registry.go b/include/registry.go index 83490a8c..ebb1cd6a 100644 --- a/include/registry.go +++ b/include/registry.go @@ -81,6 +81,7 @@ func InboundRegistry() *inbound.Registry { vless.RegisterInbound(registry) anytls.RegisterInbound(registry) mieru.RegisterInbound(registry) + ssh.RegisterInbound(registry) bond.RegisterInbound(registry) failover.RegisterInbound(registry) diff --git a/log/export.go b/log/export.go index 60a0abbb..cf1faaf7 100644 --- a/log/export.go +++ b/log/export.go @@ -39,6 +39,10 @@ func Info(args ...any) { std.Info(args...) } +func Notice(args ...any) { + std.Notice(args...) +} + func Warn(args ...any) { std.Warn(args...) } @@ -67,6 +71,10 @@ func InfoContext(ctx context.Context, args ...any) { std.InfoContext(ctx, args...) } +func NoticeContext(ctx context.Context, args ...any) { + std.NoticeContext(ctx, args...) +} + func WarnContext(ctx context.Context, args ...any) { std.WarnContext(ctx, args...) } diff --git a/log/format.go b/log/format.go index 6f4347b1..9d8176f7 100644 --- a/log/format.go +++ b/log/format.go @@ -28,6 +28,8 @@ func (f Formatter) Format(ctx context.Context, level Level, tag string, message levelString = aurora.White(levelString).String() case LevelInfo: levelString = aurora.Cyan(levelString).String() + case LevelNotice: + levelString = aurora.Green(levelString).String() case LevelWarn: levelString = aurora.Yellow(levelString).String() case LevelError, LevelFatal, LevelPanic: @@ -97,6 +99,8 @@ func (f Formatter) FormatWithSimple(ctx context.Context, level Level, tag string levelString = aurora.White(levelString).String() case LevelInfo: levelString = aurora.Cyan(levelString).String() + case LevelNotice: + levelString = aurora.Green(levelString).String() case LevelWarn: levelString = aurora.Yellow(levelString).String() case LevelError, LevelFatal, LevelPanic: diff --git a/log/level.go b/log/level.go index b216fa34..d9714c52 100644 --- a/log/level.go +++ b/log/level.go @@ -11,6 +11,7 @@ const ( LevelFatal LevelError LevelWarn + LevelNotice LevelInfo LevelDebug LevelTrace @@ -24,6 +25,8 @@ func FormatLevel(level Level) string { return "debug" case LevelInfo: return "info" + case LevelNotice: + return "notice" case LevelWarn: return "warn" case LevelError: @@ -45,6 +48,8 @@ func ParseLevel(level string) (Level, error) { return LevelDebug, nil case "info": return LevelInfo, nil + case "notice": + return LevelNotice, nil case "warn", "warning": return LevelWarn, nil case "error": diff --git a/log/nop.go b/log/nop.go index 6369e99b..f4233480 100644 --- a/log/nop.go +++ b/log/nop.go @@ -47,6 +47,9 @@ func (f *nopFactory) Debug(args ...any) { func (f *nopFactory) Info(args ...any) { } +func (f *nopFactory) Notice(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) NoticeContext(ctx context.Context, args ...any) { +} + func (f *nopFactory) WarnContext(ctx context.Context, args ...any) { } diff --git a/log/observable.go b/log/observable.go index 768942bd..7fa1f97a 100644 --- a/log/observable.go +++ b/log/observable.go @@ -154,6 +154,10 @@ func (l *observableLogger) Info(args ...any) { l.InfoContext(context.Background(), args...) } +func (l *observableLogger) Notice(args ...any) { + l.NoticeContext(context.Background(), args...) +} + func (l *observableLogger) Warn(args ...any) { l.WarnContext(context.Background(), args...) } @@ -182,6 +186,10 @@ func (l *observableLogger) InfoContext(ctx context.Context, args ...any) { 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) { l.Log(ctx, LevelWarn, args) } diff --git a/option/manager.go b/option/manager.go index a138e14d..f8ee2f6c 100644 --- a/option/manager.go +++ b/option/manager.go @@ -6,6 +6,6 @@ type ManagerServiceDatabase struct { } type ManagerServiceOptions struct { - Inbounds []string `json:"inbounds"` + Inbounds []string `json:"inbounds"` Database ManagerServiceDatabase `json:"database"` } diff --git a/option/masque.go b/option/masque.go index 18751b4b..7ccef2a8 100644 --- a/option/masque.go +++ b/option/masque.go @@ -1,18 +1,23 @@ package option import ( + "net/netip" + "github.com/sagernet/sing/common/json/badoption" ) type MASQUEOutboundOptions struct { DialerOptions - UseHTTP2 bool `json:"use_http2,omitempty"` - UseIPv6 bool `json:"use_ipv6,omitempty"` - Profile CloudflareProfile `json:"profile,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"` + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + AllowedIPs badoption.Listable[netip.Prefix] `json:"allowed_ips,omitempty"` + UseHTTP2 bool `json:"use_http2,omitempty"` + UseIPv6 bool `json:"use_ipv6,omitempty"` + Profile CloudflareProfile `json:"profile,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 } diff --git a/option/mieru.go b/option/mieru.go index a3d5b7b6..e666cb2d 100644 --- a/option/mieru.go +++ b/option/mieru.go @@ -15,10 +15,11 @@ type MieruOutboundOptions struct { type MieruInboundOptions struct { ListenOptions - Users []MieruUser `json:"users,omitempty"` - Transport string `json:"transport,omitempty"` - TrafficPattern string `json:"traffic_pattern,omitempty"` - UserHintIsMandatory bool `json:"user_hint_is_mandatory,omitempty"` + ListenPorts badoption.Listable[string] `json:"listen_ports,omitempty"` + Users []MieruUser `json:"users,omitempty"` + Transport string `json:"transport,omitempty"` + TrafficPattern string `json:"traffic_pattern,omitempty"` + UserHintIsMandatory bool `json:"user_hint_is_mandatory,omitempty"` } type MieruUser struct { diff --git a/option/mtproxy.go b/option/mtproxy.go index 4e903a4a..71cd353c 100644 --- a/option/mtproxy.go +++ b/option/mtproxy.go @@ -11,7 +11,7 @@ type MTProxyInboundOptions struct { Users []MTProxyUser `json:"users,omitempty"` Concurrency uint `json:"concurrency,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"` PreferIP string `json:"prefer_ip,omitempty"` AutoUpdate bool `json:"auto_update,omitempty"` diff --git a/option/node.go b/option/node.go index 1e24d3eb..403364b5 100644 --- a/option/node.go +++ b/option/node.go @@ -1,11 +1,11 @@ package option type NodeServiceOptions struct { - UUID string `json:"uuid"` - Inbounds []string `json:"inbounds"` - ConnectionLimiters []string `json:"connection_limiters"` - BandwidthLimiters []string `json:"bandwidth_limiters"` - TrafficLimiters []string `json:"traffic_limiters"` - RateLimiters []string `json:"rate_limiters"` - Manager string `json:"manager"` + UUID string `json:"uuid"` + Inbounds []string `json:"inbounds"` + ConnectionLimiters []string `json:"connection_limiters"` + BandwidthLimiters []string `json:"bandwidth_limiters"` + TrafficLimiters []string `json:"traffic_limiters"` + RateLimiters []string `json:"rate_limiters"` + Manager string `json:"manager"` } diff --git a/option/openvpn.go b/option/openvpn.go index e2744995..3841c109 100644 --- a/option/openvpn.go +++ b/option/openvpn.go @@ -1,38 +1,45 @@ package option -import "github.com/sagernet/sing/common/json/badoption" +import ( + "net/netip" + + "github.com/sagernet/sing/common/json/badoption" +) type OpenVPNOutboundOptions struct { DialerOptions - Servers []ServerOptions `json:"servers"` - Proto string `json:"proto,omitempty"` - Cipher string `json:"cipher,omitempty"` - Auth string `json:"auth,omitempty"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - TLSCrypt string `json:"tls_crypt,omitempty"` - TLSCryptPath string `json:"tls_crypt_path,omitempty"` - TLSCryptV2 bool `json:"tls_crypt_v2,omitempty"` - TLSAuth string `json:"tls_auth,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"` + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + AllowedIPs badoption.Listable[netip.Prefix] `json:"allowed_ips,omitempty"` + Servers []ServerOptions `json:"servers"` + Proto string `json:"proto,omitempty"` + Cipher string `json:"cipher,omitempty"` + Auth string `json:"auth,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + TLSCrypt string `json:"tls_crypt,omitempty"` + TLSCryptPath string `json:"tls_crypt_path,omitempty"` + TLSCryptV2 bool `json:"tls_crypt_v2,omitempty"` + TLSAuth string `json:"tls_auth,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 } type OpenVPNTLSOptions struct { - Certificate string `json:"certificate,omitempty"` - CertificatePath string `json:"certificate_path,omitempty"` - Key string `json:"key,omitempty"` - KeyPath string `json:"key_path,omitempty"` - CA string `json:"ca,omitempty"` - CAPath string `json:"ca_path,omitempty"` - CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` - VerifyX509Name string `json:"verify_x509_name,omitempty"` - VerifyX509NameMode string `json:"verify_x509_name_mode,omitempty"` - KernelTx bool `json:"kernel_tx,omitempty"` - KernelRx bool `json:"kernel_rx,omitempty"` + Certificate string `json:"certificate,omitempty"` + CertificatePath string `json:"certificate_path,omitempty"` + Key string `json:"key,omitempty"` + KeyPath string `json:"key_path,omitempty"` + CA string `json:"ca,omitempty"` + CAPath string `json:"ca_path,omitempty"` + CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` + VerifyX509Name string `json:"verify_x509_name,omitempty"` + VerifyX509NameMode string `json:"verify_x509_name_mode,omitempty"` + KernelTx bool `json:"kernel_tx,omitempty"` + KernelRx bool `json:"kernel_rx,omitempty"` } type OpenVPNOutboundTLSOptionsContainer struct { diff --git a/option/ssh.go b/option/ssh.go index 1c6ca6bb..255b942b 100644 --- a/option/ssh.go +++ b/option/ssh.go @@ -2,6 +2,39 @@ package option 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 { DialerOptions ServerOptions diff --git a/protocol/bond/conn.go b/protocol/bond/conn.go index 992e5187..9c8327f9 100644 --- a/protocol/bond/conn.go +++ b/protocol/bond/conn.go @@ -22,7 +22,7 @@ func NewBondedConn(conns []net.Conn, downloadRatios, uploadRatios []uint8) *bond conns: conns, downloadRatios: downloadRatios, uploadRatios: uploadRatios, - readBuffer: bytes.NewBuffer(make([]byte, 0, 65536)), + readBuffer: bytes.NewBuffer(make([]byte, 0, 4096)), } } diff --git a/protocol/failover/conn.go b/protocol/failover/conn.go index 0be29762..69ffe8cb 100644 --- a/protocol/failover/conn.go +++ b/protocol/failover/conn.go @@ -37,13 +37,13 @@ type failoverConn struct { func NewFailoverConn(ctx context.Context, conn net.Conn, dial dial, onClose func()) *failoverConn { var writeBuffers [BufferSize][]byte for i := range BufferSize { - writeBuffers[i] = make([]byte, 0, 1000) + writeBuffers[i] = make([]byte, 0, 1024) } return &failoverConn{ Conn: conn, ctx: ctx, dial: dial, - readBuffer: bytes.NewBuffer(make([]byte, 0, 1000)), + readBuffer: bytes.NewBuffer(make([]byte, 0, 1024)), writeBuffers: writeBuffers, onClose: onClose, } diff --git a/protocol/limiter/bandwidth/strategy.go b/protocol/limiter/bandwidth/strategy.go index 1dbb0f14..710b895f 100644 --- a/protocol/limiter/bandwidth/strategy.go +++ b/protocol/limiter/bandwidth/strategy.go @@ -194,8 +194,6 @@ type bwConnEntry struct { conn net.Conn } - - type ManagerBandwidthStrategy struct { strategies map[string]BandwidthStrategy conns map[string][]*bwConnEntry diff --git a/protocol/limiter/connection/outbound.go b/protocol/limiter/connection/outbound.go index 384a5966..212c9e0f 100644 --- a/protocol/limiter/connection/outbound.go +++ b/protocol/limiter/connection/outbound.go @@ -6,8 +6,8 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/outbound" - C "github.com/sagernet/sing-box/constant" "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/option" "github.com/sagernet/sing-box/route" @@ -173,8 +173,6 @@ func (h *Outbound) GetStrategy() ConnectionStrategy { return h.strategy } - - func connChecker(ctx context.Context, closeFunc func() error) { <-ctx.Done() closeFunc() diff --git a/protocol/limiter/traffic/strategy.go b/protocol/limiter/traffic/strategy.go index 2c082a68..24848960 100644 --- a/protocol/limiter/traffic/strategy.go +++ b/protocol/limiter/traffic/strategy.go @@ -76,8 +76,6 @@ type connEntry struct { conn net.Conn } - - type ManagerTrafficStrategy struct { strategies map[string]TrafficStrategy conns map[string][]*connEntry diff --git a/protocol/masque/outbound.go b/protocol/masque/outbound.go index 1d7dd5bf..80a60c5a 100644 --- a/protocol/masque/outbound.go +++ b/protocol/masque/outbound.go @@ -9,7 +9,6 @@ import ( "time" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing/common" "github.com/sagernet/sing-box/adapter/outbound" "github.com/sagernet/sing-box/common/cloudflare" "github.com/sagernet/sing-box/common/dialer" @@ -18,6 +17,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/transport/masque" + "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/bufio" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" @@ -136,11 +136,19 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL ctx, logger, 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, Address: []netip.Prefix{ netip.MustParsePrefix(appConfig.IPv4 + "/32"), netip.MustParsePrefix(appConfig.IPv6 + "/128"), }, + AllowedAddress: options.AllowedIPs, Endpoint: endpoint, TLSConfig: tlsConfig, UseHTTP2: options.UseHTTP2, diff --git a/protocol/mieru/inbound.go b/protocol/mieru/inbound.go index 637df5df..7b64aec2 100644 --- a/protocol/mieru/inbound.go +++ b/protocol/mieru/inbound.go @@ -87,7 +87,7 @@ func (h *Inbound) Start(stage adapter.StartStage) error { 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() return nil } @@ -275,14 +275,21 @@ func buildMieruServerConfig(_ context.Context, options option.MieruInboundOption transportProtocol = mierupb.TransportProtocol_UDP.Enum() } - if options.ListenOptions.ListenPort == 0 { - return nil, nil, E.New("listen_port must be set") + if options.ListenOptions.ListenPort == 0 && len(options.ListenPorts) == 0 { + 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)), Protocol: transportProtocol, - }, + }) + } + for _, pr := range options.ListenPorts { + portBindings = append(portBindings, &mierupb.PortBinding{ + PortRange: proto.String(pr), + Protocol: transportProtocol, + }) } var users []*mierupb.User diff --git a/protocol/mieru/outbound.go b/protocol/mieru/outbound.go index d04826fd..b8c6ac3d 100644 --- a/protocol/mieru/outbound.go +++ b/protocol/mieru/outbound.go @@ -53,7 +53,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if err := c.Start(); err != nil { 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{ Adapter: outbound.NewAdapterWithDialerOptions(C.TypeMieru, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions), diff --git a/protocol/mtproxy/inbound.go b/protocol/mtproxy/inbound.go index fd7f5d60..d999d92c 100644 --- a/protocol/mtproxy/inbound.go +++ b/protocol/mtproxy/inbound.go @@ -63,7 +63,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo Secrets: secrets, Concurrency: options.GetConcurrency(), DomainFrontingPort: options.GetDomainFrontingPort(), - DomainFrontingIP: options.DomainFrontingIP, + DomainFrontingHost: options.DomainFrontingHost, DomainFrontingProxyProtocol: options.DomainFrontingProxyProtocol, PreferIP: options.GetPreferIP(), AutoUpdate: options.AutoUpdate, diff --git a/protocol/naive/outbound.go b/protocol/naive/outbound.go index 8249a1fe..a08389bc 100644 --- a/protocol/naive/outbound.go +++ b/protocol/naive/outbound.go @@ -227,7 +227,7 @@ func (h *Outbound) Start(stage adapter.StartStage) error { if err != nil { return err } - h.logger.Info("NaiveProxy started, version: ", h.client.Engine().Version()) + h.logger.Notice("NaiveProxy started, version: ", h.client.Engine().Version()) return nil } diff --git a/protocol/openvpn/outbound.go b/protocol/openvpn/outbound.go index 5b645181..5ccec1a5 100644 --- a/protocol/openvpn/outbound.go +++ b/protocol/openvpn/outbound.go @@ -4,9 +4,9 @@ package openvpn import ( "context" + "net" "os" "time" - "net" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/outbound" @@ -90,10 +90,18 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL logger: logger, } 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, Servers: options.Servers, TLSConfig: tlsConfig, Config: clientConfig, + AllowedAddress: options.AllowedIPs, ReconnectDelay: time.Duration(options.ReconnectDelay), PingInterval: time.Duration(options.PingInterval), }) diff --git a/protocol/ssh/certificate.go b/protocol/ssh/certificate.go new file mode 100644 index 00000000..145cb36e --- /dev/null +++ b/protocol/ssh/certificate.go @@ -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 +} diff --git a/protocol/ssh/fallback.go b/protocol/ssh/fallback.go new file mode 100644 index 00000000..32657aac --- /dev/null +++ b/protocol/ssh/fallback.go @@ -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) + } + } + } +} + + diff --git a/protocol/ssh/inbound.go b/protocol/ssh/inbound.go new file mode 100644 index 00000000..6879bc8f --- /dev/null +++ b/protocol/ssh/inbound.go @@ -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) +} diff --git a/protocol/ssh/service.go b/protocol/ssh/service.go new file mode 100644 index 00000000..5a7afcde --- /dev/null +++ b/protocol/ssh/service.go @@ -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 +} diff --git a/protocol/tailscale/dns_transport.go b/protocol/tailscale/dns_transport.go index 4195235c..fd6263e0 100644 --- a/protocol/tailscale/dns_transport.go +++ b/protocol/tailscale/dns_transport.go @@ -151,10 +151,10 @@ func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *n } 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 }), " ")) } else { - t.logger.Info("updated ", len(routes), " routes, ", len(hosts), " hosts") + t.logger.Notice("updated ", len(routes), " routes, ", len(hosts), " hosts") } return nil } diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index 7e5d3542..a2594ce9 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -435,7 +435,7 @@ func (t *Endpoint) watchState() { } authURL := localBackend.StatusWithoutPeers().AuthURL if authURL != "" { - t.logger.Info("Waiting for authentication: ", authURL) + t.logger.Notice("Waiting for authentication: ", authURL) if t.platformInterface != nil { err := t.platformInterface.SendNotification(&adapter.Notification{ Identifier: "tailscale-authentication", diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index dec6aa9d..ecc2759e 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -388,7 +388,7 @@ func (t *Inbound) Start(stage adapter.StartStage) error { return err } t.tunStack = tunStack - t.logger.Info("started at ", t.tunOptions.Name) + t.logger.Notice("started at ", t.tunOptions.Name) case adapter.StartStatePostStart: monitor := taskmonitor.New(t.logger, C.StartTimeout) monitor.Start("starting tun stack") diff --git a/protocol/vless/outbound.go b/protocol/vless/outbound.go index 1a17baad..1f46bb8c 100644 --- a/protocol/vless/outbound.go +++ b/protocol/vless/outbound.go @@ -5,7 +5,6 @@ import ( stdtls "crypto/tls" "encoding/base64" "net" - "reflect" "strings" "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) } } - // Parse encryption configuration + muxOpts := common.PtrValueOrDefault(options.Multiplex) + if muxOpts.Enabled { + options.Flow = "" + } if options.Encryption != "" && options.Encryption != "none" { encryptionConfig, err := parseClientEncryption(options.Encryption) 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) } - muxOpts := common.PtrValueOrDefault(options.Multiplex) - if muxOpts.Enabled { - options.Flow = "" - } outbound.client, err = vless.NewClient(options.UUID, options.Flow, logger) if err != nil { return nil, err @@ -191,7 +189,6 @@ func (h *vlessDialer) DialContext(ctx context.Context, network string, destinati conn, err = h.transport.DialContext(ctx) if err == nil && h.vision { if baseConn == nil { - // Only set baseConn if the transport delivered a TLS-capable connection if isVisionTLSConn(conn) { h.logger.Warn("Vision enabled but hook was not called by transport, using fallback") baseConn = conn @@ -210,7 +207,6 @@ func (h *vlessDialer) DialContext(ctx context.Context, network string, destinati return nil, err } - // Apply encryption if configured if h.encryption != nil { conn, err = h.encryption.Handshake(conn) 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 // The connection to pass to Vision (TLS or encryption layer) + var visionBaseConn net.Conn var visionCanSplice bool if h.vision { - isRAWTransport := h.transport == nil - - if baseConn != nil && !isVisionTLSConn(baseConn) { - 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") + conn, visionBaseConn, visionCanSplice, err = h.setupVision(conn, baseConn) + if err != nil { + return nil, err } } @@ -255,8 +227,6 @@ func (h *vlessDialer) DialContext(ctx context.Context, network string, destinati case N.NetworkTCP: h.logger.InfoContext(ctx, "outbound connection to ", destination) 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.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) { h.logger.InfoContext(ctx, "outbound packet connection to ", destination) ctx, metadata := adapter.ExtendContext(ctx) @@ -299,7 +292,6 @@ func (h *vlessDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) common.Close(conn) return nil, err } - // Apply encryption if configured if h.encryption != nil { conn, err = h.encryption.Handshake(conn) if err != nil { @@ -362,7 +354,6 @@ func (c *visionConnWrapper) WriterReplaceable() bool { return true } -// isVisionTLSConn returns true when the provided connection exposes TLS semantics Vision expects. func isVisionTLSConn(conn net.Conn) bool { if conn == nil { return false @@ -373,16 +364,6 @@ func isVisionTLSConn(conn net.Conn) bool { if _, ok := conn.(interface{ Handshake() error }); ok { 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 } diff --git a/provider/remote/provider.go b/provider/remote/provider.go index deef93d6..119d75a7 100644 --- a/provider/remote/provider.go +++ b/provider/remote/provider.go @@ -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 default: 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.Info("updated outbound provider ", s.Tag()) + s.logger.Notice("updated outbound provider ", s.Tag()) return nil } diff --git a/route/network.go b/route/network.go index 03e94879..63fc6b30 100644 --- a/route/network.go +++ b/route/network.go @@ -300,7 +300,7 @@ func (r *NetworkManager) UpdateInterfaces() error { oldInterface.Expensive == newInterface.Expensive && 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 options = append(options, F.ToString(it.Type)) if it.Expensive { @@ -430,9 +430,9 @@ func (r *NetworkManager) onWIFIStateChanged(state adapter.WIFIState) { r.wifiState = state r.wifiStateMutex.Unlock() 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 { - r.logger.Info("WIFI disconnected") + r.logger.Notice("WIFI disconnected") } } else { r.wifiStateMutex.Unlock() @@ -512,7 +512,7 @@ func (r *NetworkManager) notifyInterfaceUpdate(defaultInterface *control.Interfa 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() if !r.started { @@ -538,5 +538,5 @@ func (r *NetworkManager) notifyWindowsPowerEvent(event 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") } diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go index bda6e23f..d626f6b1 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -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 default: 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.Info("updated rule-set ", s.options.Tag) + s.logger.Notice("updated rule-set ", s.options.Tag) return nil } diff --git a/service/admin_panel/web/src/api/types.ts b/service/admin_panel/web/src/api/types.ts index feda56fa..4948c5b8 100644 --- a/service/admin_panel/web/src/api/types.ts +++ b/service/admin_panel/web/src/api/types.ts @@ -42,6 +42,7 @@ export type UserType = | "mtproxy" | "naive" | "socks" + | "ssh" | "trojan" | "trusttunnel" | "tuic" @@ -57,6 +58,7 @@ export interface User { uuid: string; password: string; secret: string; + authorized_keys: string[]; flow: string; alter_id: number; created_at: string; @@ -70,6 +72,7 @@ export interface UserCreate { uuid?: string; password?: string; secret?: string; + authorized_keys?: string[]; flow?: string; alter_id?: number; } @@ -77,6 +80,7 @@ export interface UserUpdate { uuid?: string; password?: string; secret?: string; + authorized_keys?: string[]; flow?: string; alter_id?: number; } diff --git a/service/admin_panel/web/src/components/CrudPage.tsx b/service/admin_panel/web/src/components/CrudPage.tsx index be6f7428..bfaad5ee 100644 --- a/service/admin_panel/web/src/components/CrudPage.tsx +++ b/service/admin_panel/web/src/components/CrudPage.tsx @@ -64,7 +64,7 @@ import type { Listable } from "../api/types"; import { notifyApiError, useNotify } from "../notifications/NotificationsProvider"; 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 panel. FILTER_GAP is the flex gap between cells (matches the @@ -126,7 +126,7 @@ export interface FieldSpec { // spec, matching what `emptyForm` would produce. function emptyValueForField(f: FieldSpec): unknown { if (f.defaultValue !== undefined) return f.defaultValue; - if (f.type === "multiselect") return []; + if (f.type === "multiselect" || f.type === "string-list") return []; return ""; } @@ -575,7 +575,7 @@ function fieldVisible( // strings, missing selections, and empty arrays for multi-select / ids. function isFieldEmpty(f: FieldSpec, value: unknown): boolean { 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 (typeof value === "string") return value.trim() === ""; return true; @@ -4764,6 +4764,40 @@ function CrudDialog({ /> ); } + if (f.type === "string-list") { + const arr = Array.isArray(value) ? (value as string[]) : []; + return ( + + + {f.label}{f.required ? " *" : ""} + + + {arr.map((item, idx) => ( + + { + const next = [...arr]; + next[idx] = e.target.value; + set(f.name, next); + }} + /> + set(f.name, arr.filter((_, i) => i !== idx))}> + + + + ))} + + + {fieldErr && {fieldErr}} + + ); + } const isNumber = f.type === "number"; // Numeric value of the current cell. Falls back to 0 for the // empty state so the up-arrow always has a sensible base to diff --git a/service/admin_panel/web/src/pages/UsersPage.tsx b/service/admin_panel/web/src/pages/UsersPage.tsx index e3403608..180635b4 100644 --- a/service/admin_panel/web/src/pages/UsersPage.tsx +++ b/service/admin_panel/web/src/pages/UsersPage.tsx @@ -25,6 +25,7 @@ const USER_TYPES: { value: UserType; label: string }[] = [ { value: "mtproxy", label: "MTProxy" }, { value: "naive", label: "Naive" }, { value: "socks", label: "SOCKS" }, + { value: "ssh", label: "SSH" }, { value: "trojan", label: "Trojan" }, { value: "trusttunnel", label: "TrustTunnel" }, { 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 // are filtered out before validateRequired runs). const SHOW_UUID = new Set(["vless", "vmess", "tuic"]); -const SHOW_PASSWORD = new Set(["anytls", "http", "hysteria", "hysteria2", "mixed", "naive", "socks", "trojan", "trusttunnel", "tuic"]); +const SHOW_PASSWORD = new Set(["anytls", "http", "hysteria", "hysteria2", "mixed", "naive", "socks", "ssh", "trojan", "trusttunnel", "tuic"]); const SHOW_SECRET = new Set(["mtproxy"]); +const SHOW_AUTHORIZED_KEYS = new Set(["ssh"]); const SHOW_FLOW = new Set(["vless"]); const SHOW_ALTER_ID = new Set(["vmess"]); @@ -103,7 +105,7 @@ export function UsersPage() { options: USER_TYPES, // Switching the user type wipes every credential field so the form // 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 // 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 // only enforced for hysteria/hysteria2/trojan/tuic and not for vless. { 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: "authorized_keys", label: "Authorized Keys", type: "string-list", visibleWhen: showFor(SHOW_AUTHORIZED_KEYS) }, { name: "flow", label: "Flow", @@ -135,6 +138,7 @@ export function UsersPage() { uuid: u.uuid, password: u.password, secret: u.secret, + authorized_keys: u.authorized_keys ?? [], flow: u.flow, alter_id: u.alter_id, }), @@ -146,6 +150,7 @@ export function UsersPage() { uuid: f.uuid ? String(f.uuid).trim() : undefined, password: f.password ? String(f.password) : 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, 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.password !== undefined && f.password !== "") out.password = String(f.password); 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.alter_id !== undefined && f.alter_id !== "") out.alter_id = Number(f.alter_id); return out; diff --git a/service/manager/constant/dto.go b/service/manager/constant/dto.go index 4869c9b1..7135446f 100644 --- a/service/manager/constant/dto.go +++ b/service/manager/constant/dto.go @@ -49,46 +49,50 @@ type BaseNode struct { } type User struct { - ID int `json:"id" validate:"required"` - SquadIDs []int `json:"squad_ids" validate:"required,min=1"` - Username string `json:"username" validate:"required"` - Inbound string `json:"inbound" validate:"required"` - Type string `json:"type" validate:"required"` - UUID string `json:"uuid" validate:"required"` - Password string `json:"password" validate:"required"` - Secret string `json:"secret" validate:"required"` - Flow string `json:"flow" validate:"required"` - AlterID int `json:"alter_id" validate:"required"` - CreatedAt time.Time `json:"created_at" validate:"required"` - UpdatedAt time.Time `json:"updated_at" validate:"required"` + ID int `json:"id" validate:"required"` + SquadIDs []int `json:"squad_ids" validate:"required,min=1"` + Username string `json:"username" validate:"required"` + Inbound string `json:"inbound" validate:"required"` + Type string `json:"type" validate:"required"` + UUID string `json:"uuid" validate:"required"` + Password string `json:"password" validate:"required"` + Secret string `json:"secret" validate:"required"` + AuthorizedKeys []string `json:"authorized_keys" validate:"omitempty"` + Flow string `json:"flow" validate:"required"` + AlterID int `json:"alter_id" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` } type UserCreate struct { - SquadIDs []int `json:"squad_ids" validate:"required,min=1"` - Username string `json:"username" 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"` - UUID string `json:"uuid" validate:"omitempty,uuid4"` - Password string `json:"password" validate:"omitempty"` - Secret string `json:"secret" validate:"omitempty"` - Flow string `json:"flow" validate:"omitempty"` - AlterID int `json:"alter_id" validate:"omitempty"` + SquadIDs []int `json:"squad_ids" validate:"required,min=1"` + Username string `json:"username" validate:"required"` + Inbound string `json:"inbound" validate:"required"` + 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"` + Password string `json:"password" validate:"omitempty"` + Secret string `json:"secret" validate:"omitempty"` + AuthorizedKeys []string `json:"authorized_keys" validate:"omitempty"` + Flow string `json:"flow" validate:"omitempty"` + AlterID int `json:"alter_id" validate:"omitempty"` } type UserUpdate struct { - UUID string `json:"uuid" validate:"omitempty,uuid4"` - Password string `json:"password" validate:"omitempty"` - Secret string `json:"secret" validate:"omitempty"` - Flow string `json:"flow" validate:"omitempty"` - AlterID int `json:"alter_id" validate:"omitempty"` + UUID string `json:"uuid" validate:"omitempty,uuid4"` + Password string `json:"password" validate:"omitempty"` + Secret string `json:"secret" validate:"omitempty"` + AuthorizedKeys []string `json:"authorized_keys" validate:"omitempty"` + Flow string `json:"flow" validate:"omitempty"` + AlterID int `json:"alter_id" validate:"omitempty"` } type BaseUser struct { - UUID string `json:"uuid" validate:"omitempty,uuid4"` - Password string `json:"password" validate:"omitempty"` - Secret string `json:"secret" validate:"omitempty"` - Flow string `json:"flow" validate:"omitempty"` - AlterID int `json:"alter_id" validate:"omitempty"` + UUID string `json:"uuid" validate:"omitempty,uuid4"` + Password string `json:"password" validate:"omitempty"` + Secret string `json:"secret" validate:"omitempty"` + AuthorizedKeys []string `json:"authorized_keys" validate:"omitempty"` + Flow string `json:"flow" validate:"omitempty"` + AlterID int `json:"alter_id" validate:"omitempty"` } type ConnectionLimiter struct { @@ -261,5 +265,3 @@ type BaseRateLimiter struct { 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"` } - - diff --git a/service/manager/constant/repository.go b/service/manager/constant/repository.go index c700fe6f..7de46800 100644 --- a/service/manager/constant/repository.go +++ b/service/manager/constant/repository.go @@ -51,5 +51,3 @@ type Repository interface { UpdateRateLimiter(id int, limiter RateLimiterUpdate) (RateLimiter, error) DeleteRateLimiter(id int) (RateLimiter, error) } - - diff --git a/service/manager/repository/postgresql/migration.go b/service/manager/repository/postgresql/migration.go index dd958d31..7771a04c 100644 --- a/service/manager/repository/postgresql/migration.go +++ b/service/manager/repository/postgresql/migration.go @@ -331,6 +331,12 @@ var migrations = map[string]string{ DROP TABLE IF EXISTS traffic_limiter_to_squad; 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 { diff --git a/service/manager/repository/postgresql/repository.go b/service/manager/repository/postgresql/repository.go index 05489f0b..b7e22a99 100644 --- a/service/manager/repository/postgresql/repository.go +++ b/service/manager/repository/postgresql/repository.go @@ -515,6 +515,11 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us } defer tx.Rollback(r.ctx) now := time.Now() + authorizedKeysJSON, err := marshalStringSlice(user.AuthorizedKeys) + if err != nil { + return u, err + } + var authorizedKeys stringSliceJSON err = tx.QueryRow( r.ctx, ` INSERT INTO users ( @@ -524,12 +529,13 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us uuid, password, secret, + authorized_keys, flow, alter_id, created_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 id, username, @@ -538,6 +544,7 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us uuid, password, secret, + authorized_keys, flow, alter_id, created_at, @@ -549,6 +556,7 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us user.UUID, user.Password, user.Secret, + authorizedKeysJSON, user.Flow, user.AlterID, now, @@ -561,6 +569,7 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us &u.UUID, &u.Password, &u.Secret, + &authorizedKeys, &u.Flow, &u.AlterID, &u.CreatedAt, @@ -569,6 +578,7 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us if err != nil { return u, err } + u.AuthorizedKeys = []string(authorizedKeys) rows := make([][]any, len(user.SquadIDs)) for i, squadID := range user.SquadIDs { rows[i] = []any{u.ID, squadID} @@ -605,6 +615,7 @@ func (r *PostgreSQLRepository) GetUsers(filters map[string][]string) ([]constant "uuid", "password", "secret", + "authorized_keys", "flow", "alter_id", "created_at", @@ -636,6 +647,7 @@ func (r *PostgreSQLRepository) GetUsers(filters map[string][]string) ([]constant &u.UUID, &u.Password, &u.Secret, + &u.AuthorizedKeys, &u.Flow, &u.AlterID, &u.CreatedAt, @@ -681,6 +693,7 @@ func (r *PostgreSQLRepository) GetUser(id int) (constant.User, error) { uuid, password, secret, + authorized_keys, flow, alter_id, created_at, @@ -696,6 +709,7 @@ func (r *PostgreSQLRepository) GetUser(id int) (constant.User, error) { &u.UUID, &u.Password, &u.Secret, + &u.AuthorizedKeys, &u.Flow, &u.AlterID, &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) { 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, ` UPDATE users SET uuid = $1, password = $2, secret = $3, - flow = $4, - alter_id = $5, - updated_at = $6 - WHERE id = $7 + authorized_keys = $4, + flow = $5, + alter_id = $6, + updated_at = $7 + WHERE id = $8 RETURNING id, ARRAY( @@ -730,6 +749,7 @@ func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (con uuid, password, secret, + authorized_keys, flow, alter_id, created_at, @@ -738,6 +758,7 @@ func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (con user.UUID, user.Password, user.Secret, + authorizedKeysJSON, user.Flow, user.AlterID, time.Now(), @@ -751,6 +772,7 @@ func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (con &u.UUID, &u.Password, &u.Secret, + &u.AuthorizedKeys, &u.Flow, &u.AlterID, &u.CreatedAt, @@ -777,6 +799,7 @@ func (r *PostgreSQLRepository) DeleteUser(id int) (constant.User, error) { uuid, password, secret, + authorized_keys, flow, alter_id, created_at, @@ -790,6 +813,7 @@ func (r *PostgreSQLRepository) DeleteUser(id int) (constant.User, error) { &u.UUID, &u.Password, &u.Secret, + &u.AuthorizedKeys, &u.Flow, &u.AlterID, &u.CreatedAt, @@ -2143,11 +2167,11 @@ func init() { "updated_at_end": LessThanFilter("updated_at"), "sort_asc": ReplacedSortAscFilter( 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( 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(), "limit": LimitFilter(), diff --git a/service/manager/repository/sqlite/migration.go b/service/manager/repository/sqlite/migration.go index 33a1b088..f1f2233c 100644 --- a/service/manager/repository/sqlite/migration.go +++ b/service/manager/repository/sqlite/migration.go @@ -213,6 +213,12 @@ var migrations = map[string]string{ DROP TABLE IF EXISTS nodes; 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 { diff --git a/service/manager/repository/sqlite/repository.go b/service/manager/repository/sqlite/repository.go index 7857b3ef..73fb0c4e 100644 --- a/service/manager/repository/sqlite/repository.go +++ b/service/manager/repository/sqlite/repository.go @@ -510,6 +510,11 @@ func (r *SQLiteRepository) CreateUser(user constant.UserCreate) (constant.User, } defer tx.Rollback() now := time.Now() + authorizedKeysJSON, err := marshalStringSlice(user.AuthorizedKeys) + if err != nil { + return u, err + } + var authorizedKeys stringSliceJSON err = tx.QueryRowContext( r.ctx, ` INSERT INTO users ( @@ -519,12 +524,13 @@ func (r *SQLiteRepository) CreateUser(user constant.UserCreate) (constant.User, uuid, password, secret, + authorized_keys, flow, alter_id, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, username, @@ -533,6 +539,7 @@ func (r *SQLiteRepository) CreateUser(user constant.UserCreate) (constant.User, uuid, password, secret, + authorized_keys, flow, alter_id, created_at, @@ -544,6 +551,7 @@ func (r *SQLiteRepository) CreateUser(user constant.UserCreate) (constant.User, user.UUID, user.Password, user.Secret, + authorizedKeysJSON, user.Flow, user.AlterID, now, @@ -556,6 +564,7 @@ func (r *SQLiteRepository) CreateUser(user constant.UserCreate) (constant.User, &u.UUID, &u.Password, &u.Secret, + &authorizedKeys, &u.Flow, &u.AlterID, &u.CreatedAt, @@ -564,6 +573,7 @@ func (r *SQLiteRepository) CreateUser(user constant.UserCreate) (constant.User, if err != nil { return u, err } + u.AuthorizedKeys = []string(authorizedKeys) stmt, err := tx.PrepareContext(r.ctx, `INSERT INTO user_to_squad (user_id, squad_id) VALUES (?, ?)`) if err != nil { return u, err @@ -596,6 +606,7 @@ func (r *SQLiteRepository) GetUsers(filters map[string][]string) ([]constant.Use "uuid", "password", "secret", + "authorized_keys", "flow", "alter_id", "created_at", @@ -619,6 +630,7 @@ func (r *SQLiteRepository) GetUsers(filters map[string][]string) ([]constant.Use for rows.Next() { var u constant.User var squadIDs intSliceJSON + var authorizedKeys stringSliceJSON if err := rows.Scan( &u.ID, &squadIDs, @@ -628,6 +640,7 @@ func (r *SQLiteRepository) GetUsers(filters map[string][]string) ([]constant.Use &u.UUID, &u.Password, &u.Secret, + &authorizedKeys, &u.Flow, &u.AlterID, &u.CreatedAt, @@ -636,6 +649,7 @@ func (r *SQLiteRepository) GetUsers(filters map[string][]string) ([]constant.Use return nil, err } u.SquadIDs = []int(squadIDs) + u.AuthorizedKeys = []string(authorizedKeys) result = append(result, u) } 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) { var u constant.User var squadIDs intSliceJSON + var authorizedKeys stringSliceJSON err := r.db.QueryRowContext(r.ctx, ` SELECT id, @@ -675,6 +690,7 @@ func (r *SQLiteRepository) GetUser(id int) (constant.User, error) { uuid, password, secret, + authorized_keys, flow, alter_id, created_at, @@ -690,25 +706,33 @@ func (r *SQLiteRepository) GetUser(id int) (constant.User, error) { &u.UUID, &u.Password, &u.Secret, + &authorizedKeys, &u.Flow, &u.AlterID, &u.CreatedAt, &u.UpdatedAt, ) u.SquadIDs = []int(squadIDs) + u.AuthorizedKeys = []string(authorizedKeys) return u, notFoundErr(err) } func (r *SQLiteRepository) UpdateUser(id int, user constant.UserUpdate) (constant.User, error) { var u constant.User 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, ` UPDATE users SET uuid = ?, password = ?, secret = ?, + authorized_keys = ?, flow = ?, alter_id = ?, updated_at = ? @@ -726,6 +750,7 @@ func (r *SQLiteRepository) UpdateUser(id int, user constant.UserUpdate) (constan uuid, password, secret, + authorized_keys, flow, alter_id, created_at, @@ -734,6 +759,7 @@ func (r *SQLiteRepository) UpdateUser(id int, user constant.UserUpdate) (constan user.UUID, user.Password, user.Secret, + authorizedKeysJSON, user.Flow, user.AlterID, time.Now(), @@ -747,18 +773,21 @@ func (r *SQLiteRepository) UpdateUser(id int, user constant.UserUpdate) (constan &u.UUID, &u.Password, &u.Secret, + &authorizedKeys, &u.Flow, &u.AlterID, &u.CreatedAt, &u.UpdatedAt, ) u.SquadIDs = []int(squadIDs) + u.AuthorizedKeys = []string(authorizedKeys) return u, err } func (r *SQLiteRepository) DeleteUser(id int) (constant.User, error) { var u constant.User var squadIDs intSliceJSON + var authorizedKeys stringSliceJSON err := r.db.QueryRowContext(r.ctx, ` DELETE FROM users WHERE id = ? @@ -775,6 +804,7 @@ func (r *SQLiteRepository) DeleteUser(id int) (constant.User, error) { uuid, password, secret, + authorized_keys, flow, alter_id, created_at, @@ -788,12 +818,14 @@ func (r *SQLiteRepository) DeleteUser(id int) (constant.User, error) { &u.UUID, &u.Password, &u.Secret, + &authorizedKeys, &u.Flow, &u.AlterID, &u.CreatedAt, &u.UpdatedAt, ) u.SquadIDs = []int(squadIDs) + u.AuthorizedKeys = []string(authorizedKeys) return u, err } @@ -2160,11 +2192,11 @@ func init() { "updated_at_end": LessThanFilter("updated_at"), "sort_asc": ReplacedSortAscFilter( 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( 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(), "limit": LimitFilter(), diff --git a/service/manager/service.go b/service/manager/service.go index 278df0d2..d149a03f 100644 --- a/service/manager/service.go +++ b/service/manager/service.go @@ -32,10 +32,10 @@ func RegisterService(registry *boxService.Registry) { type Service struct { boxService.Adapter - ctx context.Context - logger log.ContextLogger - repository constant.Repository - nodes map[string]constant.ConnectedNode + ctx context.Context + logger log.ContextLogger + repository constant.Repository + nodes map[string]constant.ConnectedNode limiterLocks map[int]map[string]*cache.Cache[string, struct{}] trafficUsage map[int]*TrafficUsage @@ -93,6 +93,10 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio if user.Password == "" { 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": if user.Secret == "" { sl.ReportError(user.Secret, "secret", "Secret", "required", "") diff --git a/service/manager_api/grpc/client/converter.go b/service/manager_api/grpc/client/converter.go index 2e7aa931..dc14ee8d 100644 --- a/service/manager_api/grpc/client/converter.go +++ b/service/manager_api/grpc/client/converter.go @@ -59,18 +59,19 @@ func convertNode(v *pb.Node) CM.Node { func convertUser(v *pb.User) CM.User { return CM.User{ - ID: int(v.GetId()), - SquadIDs: toIntSlice(v.GetSquadIds()), - Username: v.GetUsername(), - Inbound: v.GetInbound(), - Type: v.GetType(), - UUID: v.GetUuid(), - Password: v.GetPassword(), - Secret: v.GetSecret(), - Flow: v.GetFlow(), - AlterID: int(v.GetAlterId()), - CreatedAt: timeFromNano(v.GetCreatedAt()), - UpdatedAt: timeFromNano(v.GetUpdatedAt()), + ID: int(v.GetId()), + SquadIDs: toIntSlice(v.GetSquadIds()), + Username: v.GetUsername(), + Inbound: v.GetInbound(), + Type: v.GetType(), + UUID: v.GetUuid(), + Password: v.GetPassword(), + Secret: v.GetSecret(), + AuthorizedKeys: v.GetAuthorizedKeys(), + Flow: v.GetFlow(), + AlterID: int(v.GetAlterId()), + CreatedAt: timeFromNano(v.GetCreatedAt()), + UpdatedAt: timeFromNano(v.GetUpdatedAt()), } } @@ -83,7 +84,7 @@ func convertBandwidthLimiter(v *pb.BandwidthLimiter) CM.BandwidthLimiter { Strategy: v.GetStrategy(), ConnectionType: v.GetConnectionType(), Mode: v.GetMode(), - FlowKeys: v.GetFlowKeys(), + FlowKeys: v.GetFlowKeys(), Speed: v.GetSpeed(), RawSpeed: v.GetRawSpeed(), CreatedAt: timeFromNano(v.GetCreatedAt()), diff --git a/service/manager_api/grpc/client/manager.go b/service/manager_api/grpc/client/manager.go index 616de76d..80f6dbd1 100644 --- a/service/manager_api/grpc/client/manager.go +++ b/service/manager_api/grpc/client/manager.go @@ -188,15 +188,16 @@ func (s *Client) CreateUser(in CM.UserCreate) (CM.User, error) { return CM.User{}, err } reply, err := c.CreateUser(s.callContext(), &pb.UserCreate{ - SquadIds: toInt32Slice(in.SquadIDs), - Username: in.Username, - Inbound: in.Inbound, - Type: in.Type, - Uuid: in.UUID, - Password: in.Password, - Secret: in.Secret, - Flow: in.Flow, - AlterId: int32(in.AlterID), + SquadIds: toInt32Slice(in.SquadIDs), + Username: in.Username, + Inbound: in.Inbound, + Type: in.Type, + Uuid: in.UUID, + Password: in.Password, + Secret: in.Secret, + AuthorizedKeys: in.AuthorizedKeys, + Flow: in.Flow, + AlterId: int32(in.AlterID), }) if err != nil { 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{ Id: int32(id), Update: &pb.UserUpdate{ - Uuid: in.UUID, - Password: in.Password, - Secret: in.Secret, - Flow: in.Flow, - AlterId: int32(in.AlterID), + Uuid: in.UUID, + Password: in.Password, + Secret: in.Secret, + AuthorizedKeys: in.AuthorizedKeys, + Flow: in.Flow, + AlterId: int32(in.AlterID), }, }) if err != nil { @@ -289,7 +291,7 @@ func (s *Client) CreateBandwidthLimiter(in CM.BandwidthLimiterCreate) (CM.Bandwi Strategy: in.Strategy, ConnectionType: in.ConnectionType, Mode: in.Mode, - FlowKeys: in.FlowKeys, + FlowKeys: in.FlowKeys, Speed: in.Speed, }) if err != nil { @@ -351,7 +353,7 @@ func (s *Client) UpdateBandwidthLimiter(id int, in CM.BandwidthLimiterUpdate) (C Strategy: in.Strategy, ConnectionType: in.ConnectionType, Mode: in.Mode, - FlowKeys: in.FlowKeys, + FlowKeys: in.FlowKeys, Speed: in.Speed, }, }) diff --git a/service/manager_api/grpc/manager/manager.pb.go b/service/manager_api/grpc/manager/manager.pb.go index 79183399..b5f11dba 100644 --- a/service/manager_api/grpc/manager/manager.pb.go +++ b/service/manager_api/grpc/manager/manager.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.31.1 +// protoc v7.34.1 // source: service/manager_api/grpc/manager/manager.proto package manager @@ -550,21 +550,22 @@ func (x *NodeUpdateRequest) GetUpdate() *NodeUpdate { } type User struct { - state protoimpl.MessageState `protogen:"open.v1"` - 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"` - Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,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"` - Uuid string `protobuf:"bytes,6,opt,name=uuid,proto3" json:"uuid,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"` - Flow string `protobuf:"bytes,9,opt,name=flow,proto3" json:"flow,omitempty"` - AlterId int32 `protobuf:"varint,10,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"` - CreatedAt int64 `protobuf:"varint,11,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - UpdatedAt int64 `protobuf:"varint,12,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + 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"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,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"` + Uuid string `protobuf:"bytes,6,opt,name=uuid,proto3" json:"uuid,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"` + AuthorizedKeys []string `protobuf:"bytes,13,rep,name=authorized_keys,json=authorizedKeys,proto3" json:"authorized_keys,omitempty"` + Flow string `protobuf:"bytes,9,opt,name=flow,proto3" json:"flow,omitempty"` + AlterId int32 `protobuf:"varint,10,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"` + CreatedAt int64 `protobuf:"varint,11,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + UpdatedAt int64 `protobuf:"varint,12,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *User) Reset() { @@ -653,6 +654,13 @@ func (x *User) GetSecret() string { return "" } +func (x *User) GetAuthorizedKeys() []string { + if x != nil { + return x.AuthorizedKeys + } + return nil +} + func (x *User) GetFlow() string { if x != nil { return x.Flow @@ -682,18 +690,19 @@ func (x *User) GetUpdatedAt() int64 { } type UserCreate struct { - state protoimpl.MessageState `protogen:"open.v1"` - 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"` - Inbound string `protobuf:"bytes,3,opt,name=inbound,proto3" json:"inbound,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"` - Password string `protobuf:"bytes,6,opt,name=password,proto3" json:"password,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"` - AlterId int32 `protobuf:"varint,9,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + 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"` + Inbound string `protobuf:"bytes,3,opt,name=inbound,proto3" json:"inbound,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"` + Password string `protobuf:"bytes,6,opt,name=password,proto3" json:"password,omitempty"` + Secret string `protobuf:"bytes,7,opt,name=secret,proto3" json:"secret,omitempty"` + AuthorizedKeys []string `protobuf:"bytes,10,rep,name=authorized_keys,json=authorizedKeys,proto3" json:"authorized_keys,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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UserCreate) Reset() { @@ -775,6 +784,13 @@ func (x *UserCreate) GetSecret() string { return "" } +func (x *UserCreate) GetAuthorizedKeys() []string { + if x != nil { + return x.AuthorizedKeys + } + return nil +} + func (x *UserCreate) GetFlow() string { if x != nil { return x.Flow @@ -790,14 +806,15 @@ func (x *UserCreate) GetAlterId() int32 { } type UserUpdate struct { - state protoimpl.MessageState `protogen:"open.v1"` - Uuid string `protobuf:"bytes,1,opt,name=uuid,proto3" json:"uuid,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"` - Flow string `protobuf:"bytes,4,opt,name=flow,proto3" json:"flow,omitempty"` - AlterId int32 `protobuf:"varint,5,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Uuid string `protobuf:"bytes,1,opt,name=uuid,proto3" json:"uuid,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"` + AuthorizedKeys []string `protobuf:"bytes,6,rep,name=authorized_keys,json=authorizedKeys,proto3" json:"authorized_keys,omitempty"` + Flow string `protobuf:"bytes,4,opt,name=flow,proto3" json:"flow,omitempty"` + AlterId int32 `protobuf:"varint,5,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UserUpdate) Reset() { @@ -851,6 +868,13 @@ func (x *UserUpdate) GetSecret() string { return "" } +func (x *UserUpdate) GetAuthorizedKeys() []string { + if x != nil { + return x.AuthorizedKeys + } + return nil +} + func (x *UserUpdate) GetFlow() string { if x != nil { 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" + "\x11NodeUpdateRequest\x12\x12\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" + "\x02id\x18\x01 \x01(\x05R\x02id\x12\x1b\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" + "\x04uuid\x18\x06 \x01(\tR\x04uuid\x12\x1a\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" + "\balter_id\x18\n" + " \x01(\x05R\aalterId\x12\x1d\n" + "\n" + "created_at\x18\v \x01(\x03R\tcreatedAt\x12\x1d\n" + "\n" + - "updated_at\x18\f \x01(\x03R\tupdatedAt\"\xea\x01\n" + + "updated_at\x18\f \x01(\x03R\tupdatedAt\"\x93\x02\n" + "\n" + "UserCreate\x12\x1b\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" + "\x04uuid\x18\x05 \x01(\tR\x04uuid\x12\x1a\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" + - "\balter_id\x18\t \x01(\x05R\aalterId\"\x83\x01\n" + + "\balter_id\x18\t \x01(\x05R\aalterId\"\xac\x01\n" + "\n" + "UserUpdate\x12\x12\n" + "\x04uuid\x18\x01 \x01(\tR\x04uuid\x12\x1a\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" + "\balter_id\x18\x05 \x01(\x05R\aalterId\"8\n" + "\bUserList\x12,\n" + diff --git a/service/manager_api/grpc/manager/manager.proto b/service/manager_api/grpc/manager/manager.proto index f3a9758c..9ce61d4f 100644 --- a/service/manager_api/grpc/manager/manager.proto +++ b/service/manager_api/grpc/manager/manager.proto @@ -116,6 +116,7 @@ message User { string uuid = 6; string password = 7; string secret = 8; + repeated string authorized_keys = 13; string flow = 9; int32 alter_id = 10; int64 created_at = 11; @@ -130,6 +131,7 @@ message UserCreate { string uuid = 5; string password = 6; string secret = 7; + repeated string authorized_keys = 10; string flow = 8; int32 alter_id = 9; } @@ -138,6 +140,7 @@ message UserUpdate { string uuid = 1; string password = 2; string secret = 3; + repeated string authorized_keys = 6; string flow = 4; int32 alter_id = 5; } diff --git a/service/manager_api/grpc/manager/manager_grpc.pb.go b/service/manager_api/grpc/manager/manager_grpc.pb.go index 046f5966..72c7247f 100644 --- a/service/manager_api/grpc/manager/manager_grpc.pb.go +++ b/service/manager_api/grpc/manager/manager_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 -// - protoc v6.31.1 +// - protoc-gen-go-grpc v1.6.2 +// - protoc v7.34.1 // source: service/manager_api/grpc/manager/manager.proto package manager diff --git a/service/manager_api/grpc/server/converter.go b/service/manager_api/grpc/server/converter.go index ad182182..58f8a170 100644 --- a/service/manager_api/grpc/server/converter.go +++ b/service/manager_api/grpc/server/converter.go @@ -42,18 +42,19 @@ func convertNode(v CM.Node) *pb.Node { func convertUser(v CM.User) *pb.User { return &pb.User{ - Id: int32(v.ID), - SquadIds: toInt32Slice(v.SquadIDs), - Username: v.Username, - Inbound: v.Inbound, - Type: v.Type, - Uuid: v.UUID, - Password: v.Password, - Secret: v.Secret, - Flow: v.Flow, - AlterId: int32(v.AlterID), - CreatedAt: v.CreatedAt.UnixNano(), - UpdatedAt: v.UpdatedAt.UnixNano(), + Id: int32(v.ID), + SquadIds: toInt32Slice(v.SquadIDs), + Username: v.Username, + Inbound: v.Inbound, + Type: v.Type, + Uuid: v.UUID, + Password: v.Password, + Secret: v.Secret, + AuthorizedKeys: v.AuthorizedKeys, + Flow: v.Flow, + AlterId: int32(v.AlterID), + CreatedAt: v.CreatedAt.UnixNano(), + UpdatedAt: v.UpdatedAt.UnixNano(), } } @@ -66,7 +67,7 @@ func convertBandwidthLimiter(v CM.BandwidthLimiter) *pb.BandwidthLimiter { Strategy: v.Strategy, ConnectionType: v.ConnectionType, Mode: v.Mode, - FlowKeys: v.FlowKeys, + FlowKeys: v.FlowKeys, Speed: v.Speed, RawSpeed: v.RawSpeed, CreatedAt: v.CreatedAt.UnixNano(), diff --git a/service/manager_api/grpc/server/rpc.go b/service/manager_api/grpc/server/rpc.go index a36ea3ff..332b312a 100644 --- a/service/manager_api/grpc/server/rpc.go +++ b/service/manager_api/grpc/server/rpc.go @@ -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) { v, err := s.manager.CreateUser(CM.UserCreate{ - SquadIDs: toIntSlice(req.GetSquadIds()), - Username: req.GetUsername(), - Inbound: req.GetInbound(), - Type: req.GetType(), - UUID: req.GetUuid(), - Password: req.GetPassword(), - Secret: req.GetSecret(), - Flow: req.GetFlow(), - AlterID: int(req.GetAlterId()), + SquadIDs: toIntSlice(req.GetSquadIds()), + Username: req.GetUsername(), + Inbound: req.GetInbound(), + Type: req.GetType(), + UUID: req.GetUuid(), + Password: req.GetPassword(), + Secret: req.GetSecret(), + AuthorizedKeys: req.GetAuthorizedKeys(), + Flow: req.GetFlow(), + AlterID: int(req.GetAlterId()), }) if err != nil { 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) { u := req.GetUpdate() v, err := s.manager.UpdateUser(int(req.GetId()), CM.UserUpdate{ - UUID: u.GetUuid(), - Password: u.GetPassword(), - Secret: u.GetSecret(), - Flow: u.GetFlow(), - AlterID: int(u.GetAlterId()), + UUID: u.GetUuid(), + Password: u.GetPassword(), + Secret: u.GetSecret(), + AuthorizedKeys: u.GetAuthorizedKeys(), + Flow: u.GetFlow(), + AlterID: int(u.GetAlterId()), }) if err != nil { return nil, err @@ -200,7 +202,7 @@ func (s *Server) CreateBandwidthLimiter(_ context.Context, req *pb.BandwidthLimi Strategy: req.GetStrategy(), ConnectionType: req.GetConnectionType(), Mode: req.GetMode(), - FlowKeys: req.GetFlowKeys(), + FlowKeys: req.GetFlowKeys(), Speed: req.GetSpeed(), }) if err != nil { @@ -245,7 +247,7 @@ func (s *Server) UpdateBandwidthLimiter(_ context.Context, req *pb.BandwidthLimi Strategy: u.GetStrategy(), ConnectionType: u.GetConnectionType(), Mode: u.GetMode(), - FlowKeys: u.GetFlowKeys(), + FlowKeys: u.GetFlowKeys(), Speed: u.GetSpeed(), }) if err != nil { diff --git a/service/manager_api/http/server/openapi.yaml b/service/manager_api/http/server/openapi.yaml index 2f308adb..3d15240e 100644 --- a/service/manager_api/http/server/openapi.yaml +++ b/service/manager_api/http/server/openapi.yaml @@ -194,7 +194,7 @@ paths: - {in: query, name: id, schema: {type: integer, format: int32}} - {in: query, name: username, 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/FilterCreatedAtStart"} - {$ref: "#/components/parameters/FilterCreatedAtEnd"} @@ -210,6 +210,15 @@ paths: post: tags: [Users] 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"}}}} responses: "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: username, 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/FilterCreatedAtStart"} - {$ref: "#/components/parameters/FilterCreatedAtEnd"} @@ -247,6 +256,15 @@ paths: put: tags: [Users] 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"}}}} responses: "200": {content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}} @@ -266,7 +284,7 @@ paths: summary: List bandwidth limiters parameters: - {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: type, schema: {type: string}} - {in: query, name: username, schema: {type: string}} @@ -299,7 +317,7 @@ paths: summary: Count bandwidth limiters parameters: - {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: type, schema: {type: string}} - {in: query, name: username, schema: {type: string}} @@ -430,7 +448,7 @@ paths: summary: List connection limiters parameters: - {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: outbound, schema: {type: string}} - {in: query, name: connection_type, schema: {type: string, enum: [default, hwid, mux, ip]}} @@ -460,7 +478,7 @@ paths: summary: Count connection limiters parameters: - {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: outbound, schema: {type: string}} - {in: query, name: connection_type, schema: {type: string, enum: [default, hwid, mux, ip]}} @@ -507,7 +525,7 @@ paths: summary: List rate limiters parameters: - {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: outbound, schema: {type: string}} - {in: query, name: connection_type, schema: {type: string, enum: [hwid, mux, ip, default]}} @@ -539,7 +557,7 @@ paths: summary: Count rate limiters parameters: - {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: outbound, schema: {type: string}} - {in: query, name: connection_type, schema: {type: string, enum: [hwid, mux, ip, default]}} @@ -686,16 +704,17 @@ components: User: 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: id: {type: integer, format: int32} squad_ids: {$ref: "#/components/schemas/SquadIDs"} username: {type: string, example: "alice"} 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} password: {type: string} secret: {type: string} + authorized_keys: {type: array, items: {type: string}} flow: {type: string} alter_id: {type: integer, format: int32} created_at: {type: string, format: date-time} @@ -703,24 +722,44 @@ components: UserCreate: type: object 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: squad_ids: {$ref: "#/components/schemas/SquadIDs"} username: {type: string, example: "alice"} inbound: {type: string, example: "vless-in"} type: 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} password: {type: string} secret: {type: string} + authorized_keys: {type: array, items: {type: string}} flow: {type: string} alter_id: {type: integer, format: int32} UserUpdate: 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: uuid: {type: string, format: uuid} password: {type: string} secret: {type: string} + authorized_keys: {type: array, items: {type: string}} flow: {type: string} alter_id: {type: integer, format: int32} @@ -732,41 +771,43 @@ components: squad_ids: {$ref: "#/components/schemas/SquadIDs"} username: {type: string} outbound: {type: string, example: "direct"} - strategy: {type: string, enum: [global, connection]} - connection_type: {type: string, enum: [default, hwid, mux, ip]} + strategy: {type: string, enum: [global, connection, bypass]} + connection_type: {type: string} 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"} raw_speed: {type: integer, format: int64} created_at: {type: string, format: date-time} updated_at: {type: string, format: date-time} BandwidthLimiterCreate: 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: squad_ids: {$ref: "#/components/schemas/SquadIDs"} username: {type: string} outbound: {type: string, example: "direct"} - strategy: {type: string, enum: [global, connection]} - connection_type: {type: string, enum: [default, hwid, mux, ip]} + strategy: {type: string, enum: [global, connection, bypass]} + connection_type: {type: string} 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"} BandwidthLimiterUpdate: type: object - required: [outbound, strategy, mode, speed] + required: [outbound, strategy] + description: "mode, speed, flow_keys, connection_type are required/relevant unless strategy=bypass" properties: username: {type: string} outbound: {type: string} - strategy: {type: string, enum: [global, connection]} - connection_type: {type: string, enum: [default, hwid, mux, ip]} + strategy: {type: string, enum: [global, connection, bypass]} + connection_type: {type: string} 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} TrafficLimiter: 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: id: {type: integer, format: int32} squad_ids: {$ref: "#/components/schemas/SquadIDs"} @@ -777,11 +818,13 @@ components: raw_used: {type: integer, format: int64} quota: {type: string, example: "10gb"} raw_quota: {type: integer, format: int64} + usage: {type: integer, format: int32, description: "Usage percentage 0-100"} created_at: {type: string, format: date-time} updated_at: {type: string, format: date-time} TrafficLimiterCreate: type: object - required: [squad_ids, outbound, strategy, mode, quota] + required: [squad_ids, outbound, strategy] + description: "mode, quota are required unless strategy=bypass" properties: squad_ids: {$ref: "#/components/schemas/SquadIDs"} username: {type: string} @@ -791,7 +834,8 @@ components: quota: {type: string, example: "10gb"} TrafficLimiterUpdate: type: object - required: [outbound, strategy, mode, quota] + required: [outbound, strategy] + description: "mode, quota are required unless strategy=bypass" properties: username: {type: string} outbound: {type: string} @@ -807,7 +851,7 @@ components: squad_ids: {$ref: "#/components/schemas/SquadIDs"} username: {type: string} 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]} lock_type: {type: string, enum: [manager, default]} count: {type: integer, format: int64} @@ -815,22 +859,24 @@ components: updated_at: {type: string, format: date-time} ConnectionLimiterCreate: 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: squad_ids: {$ref: "#/components/schemas/SquadIDs"} username: {type: string} 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]} lock_type: {type: string, enum: [manager, default]} count: {type: integer, format: int64} ConnectionLimiterUpdate: type: object - required: [outbound, strategy, lock_type, count] + required: [outbound, strategy] + description: "lock_type, connection_type, count are required unless strategy=bypass" properties: username: {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]} lock_type: {type: string, enum: [manager, default]} count: {type: integer, format: int64} @@ -843,7 +889,7 @@ components: squad_ids: {$ref: "#/components/schemas/SquadIDs"} username: {type: string} 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]} count: {type: integer, format: int64} interval: {type: string, example: "1s"} @@ -851,22 +897,24 @@ components: updated_at: {type: string, format: date-time} RateLimiterCreate: 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: squad_ids: {$ref: "#/components/schemas/SquadIDs"} username: {type: string} 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]} count: {type: integer, format: int64} interval: {type: string, example: "1s"} RateLimiterUpdate: type: object - required: [outbound, strategy, connection_type, count, interval] + required: [outbound, strategy] + description: "connection_type, count, interval are required unless strategy=bypass" properties: username: {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]} count: {type: integer, format: int64} interval: {type: string} diff --git a/service/node/inbound/ssh.go b/service/node/inbound/ssh.go new file mode 100644 index 00000000..2aa2bf10 --- /dev/null +++ b/service/node/inbound/ssh.go @@ -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() +} diff --git a/service/node/service.go b/service/node/service.go index be7f99c6..b7d40d8e 100644 --- a/service/node/service.go +++ b/service/node/service.go @@ -33,7 +33,7 @@ type Service struct { rateManager constant.RateLimiterManager options option.NodeServiceOptions - nodeManager CM.NodeManager + nodeManager CM.NodeManager mtx sync.Mutex } @@ -79,6 +79,7 @@ func (s *Service) Start(stage adapter.StartStage) error { "tuic": inbound.NewTUICManager(), "vless": inbound.NewVLESSManager(), "vmess": inbound.NewVMessManager(), + "ssh": inbound.NewSSHManager(), } s.connectionManager = limiter.NewConnectionLimiterManager(s.ctx, nodeManager, s.logger) s.bandwidthManager = limiter.NewBandwidthLimiterManager(s.ctx, nodeManager, s.logger) diff --git a/service/node_manager_api/client/client.go b/service/node_manager_api/client/client.go index 6b1d737b..4c729a49 100644 --- a/service/node_manager_api/client/client.go +++ b/service/node_manager_api/client/client.go @@ -281,15 +281,16 @@ func (s *APIClient) handler(node CM.ConnectedNode, stream grpc.ServerStreamingCl func (s *APIClient) convertUser(user *pb.User) CM.User { return CM.User{ - ID: int(user.Id), - Username: user.Username, - Inbound: user.Inbound, - Type: user.Type, - UUID: user.Uuid, - Password: user.Password, - Secret: user.Secret, - Flow: user.Flow, - AlterID: int(user.AlterId), + ID: int(user.Id), + Username: user.Username, + Inbound: user.Inbound, + Type: user.Type, + UUID: user.Uuid, + Password: user.Password, + Secret: user.Secret, + AuthorizedKeys: user.AuthorizedKeys, + Flow: user.Flow, + AlterID: int(user.AlterId), } } @@ -301,7 +302,7 @@ func (s *APIClient) convertBandwidthLimiter(limiter *pb.BandwidthLimiter) CM.Ban Strategy: limiter.Strategy, ConnectionType: limiter.ConnectionType, Mode: limiter.Mode, - FlowKeys: limiter.FlowKeys, + FlowKeys: limiter.FlowKeys, Speed: limiter.Speed, RawSpeed: limiter.RawSpeed, } diff --git a/service/node_manager_api/manager/manager.pb.go b/service/node_manager_api/manager/manager.pb.go index d16213ee..5a6eed99 100644 --- a/service/node_manager_api/manager/manager.pb.go +++ b/service/node_manager_api/manager/manager.pb.go @@ -151,18 +151,19 @@ func (x *Node) GetUuid() string { } type User struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,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"` - Type string `protobuf:"bytes,4,opt,name=type,proto3" json:"type,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"` - Secret string `protobuf:"bytes,7,opt,name=secret,proto3" json:"secret,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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,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"` + Type string `protobuf:"bytes,4,opt,name=type,proto3" json:"type,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"` + Secret string `protobuf:"bytes,7,opt,name=secret,proto3" json:"secret,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"` + AuthorizedKeys []string `protobuf:"bytes,10,rep,name=authorized_keys,json=authorizedKeys,proto3" json:"authorized_keys,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *User) Reset() { @@ -258,6 +259,13 @@ func (x *User) GetAlterId() int32 { return 0 } +func (x *User) GetAuthorizedKeys() []string { + if x != nil { + return x.AuthorizedKeys + } + return nil +} + type UserList struct { state protoimpl.MessageState `protogen:"open.v1"` 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" + ".service/node_manager_api/manager/manager.proto\x12\x13node_manager_api.v1\"\x1a\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" + "\x02id\x18\x01 \x01(\x05R\x02id\x12\x1a\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" + "\x06secret\x18\a \x01(\tR\x06secret\x12\x12\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" + "\x06values\x18\x01 \x03(\v2\x19.node_manager_api.v1.UserR\x06values\"\x83\x02\n" + "\x10BandwidthLimiter\x12\x0e\n" + diff --git a/service/node_manager_api/manager/manager.proto b/service/node_manager_api/manager/manager.proto index b065cba2..f554568e 100644 --- a/service/node_manager_api/manager/manager.proto +++ b/service/node_manager_api/manager/manager.proto @@ -48,6 +48,7 @@ message User { string secret = 7; string flow = 8; int32 alter_id = 9; + repeated string authorized_keys = 10; } message UserList { diff --git a/service/node_manager_api/server/node.go b/service/node_manager_api/server/node.go index 9d597398..cfdd3ae8 100644 --- a/service/node_manager_api/server/node.go +++ b/service/node_manager_api/server/node.go @@ -239,15 +239,16 @@ func (s *RemoteNode) close(err error) { func (s *RemoteNode) convertUser(user CS.User) *pb.User { return &pb.User{ - Id: int32(user.ID), - Username: user.Username, - Inbound: user.Inbound, - Type: user.Type, - Uuid: user.UUID, - Password: user.Password, - Secret: user.Secret, - Flow: user.Flow, - AlterId: int32(user.AlterID), + Id: int32(user.ID), + Username: user.Username, + Inbound: user.Inbound, + Type: user.Type, + Uuid: user.UUID, + Password: user.Password, + Secret: user.Secret, + AuthorizedKeys: user.AuthorizedKeys, + Flow: user.Flow, + AlterId: int32(user.AlterID), } } diff --git a/service/oomkiller/service.go b/service/oomkiller/service.go index ff90f6e4..08739593 100644 --- a/service/oomkiller/service.go +++ b/service/oomkiller/service.go @@ -98,12 +98,12 @@ func (s *Service) Start(stage adapter.StartStage) error { s.adaptiveTimer = newAdaptiveTimer(s.logger, s.router, s.timerConfig) s.adaptiveTimer.start(false) 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 { - s.logger.Info("started memory monitor with available memory detection") + s.logger.Notice("started memory monitor with available memory detection") } } else { - s.logger.Info("started memory pressure monitor") + s.logger.Notice("started memory pressure monitor") } globalAccess.Lock() diff --git a/service/oomkiller/service_stub.go b/service/oomkiller/service_stub.go index 7c1b84e8..7ee3a7ec 100644 --- a/service/oomkiller/service_stub.go +++ b/service/oomkiller/service_stub.go @@ -66,9 +66,9 @@ func (s *Service) Start(stage adapter.StartStage) error { s.adaptiveTimer = newAdaptiveTimer(s.logger, s.router, s.timerConfig) s.adaptiveTimer.start(false) if s.useAvailable { - s.logger.Info("started memory monitor with available memory detection") + s.logger.Notice("started memory monitor with available memory detection") } 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 } diff --git a/test/box_test.go b/test/box_test.go index d7d9b9b0..40136846 100644 --- a/test/box_test.go +++ b/test/box_test.go @@ -26,7 +26,10 @@ import ( ) 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 diff --git a/test/go.mod b/test/go.mod index 4fd222b9..5f6e1d15 100644 --- a/test/go.mod +++ b/test/go.mod @@ -36,6 +36,7 @@ require ( github.com/spyzhov/ajson v0.9.4 github.com/stretchr/testify v1.11.1 go.uber.org/goleak v1.3.0 + golang.org/x/crypto v0.49.0 golang.org/x/net v0.52.0 ) @@ -220,7 +221,6 @@ require ( go.uber.org/zap/exp v0.3.0 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // 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/mod v0.34.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect diff --git a/transport/masque/device.go b/transport/masque/device.go index d6f71597..45b14740 100644 --- a/transport/masque/device.go +++ b/transport/masque/device.go @@ -18,16 +18,23 @@ type Device interface { } type DeviceOptions struct { - Context context.Context - Logger logger.ContextLogger - Handler tun.Handler - UDPTimeout time.Duration - CreateDialer func(interfaceName string) N.Dialer - Name string - MTU uint32 - Address []netip.Prefix + Context context.Context + Logger logger.ContextLogger + System bool + UDPTimeout time.Duration + CreateDialer func(interfaceName string) N.Dialer + Name string + MTU uint32 + Address []netip.Prefix + AllowedAddress []netip.Prefix } 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) + } } diff --git a/transport/masque/device_stack.go b/transport/masque/device_stack.go index 0d926dca..d597edb4 100644 --- a/transport/masque/device_stack.go +++ b/transport/masque/device_stack.go @@ -7,6 +7,7 @@ import ( "net" "net/netip" "os" + "sync" "github.com/sagernet/gvisor/pkg/buffer" "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/ipv6" "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/transport/wireguard" "github.com/sagernet/sing-tun" @@ -29,15 +27,15 @@ import ( ) type stackDevice struct { - ctx context.Context - logger log.ContextLogger - stack *stack.Stack - mtu uint32 - events chan wgTun.Event - wgTun.Device + ctx context.Context + logger log.ContextLogger + stack *stack.Stack + mtu uint32 + events chan wgTun.Event outbound chan *stack.PacketBuffer packetOutbound chan *buf.Buffer done chan struct{} + closeOnce sync.Once dispatcher stack.NetworkDispatcher inet4Address netip.Addr inet6Address netip.Addr @@ -84,13 +82,6 @@ func newStackDevice(options DeviceOptions) (*stackDevice, error) { } } 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 } @@ -141,11 +132,17 @@ func (w *stackDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) } var networkProtocol tcpip.NetworkProtocolNumber if destination.IsIPv4() { + if !w.inet4Address.IsValid() { + return nil, E.New("missing IPv4 local address") + } networkProtocol = header.IPv4ProtocolNumber bind.Addr = tun.AddressFromAddr(w.inet4Address) } else { + if !w.inet6Address.IsValid() { + return nil, E.New("missing IPv6 local address") + } networkProtocol = header.IPv6ProtocolNumber - bind.Addr = tun.AddressFromAddr(w.inet4Address) + bind.Addr = tun.AddressFromAddr(w.inet6Address) } udpConn, err := gonet.DialUDP(w.stack, &bind, nil, networkProtocol) if err != nil { @@ -228,13 +225,15 @@ func (w *stackDevice) Events() <-chan wgTun.Event { } func (w *stackDevice) Close() error { - close(w.done) - close(w.events) - w.stack.Close() - for _, endpoint := range w.stack.CleanupEndpoints() { - endpoint.Abort() - } - w.stack.Wait() + w.closeOnce.Do(func() { + close(w.done) + close(w.events) + w.stack.Close() + for _, endpoint := range w.stack.CleanupEndpoints() { + endpoint.Abort() + } + w.stack.Wait() + }) return nil } diff --git a/transport/masque/device_system.go b/transport/masque/device_system.go new file mode 100644 index 00000000..b50b0c39 --- /dev/null +++ b/transport/masque/device_system.go @@ -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 +} diff --git a/transport/masque/device_system_stack.go b/transport/masque/device_system_stack.go new file mode 100644 index 00000000..bf888bd5 --- /dev/null +++ b/transport/masque/device_system_stack.go @@ -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()) { +} diff --git a/transport/masque/options.go b/transport/masque/options.go index b2722436..3dfe1647 100644 --- a/transport/masque/options.go +++ b/transport/masque/options.go @@ -5,15 +5,17 @@ import ( "net/netip" "time" - tun "github.com/sagernet/sing-tun" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/tls" ) type TunnelOptions struct { - Handler tun.Handler + System bool + Name string + CreateDialer func(interfaceName string) N.Dialer Dialer N.Dialer Address []netip.Prefix + AllowedAddress []netip.Prefix Endpoint net.Addr TLSConfig tls.Config UseHTTP2 bool diff --git a/transport/masque/tunnel.go b/transport/masque/tunnel.go index 0c2d9c6d..e58a5251 100644 --- a/transport/masque/tunnel.go +++ b/transport/masque/tunnel.go @@ -31,12 +31,15 @@ type Tunnel struct { func NewTunnel(ctx context.Context, logger logger.ContextLogger, options TunnelOptions) (*Tunnel, error) { deviceOptions := DeviceOptions{ - Context: ctx, - Logger: logger, - Handler: options.Handler, - UDPTimeout: options.UDPTimeout, - MTU: 1280, - Address: options.Address, + Context: ctx, + Logger: logger, + System: options.System, + UDPTimeout: options.UDPTimeout, + CreateDialer: options.CreateDialer, + Name: options.Name, + MTU: 1280, + Address: options.Address, + AllowedAddress: options.AllowedAddress, } tunDevice, err := NewDevice(deviceOptions) if err != nil { @@ -102,11 +105,24 @@ func (e *Tunnel) maintainTunnel() { e.logger.ErrorContext(e.ctx, fmt.Errorf("failed to read from TUN device: %v", err)) continue } + packet := bufs[0][:sizes[0]] + if len(packet) > 0 { + switch packet[0] >> 4 { + case 4: + if len(packet) >= 9 && packet[8] <= 1 { + continue + } + case 6: + if len(packet) >= 8 && packet[7] <= 1 { + continue + } + } + } ipConn, err := e.getIpConn() if err != nil { return } - icmp, err := ipConn.WritePacket(bufs[0][:sizes[0]]) + icmp, err := ipConn.WritePacket(packet) if err != nil { if errors.As(err, new(*connectip.CloseError)) { if ok := e.closeIpConn(ipConn); ok { @@ -163,11 +179,11 @@ func (e *Tunnel) getIpConn() (*connectip.Conn, error) { if e.ipConn != nil { return e.ipConn, nil } - e.logger.InfoContext(e.ctx, "Establishing MASQUE connection to ", e.options.Endpoint) + e.logger.NoticeContext(e.ctx, "Establishing MASQUE connection to ", e.options.Endpoint) timer := time.NewTimer(0) defer timer.Stop() for { - e.logger.InfoContext(e.ctx, fmt.Errorf("Establishing MASQUE connection to %s", e.options.Endpoint)) + e.logger.NoticeContext(e.ctx, fmt.Errorf("Establishing MASQUE connection to %s", e.options.Endpoint)) udpConn, tr, ipConn, rsp, err := ConnectTunnel( e.ctx, e.options.Dialer, @@ -207,7 +223,7 @@ func (e *Tunnel) getIpConn() (*connectip.Conn, error) { e.udpConn = udpConn e.tr = tr e.ipConn = ipConn - e.logger.InfoContext(e.ctx, "Connected to MASQUE server", e.options.Endpoint) + e.logger.NoticeContext(e.ctx, "Connected to MASQUE server ", e.options.Endpoint) return ipConn, nil } } diff --git a/transport/openvpn/control.go b/transport/openvpn/control.go index bdac9a76..b777424a 100644 --- a/transport/openvpn/control.go +++ b/transport/openvpn/control.go @@ -38,8 +38,8 @@ type ControlChannel struct { func NewControlChannel(io PacketIO, crypt ControlCrypt, local SessionID) *ControlChannel { ch := &ControlChannel{ - io: io, - + io: io, + clock: time.Now, local: local, pending: make(map[uint32]*ControlPacket), diff --git a/transport/openvpn/data.go b/transport/openvpn/data.go index 521296e3..a67d15b3 100644 --- a/transport/openvpn/data.go +++ b/transport/openvpn/data.go @@ -11,10 +11,10 @@ const ( ) type DataChannel struct { - cipher DataCipher - keyID uint8 - peerID uint32 - compLZO bool + cipher DataCipher + keyID uint8 + peerID uint32 + compLZO bool mu sync.Mutex sendPacketID uint32 } diff --git a/transport/openvpn/device.go b/transport/openvpn/device.go index bc3ac83c..d2f9a21a 100644 --- a/transport/openvpn/device.go +++ b/transport/openvpn/device.go @@ -18,16 +18,23 @@ type Device interface { } type DeviceOptions struct { - Context context.Context - Logger logger.ContextLogger - Handler tun.Handler - UDPTimeout time.Duration - CreateDialer func(interfaceName string) N.Dialer - Name string - MTU uint32 - Address []netip.Prefix + Context context.Context + Logger logger.ContextLogger + System bool + UDPTimeout time.Duration + CreateDialer func(interfaceName string) N.Dialer + Name string + MTU uint32 + Address []netip.Prefix + AllowedAddress []netip.Prefix } 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) + } } diff --git a/transport/openvpn/device_stack.go b/transport/openvpn/device_stack.go index 578e58ec..d5568799 100644 --- a/transport/openvpn/device_stack.go +++ b/transport/openvpn/device_stack.go @@ -7,6 +7,7 @@ import ( "net" "net/netip" "os" + "sync" "github.com/sagernet/gvisor/pkg/buffer" "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/ipv6" "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/transport/wireguard" "github.com/sagernet/sing-tun" @@ -29,15 +27,15 @@ import ( ) type stackDevice struct { - ctx context.Context - logger log.ContextLogger - stack *stack.Stack - mtu uint32 - events chan wgTun.Event - wgTun.Device + ctx context.Context + logger log.ContextLogger + stack *stack.Stack + mtu uint32 + events chan wgTun.Event outbound chan *stack.PacketBuffer packetOutbound chan *buf.Buffer done chan struct{} + closeOnce sync.Once dispatcher stack.NetworkDispatcher inet4Address netip.Addr inet6Address netip.Addr @@ -84,13 +82,6 @@ func newStackDevice(options DeviceOptions) (*stackDevice, error) { } } 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 } @@ -141,9 +132,15 @@ func (w *stackDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) } var networkProtocol tcpip.NetworkProtocolNumber if destination.IsIPv4() { + if !w.inet4Address.IsValid() { + return nil, E.New("missing IPv4 local address") + } networkProtocol = header.IPv4ProtocolNumber bind.Addr = tun.AddressFromAddr(w.inet4Address) } else { + if !w.inet6Address.IsValid() { + return nil, E.New("missing IPv6 local address") + } networkProtocol = header.IPv6ProtocolNumber bind.Addr = tun.AddressFromAddr(w.inet6Address) } @@ -228,13 +225,15 @@ func (w *stackDevice) Events() <-chan wgTun.Event { } func (w *stackDevice) Close() error { - close(w.done) - close(w.events) - w.stack.Close() - for _, endpoint := range w.stack.CleanupEndpoints() { - endpoint.Abort() - } - w.stack.Wait() + w.closeOnce.Do(func() { + close(w.done) + close(w.events) + w.stack.Close() + for _, endpoint := range w.stack.CleanupEndpoints() { + endpoint.Abort() + } + w.stack.Wait() + }) return nil } diff --git a/transport/openvpn/device_system.go b/transport/openvpn/device_system.go new file mode 100644 index 00000000..50ab1945 --- /dev/null +++ b/transport/openvpn/device_system.go @@ -0,0 +1,191 @@ +package openvpn + +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("openvpn") + } + 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 +} diff --git a/transport/openvpn/device_system_stack.go b/transport/openvpn/device_system_stack.go new file mode 100644 index 00000000..bf259413 --- /dev/null +++ b/transport/openvpn/device_system_stack.go @@ -0,0 +1,200 @@ +//go:build with_gvisor + +package openvpn + +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()) { +} diff --git a/transport/openvpn/tunnel.go b/transport/openvpn/tunnel.go index ef1ed509..df9a7b2f 100644 --- a/transport/openvpn/tunnel.go +++ b/transport/openvpn/tunnel.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "net" + "net/netip" "os" "sync" "time" @@ -18,10 +19,14 @@ import ( ) type TunnelOptions struct { + System bool + Name string + CreateDialer func(interfaceName string) N.Dialer Dialer N.Dialer Servers []option.ServerOptions TLSConfig tls.Config Config *ClientConfig + AllowedAddress []netip.Prefix UDPTimeout time.Duration ReconnectDelay time.Duration PingInterval time.Duration @@ -65,11 +70,15 @@ func (t *Tunnel) Start() error { t.mtu = client.push.MTU } deviceOptions := DeviceOptions{ - Context: t.ctx, - Logger: t.logger, - UDPTimeout: t.options.UDPTimeout, - MTU: t.mtu, - Address: client.push.Prefixes, + Context: t.ctx, + Logger: t.logger, + System: t.options.System, + UDPTimeout: t.options.UDPTimeout, + CreateDialer: t.options.CreateDialer, + Name: t.options.Name, + MTU: t.mtu, + Address: client.push.Prefixes, + AllowedAddress: t.options.AllowedAddress, } device, err := NewDevice(deviceOptions) if err != nil { @@ -214,7 +223,7 @@ func (t *Tunnel) getClient() (*Client, error) { timer := time.NewTimer(0) defer timer.Stop() for { - t.logger.InfoContext(t.ctx, "connecting to OpenVPN server") + t.logger.NoticeContext(t.ctx, "connecting to OpenVPN server") client, err := t.connect() if err != nil { t.logger.ErrorContext(t.ctx, fmt.Errorf("connect failed: %v", err)) @@ -227,7 +236,7 @@ func (t *Tunnel) getClient() (*Client, error) { continue } t.client = client - t.logger.InfoContext(t.ctx, "connected to OpenVPN server") + t.logger.NoticeContext(t.ctx, "connected to OpenVPN server") return client, nil } } diff --git a/transport/sudoku/obfs/httpmask/tunnel.go b/transport/sudoku/obfs/httpmask/tunnel.go index 46d7c7a2..a64cf9cc 100644 --- a/transport/sudoku/obfs/httpmask/tunnel.go +++ b/transport/sudoku/obfs/httpmask/tunnel.go @@ -19,7 +19,6 @@ import ( "syscall" "time" - "net/http" "net/http/httputil" diff --git a/transport/sudoku/uot.go b/transport/sudoku/uot.go index ed8b5cb5..ca7ff4cc 100644 --- a/transport/sudoku/uot.go +++ b/transport/sudoku/uot.go @@ -10,7 +10,6 @@ import ( "net/netip" "sync" "time" - ) const ( diff --git a/transport/trusttunnel/protocol.go b/transport/trusttunnel/protocol.go index 694b3145..cea14fcd 100644 --- a/transport/trusttunnel/protocol.go +++ b/transport/trusttunnel/protocol.go @@ -13,9 +13,9 @@ import ( ) const ( - UDPMagicAddress = "_udp2" - ICMPMagicAddress = "_icmp" - HealthCheckMagicAddress = "_check" + UDPMagicAddress = "_udp2" + ICMPMagicAddress = "_icmp" + HealthCheckMagicAddress = "_check" DefaultConnectionTimeout = 30 * time.Second DefaultHealthCheckTimeout = 7 * time.Second DefaultSessionTimeout = 30 * time.Second diff --git a/transport/v2rayxhttp/client.go b/transport/v2rayxhttp/client.go index 37d3fa63..e6024887 100644 --- a/transport/v2rayxhttp/client.go +++ b/transport/v2rayxhttp/client.go @@ -48,6 +48,9 @@ func NewClient(ctx context.Context, logger log.ContextLogger, dialer N.Dialer, s if options.Mode == "" { return nil, E.New("mode is not set") } + if tlsConfig != nil && len(tlsConfig.NextProtos()) == 0 { + tlsConfig.SetNextProtos([]string{"h2"}) + } dest := serverAddr baseRequestURL, err := getBaseRequestURL(&options.V2RayXHTTPBaseOptions, dest, tlsConfig) if err != nil { @@ -84,6 +87,9 @@ func NewClient(ctx context.Context, logger log.ContextLogger, dialer N.Dialer, s return nil, err } } + if tlsConfig2 != nil && len(tlsConfig2.NextProtos()) == 0 { + tlsConfig2.SetNextProtos([]string{"h2"}) + } baseRequestURL2, err = getBaseRequestURL(&options2.V2RayXHTTPBaseOptions, dest2, tlsConfig2) if err != nil { return nil, err diff --git a/transport/v2rayxhttp/server.go b/transport/v2rayxhttp/server.go index 95db64c1..f67d5e89 100644 --- a/transport/v2rayxhttp/server.go +++ b/transport/v2rayxhttp/server.go @@ -197,7 +197,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { Reader: httpSC, }) if err != nil { - s.logger.InfoContext(request.Context(), err, "failed to upload (PushReader)") + s.logger.DebugContext(request.Context(), err, "failed to upload (PushReader)") writer.WriteHeader(http.StatusConflict) } else { writer.Header().Set("X-Accel-Buffering", "no") @@ -242,7 +242,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { headerPayloadEncoded := strings.Join(headerPayloadChunks, "") headerPayload, err = base64.RawURLEncoding.DecodeString(headerPayloadEncoded) if err != nil { - s.logger.InfoContext(request.Context(), err, "Invalid base64 in header's payload") + s.logger.DebugContext(request.Context(), err, "Invalid base64 in header's payload") writer.WriteHeader(http.StatusBadRequest) return } @@ -261,7 +261,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { cookiePayloadEncoded := strings.Join(cookiePayloadChunks, "") cookiePayload, err = base64.RawURLEncoding.DecodeString(cookiePayloadEncoded) if err != nil { - s.logger.InfoContext(request.Context(), err, "Invalid base64 in cookies' payload") + s.logger.DebugContext(request.Context(), err, "Invalid base64 in cookies' payload") writer.WriteHeader(http.StatusBadRequest) return } @@ -281,7 +281,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { bodyPayload, readErr = buf.ReadAllToBytes(io.LimitReader(request.Body, int64(scMaxEachPostBytes)+1)) } if readErr != nil { - s.logger.InfoContext(request.Context(), readErr, "failed to read body payload") + s.logger.DebugContext(request.Context(), readErr, "failed to read body payload") writer.WriteHeader(http.StatusBadRequest) return } @@ -304,7 +304,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { } seq, err := strconv.ParseUint(seqStr, 10, 64) if err != nil { - s.logger.InfoContext(request.Context(), err, "failed to upload (ParseUint)") + s.logger.DebugContext(request.Context(), err, "failed to upload (ParseUint)") writer.WriteHeader(http.StatusInternalServerError) return } @@ -313,7 +313,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { Seq: seq, }) if err != nil { - s.logger.InfoContext(request.Context(), err, "failed to upload (PushPayload)") + s.logger.DebugContext(request.Context(), err, "failed to upload (PushPayload)") writer.WriteHeader(http.StatusInternalServerError) return } diff --git a/transport/wireguard/device_system.go b/transport/wireguard/device_system.go index 1c0b8b6c..13e213bf 100644 --- a/transport/wireguard/device_system.go +++ b/transport/wireguard/device_system.go @@ -114,7 +114,7 @@ func (w *systemDevice) Start() error { tunInterface.Close() return err } - w.options.Logger.Info("started at ", w.options.Name) + w.options.Logger.Notice("started at ", w.options.Name) w.device = tunInterface batchTUN, isBatchTUN := tunInterface.(tun.LinuxTUN) if isBatchTUN && batchTUN.BatchSize() > 1 {