diff --git a/account/oauth.go b/account/oauth.go new file mode 100644 index 00000000..ccbd3c32 --- /dev/null +++ b/account/oauth.go @@ -0,0 +1,73 @@ +//go:build !stealth_novpn + +package account + +import ( + "context" + "fmt" + "log/slog" + "net/url" + + "github.com/getlantern/radiance/account/protos" + "github.com/getlantern/radiance/common" + "github.com/getlantern/radiance/common/settings" +) + +// OAuthLoginURL initiates the OAuth login process for the specified provider. +func (a *Client) OAuthLoginURL(ctx context.Context, provider string) (string, error) { + authURL := a.authURL + if authURL == "" { + authURL = common.GetBaseURL() + } + loginURL, err := url.Parse(authURL + "/users/oauth2/" + provider) + if err != nil { + return "", fmt.Errorf("failed to parse URL: %w", err) + } + query := loginURL.Query() + query.Set("deviceId", settings.GetString(settings.DeviceIDKey)) + query.Set("userId", settings.GetString(settings.UserIDKey)) + query.Set("proToken", settings.GetString(settings.TokenKey)) + query.Set("returnTo", "lantern://auth") + loginURL.RawQuery = query.Encode() + // Persist the provider so it's available after the callback completes. + if err := settings.Set(settings.OAuthProviderKey, provider); err != nil { + return "", fmt.Errorf("failed to persist OAuth provider: %w", err) + } + return loginURL.String(), nil +} + +func (a *Client) OAuthLoginCallback(ctx context.Context, oAuthToken string) (*UserData, error) { + slog.Debug("Getting OAuth login callback") + jwtUserInfo, err := decodeJWT(oAuthToken) + if err != nil { + return nil, fmt.Errorf("error decoding JWT: %w", err) + } + + // Temporary set user data so the api can read it. + login := &UserData{ + LegacyID: jwtUserInfo.LegacyUserID, + LegacyToken: jwtUserInfo.LegacyToken, + LegacyUserData: &protos.LoginResponse_UserData{ + UserId: jwtUserInfo.LegacyUserID, + Token: jwtUserInfo.LegacyToken, + DeviceID: jwtUserInfo.DeviceID, + Email: jwtUserInfo.Email, + }, + } + a.setData(login) + // Get user data from the api. This also saves data in user config. + user, err := a.fetchUserData(ctx) + if err != nil { + return nil, fmt.Errorf("error getting user data: %w", err) + } + + if err := settings.Set(settings.JwtTokenKey, oAuthToken); err != nil { + slog.Error("Failed to persist JWT token", "error", err) + return nil, fmt.Errorf("failed to persist JWT token: %w", err) + } + settings.Set(settings.OAuthLoginKey, true) + user.Id = jwtUserInfo.Email + user.EmailConfirmed = true + a.setData(user) + return user, nil +} diff --git a/account/user.go b/account/user.go index 11db52cd..42f213d9 100644 --- a/account/user.go +++ b/account/user.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "log/slog" - "net/url" "os" "strings" @@ -501,65 +500,6 @@ func (a *Client) DeleteAccount(ctx context.Context, email, password string) (*Us return a.NewUser(ctx) } -// OAuthLoginURL initiates the OAuth login process for the specified provider. -func (a *Client) OAuthLoginURL(ctx context.Context, provider string) (string, error) { - authURL := a.authURL - if authURL == "" { - authURL = common.GetBaseURL() - } - loginURL, err := url.Parse(authURL + "/users/oauth2/" + provider) - if err != nil { - return "", fmt.Errorf("failed to parse URL: %w", err) - } - query := loginURL.Query() - query.Set("deviceId", settings.GetString(settings.DeviceIDKey)) - query.Set("userId", settings.GetString(settings.UserIDKey)) - query.Set("proToken", settings.GetString(settings.TokenKey)) - query.Set("returnTo", "lantern://auth") - loginURL.RawQuery = query.Encode() - // Persist the provider so it's available after the callback completes. - if err := settings.Set(settings.OAuthProviderKey, provider); err != nil { - return "", fmt.Errorf("failed to persist OAuth provider: %w", err) - } - return loginURL.String(), nil -} - -func (a *Client) OAuthLoginCallback(ctx context.Context, oAuthToken string) (*UserData, error) { - slog.Debug("Getting OAuth login callback") - jwtUserInfo, err := decodeJWT(oAuthToken) - if err != nil { - return nil, fmt.Errorf("error decoding JWT: %w", err) - } - - // Temporary set user data to so api can read it - login := &UserData{ - LegacyID: jwtUserInfo.LegacyUserID, - LegacyToken: jwtUserInfo.LegacyToken, - LegacyUserData: &protos.LoginResponse_UserData{ - UserId: jwtUserInfo.LegacyUserID, - Token: jwtUserInfo.LegacyToken, - DeviceID: jwtUserInfo.DeviceID, - Email: jwtUserInfo.Email, - }, - } - a.setData(login) - // Get user data from api this will also save data in user config - user, err := a.fetchUserData(ctx) - if err != nil { - return nil, fmt.Errorf("error getting user data: %w", err) - } - - if err := settings.Set(settings.JwtTokenKey, oAuthToken); err != nil { - slog.Error("Failed to persist JWT token", "error", err) - return nil, fmt.Errorf("failed to persist JWT token: %w", err) - } - settings.Set(settings.OAuthLoginKey, true) - user.Id = jwtUserInfo.Email - user.EmailConfirmed = true - a.setData(user) - return user, nil -} - type LinkResponse struct { *protos.BaseResponse `json:",inline"` UserID int `json:"userID"` diff --git a/backend/oauth.go b/backend/oauth.go new file mode 100644 index 00000000..2f37d059 --- /dev/null +++ b/backend/oauth.go @@ -0,0 +1,17 @@ +//go:build !stealth_novpn + +package backend + +import ( + "context" + + "github.com/getlantern/radiance/account" +) + +func (r *LocalBackend) OAuthLoginCallback(ctx context.Context, oAuthToken string) (*account.UserData, error) { + return r.accountClient.OAuthLoginCallback(ctx, oAuthToken) +} + +func (r *LocalBackend) OAuthLoginURL(ctx context.Context, provider string) (string, error) { + return r.accountClient.OAuthLoginURL(ctx, provider) +} diff --git a/backend/radiance.go b/backend/radiance.go index 8ea01106..c94dd8ce 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -255,13 +255,13 @@ func (r *LocalBackend) Start() { var srvs []*servers.Server for _, out := range cfg.Options.Outbounds { srvs = append(srvs, &servers.Server{ - Tag: out.Tag, Type: out.Type, IsLantern: true, + Tag: out.Tag, Type: out.Type, Managed: true, Options: out, Location: locs[out.Tag], }) } for _, ep := range cfg.Options.Endpoints { srvs = append(srvs, &servers.Server{ - Tag: ep.Tag, Type: ep.Type, IsLantern: true, + Tag: ep.Tag, Type: ep.Type, Managed: true, Options: ep, Location: locs[ep.Tag], }) } @@ -583,8 +583,8 @@ func (r *LocalBackend) RemoveServers(tags []string) error { return nil } -func (r *LocalBackend) setServers(list servers.ServerList, isLantern bool) error { - if err := r.srvManager.SetServers(list, isLantern); err != nil { +func (r *LocalBackend) setServers(list servers.ServerList, managed bool) error { + if err := r.srvManager.SetServers(list, managed); err != nil { return fmt.Errorf("failed to set servers in ServerManager: %w", err) } // updateOutbounds evicts any outbound absent from the list; include all @@ -730,7 +730,7 @@ func (r *LocalBackend) ConnectVPN(tag string) error { bOptions := r.getBoxOptions() bOptions.InitialServer = tag if err := r.vpnClient.Connect(bOptions); err != nil { - return fmt.Errorf("failed to connect VPN: %w", err) + return fmt.Errorf("failed to connect session: %w", err) } r.persistSelection(tag) return nil @@ -755,7 +755,7 @@ func (r *LocalBackend) getBoxOptions() vpn.BoxOptions { } seed := make(map[string]adapter.URLTestHistory) for _, srv := range r.srvManager.AllServers() { - if !srv.IsLantern { + if !srv.Managed { switch opts := srv.Options.(type) { case option.Outbound: bOptions.Options.Outbounds = append(bOptions.Options.Outbounds, opts) @@ -883,7 +883,7 @@ func (r *LocalBackend) SelectedServer() (*servers.Server, bool, error) { } server, found := r.srvManager.GetServerByTag(selected.Tag) stillExists := found && - server.IsLantern == selected.IsLantern && + server.Managed == selected.Managed && server.Type == selected.Type && server.Location == selected.Location return &selected, stillExists, nil @@ -1082,14 +1082,6 @@ func (r *LocalBackend) RemoveDevice(ctx context.Context, deviceID string) (*acco return r.accountClient.RemoveDevice(ctx, deviceID) } -func (r *LocalBackend) OAuthLoginCallback(ctx context.Context, oAuthToken string) (*account.UserData, error) { - return r.accountClient.OAuthLoginCallback(ctx, oAuthToken) -} - -func (r *LocalBackend) OAuthLoginURL(ctx context.Context, provider string) (string, error) { - return r.accountClient.OAuthLoginURL(ctx, provider) -} - func (r *LocalBackend) UserDevices() ([]settings.Device, error) { return settings.Devices() } diff --git a/config/config.go b/config/config.go index c5fffd7c..0aeb6484 100644 --- a/config/config.go +++ b/config/config.go @@ -19,15 +19,11 @@ import ( "sync/atomic" "time" - "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - C "github.com/getlantern/common" + lbO "github.com/getlantern/lantern-box/option" "github.com/sagernet/sing-box/option" singjson "github.com/sagernet/sing/common/json" - box "github.com/getlantern/lantern-box" - lbO "github.com/getlantern/lantern-box/option" - "github.com/getlantern/radiance/account" "github.com/getlantern/radiance/common" "github.com/getlantern/radiance/common/atomicfile" @@ -35,6 +31,7 @@ import ( "github.com/getlantern/radiance/common/settings" "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/internal" + "github.com/getlantern/radiance/internal/boxctx" ) const ( @@ -129,21 +126,15 @@ func (ch *ConfigHandler) Start() { var ErrNoWGKey = errors.New("no wg key") -func (ch *ConfigHandler) loadWGKey() (wgtypes.Key, error) { - buf, err := atomicfile.ReadFile(ch.wgKeyPath) - if os.IsNotExist(err) { - return wgtypes.Key{}, ErrNoWGKey - } - if err != nil { - return wgtypes.Key{}, fmt.Errorf("reading wg key file: %w", err) - } - key, err := wgtypes.ParseKey(string(buf)) - if err != nil { - return wgtypes.Key{}, fmt.Errorf("parsing wg key: %w", err) - } - return key, nil +type proxyConfigKey struct { + private string + public string } +func (k proxyConfigKey) Private() string { return k.private } + +func (k proxyConfigKey) Public() string { return k.public } + func (ch *ConfigHandler) fetchConfig() error { if settings.GetBool(settings.ConfigFetchDisabledKey) { ch.logger.Info("config fetch disabled, skipping") @@ -158,20 +149,9 @@ func (ch *ConfigHandler) fetchConfig() error { } defer ch.fetching.Store(false) - privateKey, err := ch.loadWGKey() - if err != nil && !errors.Is(err, ErrNoWGKey) { - return fmt.Errorf("loading wg key: %w", err) - } - - if errors.Is(err, ErrNoWGKey) { - var keyErr error - if privateKey, keyErr = wgtypes.GeneratePrivateKey(); keyErr != nil { - return fmt.Errorf("failed to generate wg keys: %w", keyErr) - } - - if writeErr := atomicfile.WriteFile(ch.wgKeyPath, []byte(privateKey.String()), fileperm.File); writeErr != nil { - return fmt.Errorf("writing wg key file: %w", writeErr) - } + privateKey, err := ch.loadProxyConfigKey() + if err != nil { + return fmt.Errorf("loading proxy config key: %w", err) } ch.logger.Info("Fetching config") @@ -180,7 +160,7 @@ func (ch *ConfigHandler) fetchConfig() error { ch.logger.Error("failed to get preferred location from settings", "error", err) } - resp, err := ch.ftr.fetchConfig(ch.ctx, preferred, privateKey.PublicKey().String()) + resp, err := ch.ftr.fetchConfig(ch.ctx, preferred, privateKey.Public()) if err != nil { return fmt.Errorf("%w: %w", ErrFetchingConfig, err) } @@ -200,14 +180,14 @@ func (ch *ConfigHandler) fetchConfig() error { // because the error could have been due to temporary network issues, such as brief // power loss or internet disconnection. // On the other hand, if we have a new config, we want to overwrite any previous error. - confResp, err := singjson.UnmarshalExtendedContext[C.ConfigResponse](box.BaseContext(), resp) + confResp, err := singjson.UnmarshalExtendedContext[C.ConfigResponse](boxctx.BaseContext(), resp) if err != nil { ch.logger.Error("failed to parse config", "error", err) return fmt.Errorf("parsing config: %w", err) } cleanTags(&confResp) - setWireGuardKeyInOptions(confResp.Options.Endpoints, privateKey) + setEndpointKeys(confResp.Options.Endpoints, privateKey) setCustomProtocolOptions(confResp.Options.Outbounds) if err := ch.setConfig(&confResp); err != nil { ch.logger.Error("failed to set config", "error", err) @@ -249,23 +229,6 @@ func cleanTags(cfg *C.ConfigResponse) { cfg.OutboundLocations = nlocs } -func setWireGuardKeyInOptions(endpoints []option.Endpoint, privateKey wgtypes.Key) { - // Requires privilege and cannot conflict with existing system interfaces - // System tries to use system env; for mobile we need to tun device - system := !(common.IsAndroid() || common.IsIOS() || common.IsMacOS()) - for _, endpoint := range endpoints { - switch opts := endpoint.Options.(type) { - case *option.WireGuardEndpointOptions: - opts.PrivateKey = privateKey.String() - opts.System = opts.System && system - case *lbO.AmneziaEndpointOptions: - opts.PrivateKey = privateKey.String() - opts.System = opts.System && system - default: - } - } -} - // fetchLoop fetches the configuration periodically. It uses the server's // recommended poll interval (PollIntervalSeconds) when available, falling // back to the default pollInterval. This allows the bandit to control how @@ -355,7 +318,7 @@ func load(path string) (*Config, error) { if err != nil { return nil, fmt.Errorf("reading config file: %w", err) } - ctx := box.BaseContext() + ctx := boxctx.BaseContext() cfg, err := singjson.UnmarshalExtendedContext[*Config](ctx, buf) if err != nil { // try to migrate from old format if parsing fails @@ -379,7 +342,7 @@ func migrateToNewFmt(data []byte) (*Config, error) { if err := json.Unmarshal(data, &tmp); err != nil { return nil, err } - opts, err := singjson.UnmarshalExtendedContext[C.ConfigResponse](box.BaseContext(), tmp.ConfigResponse) + opts, err := singjson.UnmarshalExtendedContext[C.ConfigResponse](boxctx.BaseContext(), tmp.ConfigResponse) if err != nil { return nil, err } diff --git a/config/fetcher.go b/config/fetcher.go index a5af7b85..bd0320d6 100644 --- a/config/fetcher.go +++ b/config/fetcher.go @@ -16,13 +16,11 @@ import ( "slices" C "github.com/getlantern/common" + "github.com/getlantern/kindling" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "github.com/getlantern/kindling" - "github.com/getlantern/lantern-box/protocol" - "github.com/getlantern/radiance/account" "github.com/getlantern/radiance/common" "github.com/getlantern/radiance/common/env" @@ -84,7 +82,7 @@ func (f *fetcher) fetchConfig(ctx context.Context, preferred common.PreferredLoc WGPublicKey: wgPublicKey, Backend: C.SINGBOX, Locale: f.locale, - Protocols: protocol.SupportedProtocols(), + Protocols: supportedProtocols(), } if preferred.Country != "" { confReq.PreferredLocation = &preferred diff --git a/config/protocols_default.go b/config/protocols_default.go new file mode 100644 index 00000000..404ee248 --- /dev/null +++ b/config/protocols_default.go @@ -0,0 +1,9 @@ +//go:build !stealth_novpn + +package config + +import "github.com/getlantern/lantern-box/protocol" + +func supportedProtocols() []string { + return protocol.SupportedProtocols() +} diff --git a/config/protocols_stealth_novpn.go b/config/protocols_stealth_novpn.go new file mode 100644 index 00000000..7f56b2cd --- /dev/null +++ b/config/protocols_stealth_novpn.go @@ -0,0 +1,21 @@ +//go:build stealth_novpn + +package config + +func supportedProtocols() []string { + return []string{ + "algeneva", + "outline", + "reflex", + "samizdat", + "unbounded", + "water", + "http", + "shadowsocks", + "shadowtls", + "socks", + "trojan", + "vless", + "vmess", + } +} diff --git a/config/wg_default.go b/config/wg_default.go new file mode 100644 index 00000000..9ff5fe9b --- /dev/null +++ b/config/wg_default.go @@ -0,0 +1,64 @@ +//go:build !stealth_novpn + +package config + +import ( + "fmt" + "os" + + lbO "github.com/getlantern/lantern-box/option" + "github.com/sagernet/sing-box/option" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/getlantern/radiance/common" + "github.com/getlantern/radiance/common/atomicfile" + "github.com/getlantern/radiance/common/fileperm" +) + +func (ch *ConfigHandler) loadProxyConfigKey() (proxyConfigKey, error) { + privateKey, err := ch.loadWGKey() + if err != nil && err != ErrNoWGKey { + return proxyConfigKey{}, err + } + if err == ErrNoWGKey { + var keyErr error + if privateKey, keyErr = wgtypes.GeneratePrivateKey(); keyErr != nil { + return proxyConfigKey{}, fmt.Errorf("failed to generate wg keys: %w", keyErr) + } + if writeErr := atomicfile.WriteFile(ch.wgKeyPath, []byte(privateKey.String()), fileperm.File); writeErr != nil { + return proxyConfigKey{}, fmt.Errorf("writing wg key file: %w", writeErr) + } + } + return proxyConfigKey{private: privateKey.String(), public: privateKey.PublicKey().String()}, nil +} + +func (ch *ConfigHandler) loadWGKey() (wgtypes.Key, error) { + buf, err := atomicfile.ReadFile(ch.wgKeyPath) + if os.IsNotExist(err) { + return wgtypes.Key{}, ErrNoWGKey + } + if err != nil { + return wgtypes.Key{}, fmt.Errorf("reading wg key file: %w", err) + } + key, err := wgtypes.ParseKey(string(buf)) + if err != nil { + return wgtypes.Key{}, fmt.Errorf("parsing wg key: %w", err) + } + return key, nil +} + +func setEndpointKeys(endpoints []option.Endpoint, privateKey proxyConfigKey) { + // Requires privilege and cannot conflict with existing system interfaces. + system := !(common.IsAndroid() || common.IsIOS() || common.IsMacOS()) + for _, endpoint := range endpoints { + switch opts := endpoint.Options.(type) { + case *option.WireGuardEndpointOptions: + opts.PrivateKey = privateKey.Private() + opts.System = opts.System && system + case *lbO.AmneziaEndpointOptions: + opts.PrivateKey = privateKey.Private() + opts.System = opts.System && system + default: + } + } +} diff --git a/config/wg_stealth_novpn.go b/config/wg_stealth_novpn.go new file mode 100644 index 00000000..9e987168 --- /dev/null +++ b/config/wg_stealth_novpn.go @@ -0,0 +1,11 @@ +//go:build stealth_novpn + +package config + +import "github.com/sagernet/sing-box/option" + +func (ch *ConfigHandler) loadProxyConfigKey() (proxyConfigKey, error) { + return proxyConfigKey{}, nil +} + +func setEndpointKeys([]option.Endpoint, proxyConfigKey) {} diff --git a/internal/boxctx/base_default.go b/internal/boxctx/base_default.go new file mode 100644 index 00000000..75d0bd51 --- /dev/null +++ b/internal/boxctx/base_default.go @@ -0,0 +1,13 @@ +//go:build !stealth_novpn + +package boxctx + +import ( + "context" + + box "github.com/getlantern/lantern-box" +) + +func BaseContext() context.Context { + return box.BaseContext() +} diff --git a/internal/boxctx/base_stealth_novpn.go b/internal/boxctx/base_stealth_novpn.go new file mode 100644 index 00000000..d70f0486 --- /dev/null +++ b/internal/boxctx/base_stealth_novpn.go @@ -0,0 +1,127 @@ +//go:build stealth_novpn + +package boxctx + +import ( + "context" + + lbC "github.com/getlantern/lantern-box/constant" + lbO "github.com/getlantern/lantern-box/option" + "github.com/getlantern/lantern-box/protocol/algeneva" + lbGroup "github.com/getlantern/lantern-box/protocol/group" + "github.com/getlantern/lantern-box/protocol/outline" + "github.com/getlantern/lantern-box/protocol/reflex" + "github.com/getlantern/lantern-box/protocol/samizdat" + "github.com/getlantern/lantern-box/protocol/unbounded" + "github.com/getlantern/lantern-box/protocol/water" + box "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/adapter/outbound" + boxservice "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/dns/transport/fakeip" + "github.com/sagernet/sing-box/dns/transport/hosts" + "github.com/sagernet/sing-box/dns/transport/local" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/block" + "github.com/sagernet/sing-box/protocol/direct" + protocolDNS "github.com/sagernet/sing-box/protocol/dns" + "github.com/sagernet/sing-box/protocol/group" + "github.com/sagernet/sing-box/protocol/http" + "github.com/sagernet/sing-box/protocol/mixed" + "github.com/sagernet/sing-box/protocol/shadowsocks" + "github.com/sagernet/sing-box/protocol/shadowtls" + "github.com/sagernet/sing-box/protocol/socks" + "github.com/sagernet/sing-box/protocol/trojan" + "github.com/sagernet/sing-box/protocol/vless" + "github.com/sagernet/sing-box/protocol/vmess" + "github.com/sagernet/sing/common/exceptions" +) + +func BaseContext() context.Context { + return box.Context( + context.Background(), + inboundRegistry(), + outboundRegistry(), + endpointRegistry(), + dnsTransportRegistry(), + boxservice.NewRegistry(), + ) +} + +func inboundRegistry() *inbound.Registry { + registry := inbound.NewRegistry() + direct.RegisterInbound(registry) + socks.RegisterInbound(registry) + http.RegisterInbound(registry) + mixed.RegisterInbound(registry) + return registry +} + +func outboundRegistry() *outbound.Registry { + registry := outbound.NewRegistry() + direct.RegisterOutbound(registry) + block.RegisterOutbound(registry) + protocolDNS.RegisterOutbound(registry) + group.RegisterSelector(registry) + group.RegisterURLTest(registry) + socks.RegisterOutbound(registry) + http.RegisterOutbound(registry) + shadowsocks.RegisterOutbound(registry) + shadowtls.RegisterOutbound(registry) + trojan.RegisterOutbound(registry) + vless.RegisterOutbound(registry) + vmess.RegisterOutbound(registry) + algeneva.RegisterOutbound(registry) + outline.RegisterOutbound(registry) + reflex.RegisterOutbound(registry) + samizdat.RegisterOutbound(registry) + unbounded.RegisterOutbound(registry) + water.RegisterOutbound(registry) + lbGroup.RegisterFallback(registry) + lbGroup.RegisterMutableSelector(registry) + lbGroup.RegisterMutableURLTest(registry) + registerUnsupportedOutboundStubs(registry) + return registry +} + +func endpointRegistry() *endpoint.Registry { + registry := endpoint.NewRegistry() + registerUnsupportedEndpointStubs(registry) + return registry +} + +func dnsTransportRegistry() *dns.TransportRegistry { + registry := dns.NewTransportRegistry() + transport.RegisterTCP(registry) + transport.RegisterUDP(registry) + transport.RegisterTLS(registry) + transport.RegisterHTTPS(registry) + hosts.RegisterTransport(registry) + local.RegisterTransport(registry) + fakeip.RegisterTransport(registry) + return registry +} + +func registerUnsupportedOutboundStubs(registry *outbound.Registry) { + outbound.Register[option.LegacyWireGuardOutboundOptions](registry, C.TypeWireGuard, func(context.Context, adapter.Router, log.ContextLogger, string, option.LegacyWireGuardOutboundOptions) (adapter.Outbound, error) { + return nil, exceptions.New("unsupported in this build") + }) +} + +func registerUnsupportedEndpointStubs(registry *endpoint.Registry) { + endpoint.Register[option.WireGuardEndpointOptions](registry, C.TypeWireGuard, func(context.Context, adapter.Router, log.ContextLogger, string, option.WireGuardEndpointOptions) (adapter.Endpoint, error) { + return nil, exceptions.New("unsupported in this build") + }) + endpoint.Register[option.TailscaleEndpointOptions](registry, C.TypeTailscale, func(context.Context, adapter.Router, log.ContextLogger, string, option.TailscaleEndpointOptions) (adapter.Endpoint, error) { + return nil, exceptions.New("unsupported in this build") + }) + endpoint.Register[lbO.AmneziaEndpointOptions](registry, lbC.TypeAmnezia, func(context.Context, adapter.Router, log.ContextLogger, string, lbO.AmneziaEndpointOptions) (adapter.Endpoint, error) { + return nil, exceptions.New("unsupported in this build") + }) +} diff --git a/ipc/auth_provider_default.go b/ipc/auth_provider_default.go new file mode 100644 index 00000000..6c0b96f1 --- /dev/null +++ b/ipc/auth_provider_default.go @@ -0,0 +1,69 @@ +//go:build !stealth_novpn + +package ipc + +import ( + "context" + "net/http" + "net/url" + + "github.com/getlantern/radiance/account" +) + +const accountOAuthEndpoint = "/account/oauth" + +type OAuthTokenRequest struct { + OAuthToken string `json:"oAuthToken"` +} + +func (s *localapi) registerAuthProviderHandlers(mux *http.ServeMux, traced func(http.HandlerFunc) http.HandlerFunc) { + mux.HandleFunc(accountOAuthEndpoint, traced(s.accountOAuthHandler)) +} + +// accountOAuthHandler handles GET /account/oauth (login URL) and POST /account/oauth (callback). +func (s *localapi) accountOAuthHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + var req OAuthTokenRequest + if err := decodeJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + userData, err := s.backend(r.Context()).OAuthLoginCallback(r.Context(), req.OAuthToken) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, userData) + return + } + provider := r.URL.Query().Get("provider") + if provider == "" { + http.Error(w, "provider is required", http.StatusBadRequest) + return + } + u, err := s.backend(r.Context()).OAuthLoginURL(r.Context(), provider) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, URLResponse{URL: u}) +} + +// OAuthLoginURL returns the OAuth login URL for the given provider. +func (c *Client) OAuthLoginURL(ctx context.Context, provider string) (string, error) { + var resp URLResponse + q := url.Values{"provider": {provider}} + err := c.doJSON(ctx, http.MethodGet, accountOAuthEndpoint+"?"+q.Encode(), nil, &resp) + return resp.URL, err +} + +// OAuthLoginCallback exchanges an OAuth token for user data. +func (c *Client) OAuthLoginCallback(ctx context.Context, oAuthToken string) (*account.UserData, error) { + var userData account.UserData + err := c.doJSON(ctx, http.MethodPost, accountOAuthEndpoint, + OAuthTokenRequest{OAuthToken: oAuthToken}, &userData) + if err != nil { + return nil, err + } + return &userData, nil +} diff --git a/ipc/auth_provider_stealth_novpn.go b/ipc/auth_provider_stealth_novpn.go new file mode 100644 index 00000000..bd5ecc9b --- /dev/null +++ b/ipc/auth_provider_stealth_novpn.go @@ -0,0 +1,8 @@ +//go:build stealth_novpn + +package ipc + +import "net/http" + +func (s *localapi) registerAuthProviderHandlers(mux *http.ServeMux, traced func(http.HandlerFunc) http.HandlerFunc) { +} diff --git a/ipc/client.go b/ipc/client.go index 5f4a94d6..9b7ee59b 100644 --- a/ipc/client.go +++ b/ipc/client.go @@ -547,25 +547,6 @@ func (c *Client) DeleteAccount(ctx context.Context, email, password string) (*ac return &userData, nil } -// OAuthLoginURL returns the OAuth login URL for the given provider. -func (c *Client) OAuthLoginURL(ctx context.Context, provider string) (string, error) { - var resp URLResponse - q := url.Values{"provider": {provider}} - err := c.doJSON(ctx, http.MethodGet, accountOAuthEndpoint+"?"+q.Encode(), nil, &resp) - return resp.URL, err -} - -// OAuthLoginCallback exchanges an OAuth token for user data. -func (c *Client) OAuthLoginCallback(ctx context.Context, oAuthToken string) (*account.UserData, error) { - var userData account.UserData - err := c.doJSON(ctx, http.MethodPost, accountOAuthEndpoint, - OAuthTokenRequest{OAuthToken: oAuthToken}, &userData) - if err != nil { - return nil, err - } - return &userData, nil -} - // DataCapInfo returns the current data cap information as a JSON string. func (c *Client) DataCapInfo(ctx context.Context) (*account.DataCapInfo, error) { var resp account.DataCapInfo diff --git a/ipc/outbound_test.go b/ipc/outbound_test.go index d366ac2b..6ff0ca3f 100644 --- a/ipc/outbound_test.go +++ b/ipc/outbound_test.go @@ -44,10 +44,10 @@ func TestSamizdatOptionsRoundTrip(t *testing.T) { list := servers.ServerList{ Servers: []*servers.Server{ { - Tag: outbound.Tag, - Type: outbound.Type, - IsLantern: true, - Options: outbound, + Tag: outbound.Tag, + Type: outbound.Type, + Managed: true, + Options: outbound, }, }, } diff --git a/ipc/server.go b/ipc/server.go index 27ff1175..63be8f45 100644 --- a/ipc/server.go +++ b/ipc/server.go @@ -76,7 +76,6 @@ const ( accountEmailEndpoint = "/account/email" accountRecoveryEndpoint = "/account/recovery" accountDeleteEndpoint = "/account/delete" - accountOAuthEndpoint = "/account/oauth" accountDataCapEndpoint = "/account/datacap" accountDataCapStreamEndpoint = "/account/datacap/stream" @@ -238,7 +237,7 @@ func newLocalAPI(b *backend.LocalBackend, withAuth bool) *localapi { mux.HandleFunc("POST "+accountEmailEndpoint+"/{action}", traced(s.accountEmailHandler)) mux.HandleFunc("POST "+accountRecoveryEndpoint+"/{action}", traced(s.accountRecoveryHandler)) mux.HandleFunc("DELETE "+accountDeleteEndpoint, traced(s.accountDeleteHandler)) - mux.HandleFunc(accountOAuthEndpoint, traced(s.accountOAuthHandler)) + s.registerAuthProviderHandlers(mux, traced) mux.HandleFunc("GET "+accountDataCapEndpoint, traced(s.accountDataCapHandler)) // SSE routes skip the tracer middleware since it buffers the entire response body. @@ -952,35 +951,6 @@ func (s *localapi) accountDeleteHandler(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, userData) } -// accountOAuthHandler handles GET /account/oauth (login URL) and POST /account/oauth (callback). -func (s *localapi) accountOAuthHandler(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - var req OAuthTokenRequest - if err := decodeJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - userData, err := s.backend(r.Context()).OAuthLoginCallback(r.Context(), req.OAuthToken) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, http.StatusOK, userData) - return - } - provider := r.URL.Query().Get("provider") - if provider == "" { - http.Error(w, "provider is required", http.StatusBadRequest) - return - } - u, err := s.backend(r.Context()).OAuthLoginURL(r.Context(), provider) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, http.StatusOK, URLResponse{URL: u}) -} - func (s *localapi) accountDataCapHandler(w http.ResponseWriter, r *http.Request) { info, err := s.backend(r.Context()).DataCapInfo(r.Context()) if err != nil { diff --git a/ipc/types.go b/ipc/types.go index f9072d15..f4c98fe9 100644 --- a/ipc/types.go +++ b/ipc/types.go @@ -28,10 +28,6 @@ type EmailCodeRequest struct { Code string `json:"code"` } -type OAuthTokenRequest struct { - OAuthToken string `json:"oAuthToken"` -} - type CodeRequest struct { Code string `json:"code"` } diff --git a/servers/add_servers_by_url_default.go b/servers/add_servers_by_url_default.go new file mode 100644 index 00000000..2d00a7a0 --- /dev/null +++ b/servers/add_servers_by_url_default.go @@ -0,0 +1,55 @@ +//go:build !stealth_novpn + +package servers + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/getlantern/pluriconfig" + "github.com/getlantern/pluriconfig/model" + "github.com/getlantern/radiance/traces" + "go.opentelemetry.io/otel" +) + +// AddServersByURL adds a server(s) by downloading and parsing the config from a list of URLs. +func (m *Manager) AddServersByURL(ctx context.Context, urls []string, skipCertVerification bool) ([]string, error) { + ctx, span := otel.Tracer(tracerName).Start(ctx, "Manager.AddServerByURLs") + defer span.End() + urlProvider, loaded := pluriconfig.GetProvider(string(model.ProviderURL)) + if !loaded { + return nil, traces.RecordError(ctx, fmt.Errorf("URL config provider not loaded")) + } + cfg, err := urlProvider.Parse(ctx, []byte(strings.Join(urls, "\n"))) + if err != nil { + return nil, traces.RecordError(ctx, fmt.Errorf("failed to parse URLs: %w", err)) + } + cfgURLs, ok := cfg.Options.([]url.URL) + if !ok || len(cfgURLs) == 0 { + return nil, traces.RecordError(ctx, fmt.Errorf("no valid URLs found in the provided configuration")) + } + + if skipCertVerification { + urlsWithCustomOptions := make([]url.URL, 0, len(cfgURLs)) + for _, v := range cfgURLs { + queryParams := v.Query() + queryParams.Add("allowInsecure", "1") + v.RawQuery = queryParams.Encode() + urlsWithCustomOptions = append(urlsWithCustomOptions, v) + } + cfg.Options = urlsWithCustomOptions + } + + singBoxProvider, loaded := pluriconfig.GetProvider(string(model.ProviderSingBox)) + if !loaded { + return nil, traces.RecordError(ctx, fmt.Errorf("singbox config provider not loaded")) + } + singBoxCfg, err := singBoxProvider.Serialize(ctx, cfg) + if err != nil { + return nil, traces.RecordError(ctx, fmt.Errorf("failed to serialize sing-box config: %w", err)) + } + m.logger.Info("Added servers based on URLs", "serverCount", len(cfgURLs), "skipCertVerification", skipCertVerification) + return m.AddServersByJSON(ctx, singBoxCfg) +} diff --git a/servers/add_servers_by_url_stealth_novpn.go b/servers/add_servers_by_url_stealth_novpn.go new file mode 100644 index 00000000..0a0f1d65 --- /dev/null +++ b/servers/add_servers_by_url_stealth_novpn.go @@ -0,0 +1,14 @@ +//go:build stealth_novpn + +package servers + +import ( + "context" + "fmt" + + "github.com/getlantern/radiance/traces" +) + +func (m *Manager) AddServersByURL(ctx context.Context, urls []string, skipCertVerification bool) ([]string, error) { + return nil, traces.RecordError(ctx, fmt.Errorf("URL config provider not loaded")) +} diff --git a/servers/managed_legacy_default.go b/servers/managed_legacy_default.go new file mode 100644 index 00000000..4122c553 --- /dev/null +++ b/servers/managed_legacy_default.go @@ -0,0 +1,18 @@ +//go:build !stealth_novpn + +package servers + +import stdjson "encoding/json" + +func applyLegacyManagedFlag(data []byte, managed *bool) { + var legacy struct { + Value *bool `json:"isLantern"` + } + if err := stdjson.Unmarshal(data, &legacy); err == nil && legacy.Value != nil { + *managed = *legacy.Value + } +} + +func isManagedServerGroup(group string) bool { + return group == "lantern" +} diff --git a/servers/managed_legacy_stealth_novpn.go b/servers/managed_legacy_stealth_novpn.go new file mode 100644 index 00000000..6d76533b --- /dev/null +++ b/servers/managed_legacy_stealth_novpn.go @@ -0,0 +1,9 @@ +//go:build stealth_novpn + +package servers + +func applyLegacyManagedFlag(_ []byte, _ *bool) {} + +func isManagedServerGroup(group string) bool { + return group == "managed" || group == string([]byte{'l', 'a', 'n', 't', 'e', 'r', 'n'}) +} diff --git a/servers/manager.go b/servers/manager.go index 0c6302f6..65245181 100644 --- a/servers/manager.go +++ b/servers/manager.go @@ -17,11 +17,9 @@ import ( "path/filepath" "runtime" "strconv" - "strings" "sync" "time" - box "github.com/getlantern/lantern-box" "github.com/hashicorp/go-retryablehttp" "go.opentelemetry.io/otel" @@ -31,14 +29,10 @@ import ( "github.com/getlantern/radiance/common/atomicfile" "github.com/getlantern/radiance/common/fileperm" "github.com/getlantern/radiance/internal" + "github.com/getlantern/radiance/internal/boxctx" "github.com/getlantern/radiance/log" "github.com/getlantern/radiance/traces" - "github.com/getlantern/pluriconfig" - "github.com/getlantern/pluriconfig/model" - _ "github.com/getlantern/pluriconfig/provider/singbox" - _ "github.com/getlantern/pluriconfig/provider/url" - "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common/json" ) @@ -87,7 +81,7 @@ type ServerCredentials struct { type Server struct { Tag string `json:"tag"` Type string `json:"type"` - IsLantern bool `json:"isLantern"` + Managed bool `json:"managed"` Options any `json:"options"` Location C.ServerLocation `json:"location,omitempty"` Credentials *ServerCredentials `json:"credentials,omitempty"` @@ -100,7 +94,7 @@ type Server struct { type serverJSON struct { Tag string `json:"tag"` Type string `json:"type"` - IsLantern bool `json:"isLantern"` + Managed bool `json:"managed"` Outbound *option.Outbound `json:"outbound,omitempty"` Endpoint *option.Endpoint `json:"endpoint,omitempty"` Location C.ServerLocation `json:"location,omitempty"` @@ -112,7 +106,7 @@ func (s Server) MarshalJSON() ([]byte, error) { sj := serverJSON{ Tag: s.Tag, Type: s.Type, - IsLantern: s.IsLantern, + Managed: s.Managed, Location: s.Location, Credentials: s.Credentials, URLTestResult: s.URLTestResult, @@ -123,17 +117,18 @@ func (s Server) MarshalJSON() ([]byte, error) { case option.Endpoint: sj.Endpoint = &opts } - return json.MarshalContext(box.BaseContext(), sj) + return json.MarshalContext(boxctx.BaseContext(), sj) } func (s *Server) UnmarshalJSON(data []byte) error { - sj, err := json.UnmarshalExtendedContext[serverJSON](box.BaseContext(), data) + sj, err := json.UnmarshalExtendedContext[serverJSON](boxctx.BaseContext(), data) if err != nil { return err } s.Tag = sj.Tag s.Type = sj.Type - s.IsLantern = sj.IsLantern + s.Managed = sj.Managed + applyLegacyManagedFlag(data, &s.Managed) s.Location = sj.Location s.Credentials = sj.Credentials s.URLTestResult = sj.URLTestResult @@ -304,22 +299,22 @@ func warnIfReaderStarved(caller string, wait time.Duration) { ) } -// SetServers sets the server options for servers with a matching IsLantern value. -// Important: this will overwrite any existing servers with the same IsLantern value. To add new +// SetServers sets the server options for servers with a matching Managed value. +// Important: this will overwrite any existing servers with the same Managed value. To add new // servers without overwriting existing ones, use [AddServers] instead. -func (m *Manager) SetServers(list ServerList, isLantern bool) error { +func (m *Manager) SetServers(list ServerList, managed bool) error { func() { m.access.Lock() defer m.access.Unlock() - // Remove existing with matching IsLantern + // Remove existing with matching Managed for tag, srv := range m.servers { - if srv.IsLantern == isLantern { + if srv.Managed == managed { delete(m.servers, tag) } } // Add new for _, srv := range list.Servers { - srv.IsLantern = isLantern + srv.Managed = managed m.servers[srv.Tag] = srv } }() @@ -417,7 +412,7 @@ func (m *Manager) saveServers() error { for _, srv := range m.servers { servers = append(servers, srv) } - buf, err := json.MarshalContext(box.BaseContext(), servers) + buf, err := json.MarshalContext(boxctx.BaseContext(), servers) m.access.RUnlock() marshalDur := time.Since(marshalStart) - rlockWait if err != nil { @@ -463,11 +458,6 @@ func (m *Manager) saveServers() error { return werr } -const ( - modeLantern = "lantern" - modeUser = "user" -) - func (m *Manager) loadServers() error { buf, err := atomicfile.ReadFile(m.serversFile) if errors.Is(err, os.ErrNotExist) { @@ -477,7 +467,7 @@ func (m *Manager) loadServers() error { return fmt.Errorf("read server file %q: %w", m.serversFile, err) } buf = bytes.TrimSpace(buf) - ctx := box.BaseContext() + ctx := boxctx.BaseContext() if len(buf) > 0 && buf[0] == '[' { loaded, err := json.UnmarshalExtendedContext[[]*Server](ctx, buf) @@ -502,10 +492,10 @@ func (m *Manager) loadServers() error { return fmt.Errorf("unmarshal server options: %w", err) } for group, opts := range old { - isLantern := group == modeLantern + managed := isManagedServerGroup(group) for _, out := range opts.Outbounds { srv := &Server{ - Tag: out.Tag, Type: out.Type, IsLantern: isLantern, + Tag: out.Tag, Type: out.Type, Managed: managed, Options: out, Location: opts.Locations[out.Tag], } if creds, ok := opts.Credentials[out.Tag]; ok { @@ -515,7 +505,7 @@ func (m *Manager) loadServers() error { } for _, ep := range opts.Endpoints { srv := &Server{ - Tag: ep.Tag, Type: ep.Type, IsLantern: isLantern, + Tag: ep.Tag, Type: ep.Type, Managed: managed, Options: ep, Location: opts.Locations[ep.Tag], } if creds, ok := opts.Credentials[ep.Tag]; ok { @@ -557,7 +547,7 @@ func (m *Manager) AddPrivateServer(tag, ip string, port int, accessToken string, Outbounds []option.Outbound `json:"outbounds,omitempty"` Endpoints []option.Endpoint `json:"endpoints,omitempty"` } - ctx := box.BaseContext() + ctx := boxctx.BaseContext() cfg, err := json.UnmarshalExtendedContext[remoteConfig](ctx, body) if err != nil { return fmt.Errorf("decode response: %w", err) @@ -569,11 +559,11 @@ func (m *Manager) AddPrivateServer(tag, ip string, port int, accessToken string, // TODO: update when we support endpoints cfg.Outbounds[0].Tag = tag srv := &Server{ - Tag: tag, - Type: cfg.Outbounds[0].Type, - IsLantern: false, - Options: cfg.Outbounds[0], - Location: loc, + Tag: tag, + Type: cfg.Outbounds[0].Type, + Managed: false, + Options: cfg.Outbounds[0], + Location: loc, Credentials: &ServerCredentials{ AccessToken: accessToken, Port: port, IsJoined: joined, }, @@ -632,7 +622,7 @@ func (m *Manager) AddServersByJSON(ctx context.Context, config []byte) ([]string Outbounds []option.Outbound `json:"outbounds,omitempty"` Endpoints []option.Endpoint `json:"endpoints,omitempty"` } - cfg, err := json.UnmarshalExtendedContext[singboxConfig](box.BaseContext(), config) + cfg, err := json.UnmarshalExtendedContext[singboxConfig](boxctx.BaseContext(), config) if err != nil { return nil, traces.RecordError(ctx, fmt.Errorf("failed to parse config: %w", err)) } @@ -660,43 +650,3 @@ func (m *Manager) AddServersByJSON(ctx context.Context, config []byte) ([]string } return tags, nil } - -// AddServersByURL adds a server(s) by downloading and parsing the config from a list of URLs. -func (m *Manager) AddServersByURL(ctx context.Context, urls []string, skipCertVerification bool) ([]string, error) { - ctx, span := otel.Tracer(tracerName).Start(ctx, "Manager.AddServerByURLs") - defer span.End() - urlProvider, loaded := pluriconfig.GetProvider(string(model.ProviderURL)) - if !loaded { - return nil, traces.RecordError(ctx, fmt.Errorf("URL config provider not loaded")) - } - cfg, err := urlProvider.Parse(ctx, []byte(strings.Join(urls, "\n"))) - if err != nil { - return nil, traces.RecordError(ctx, fmt.Errorf("failed to parse URLs: %w", err)) - } - cfgURLs, ok := cfg.Options.([]url.URL) - if !ok || len(cfgURLs) == 0 { - return nil, traces.RecordError(ctx, fmt.Errorf("no valid URLs found in the provided configuration")) - } - - if skipCertVerification { - urlsWithCustomOptions := make([]url.URL, 0, len(cfgURLs)) - for _, v := range cfgURLs { - queryParams := v.Query() - queryParams.Add("allowInsecure", "1") - v.RawQuery = queryParams.Encode() - urlsWithCustomOptions = append(urlsWithCustomOptions, v) - } - cfg.Options = urlsWithCustomOptions - } - - singBoxProvider, loaded := pluriconfig.GetProvider(string(model.ProviderSingBox)) - if !loaded { - return nil, traces.RecordError(ctx, fmt.Errorf("singbox config provider not loaded")) - } - singBoxCfg, err := singBoxProvider.Serialize(ctx, cfg) - if err != nil { - return nil, traces.RecordError(ctx, fmt.Errorf("failed to serialize sing-box config: %w", err)) - } - m.logger.Info("Added servers based on URLs", "serverCount", len(cfgURLs), "skipCertVerification", skipCertVerification) - return m.AddServersByJSON(ctx, singBoxCfg) -} diff --git a/servers/manager_test.go b/servers/manager_test.go index 6a6ce0e6..f334ca0d 100644 --- a/servers/manager_test.go +++ b/servers/manager_test.go @@ -151,10 +151,10 @@ func TestAddServersByJSON(t *testing.T) { cfg, err := json.UnmarshalExtendedContext[singboxConfig](box.BaseContext(), testConfig) require.NoError(t, err, "failed to unmarshal test config") want := &Server{ - Tag: "out", - Type: "shadowsocks", - IsLantern: false, - Options: cfg.Outbounds[0], + Tag: "out", + Type: "shadowsocks", + Managed: false, + Options: cfg.Outbounds[0], } m := testManager(t) tags, err := m.AddServersByJSON(t.Context(), testConfig) @@ -164,7 +164,7 @@ func TestAddServersByJSON(t *testing.T) { assert.True(t, exists, "server was not added") assert.Equal(t, want.Tag, got.Tag) assert.Equal(t, want.Type, got.Type) - assert.Equal(t, want.IsLantern, got.IsLantern) + assert.Equal(t, want.Managed, got.Managed) }) t.Run("empty config", func(t *testing.T) { m := testManager(t) @@ -175,6 +175,9 @@ func TestAddServersByJSON(t *testing.T) { } func TestAddServersByURL(t *testing.T) { + if stealthNoVPNBuild { + t.Skip("URL config providers are intentionally not linked into stealth noVPN builds") + } urls := []string{ "vless://uuid@host:443?encryption=none&security=tls&type=ws&host=example.com&path=/vless#VLESS+over+WS+with+TLS", "trojan://password@host:443?security=tls&sni=example.com#Trojan+with+TLS", diff --git a/servers/providers_default.go b/servers/providers_default.go new file mode 100644 index 00000000..4e0160d3 --- /dev/null +++ b/servers/providers_default.go @@ -0,0 +1,8 @@ +//go:build !stealth_novpn + +package servers + +import ( + _ "github.com/getlantern/pluriconfig/provider/singbox" + _ "github.com/getlantern/pluriconfig/provider/url" +) diff --git a/servers/providers_stealth_novpn.go b/servers/providers_stealth_novpn.go new file mode 100644 index 00000000..27c25482 --- /dev/null +++ b/servers/providers_stealth_novpn.go @@ -0,0 +1,5 @@ +//go:build stealth_novpn + +package servers + +// URL/private-server provider registration is intentionally omitted in stealth_novpn. diff --git a/servers/stealth_flag_default_test.go b/servers/stealth_flag_default_test.go new file mode 100644 index 00000000..a4668945 --- /dev/null +++ b/servers/stealth_flag_default_test.go @@ -0,0 +1,5 @@ +//go:build !stealth_novpn + +package servers + +const stealthNoVPNBuild = false diff --git a/servers/stealth_flag_stealth_novpn_test.go b/servers/stealth_flag_stealth_novpn_test.go new file mode 100644 index 00000000..c327fe78 --- /dev/null +++ b/servers/stealth_flag_stealth_novpn_test.go @@ -0,0 +1,5 @@ +//go:build stealth_novpn + +package servers + +const stealthNoVPNBuild = true diff --git a/vpn/boxoptions.go b/vpn/boxoptions.go index cda33b58..4715cb16 100644 --- a/vpn/boxoptions.go +++ b/vpn/boxoptions.go @@ -19,7 +19,6 @@ import ( "go.opentelemetry.io/otel/trace" lcommon "github.com/getlantern/common" - box "github.com/getlantern/lantern-box" lbC "github.com/getlantern/lantern-box/constant" lbO "github.com/getlantern/lantern-box/option" "github.com/sagernet/sing-box/adapter" @@ -34,6 +33,7 @@ import ( "github.com/getlantern/radiance/common/env" "github.com/getlantern/radiance/common/fileperm" "github.com/getlantern/radiance/internal" + "github.com/getlantern/radiance/internal/boxctx" "github.com/getlantern/radiance/log" ) @@ -90,7 +90,6 @@ type BoxOptions struct { func baseOpts(basePath string) O.Options { splitTunnelPath := filepath.Join(basePath, splitTunnelFile) cacheFile := filepath.Join(basePath, cacheFileName) - loopbackAddr := badoption.Addr(netip.MustParseAddr("127.0.0.1")) return O.Options{ Log: &O.LogOptions{ Level: "debug", @@ -106,31 +105,7 @@ func baseOpts(basePath string) O.Options { Final: "dns_local", }, }, - Inbounds: []O.Inbound{ - { - Type: "tun", - Tag: "tun-in", - Options: &O.TunInboundOptions{ - InterfaceName: "utun225", - Address: []netip.Prefix{ - netip.MustParsePrefix("10.10.1.1/30"), - }, - AutoRoute: true, - StrictRoute: true, - EndpointIndependentNat: true, // needed for QUIC migration and hole-punching - }, - }, - { - Type: C.TypeMixed, - Tag: bypass.BypassInboundTag, - Options: &O.HTTPMixedInboundOptions{ - ListenOptions: O.ListenOptions{ - Listen: &loopbackAddr, - ListenPort: bypass.ProxyPort, - }, - }, - }, - }, + Inbounds: baseInbounds(), Outbounds: []O.Outbound{ { Type: C.TypeDirect, @@ -271,6 +246,7 @@ func baseRoutingRules() []O.Rule { func buildOptions(bOptions BoxOptions) (O.Options, error) { _, span := otel.Tracer(tracerName).Start(context.Background(), "buildOptions") defer span.End() + filterBuildOptions(&bOptions) if len(bOptions.Options.Outbounds) == 0 && len(bOptions.Options.Endpoints) == 0 { return O.Options{}, errors.New("no outbounds or endpoints found in config or user servers") @@ -279,6 +255,7 @@ func buildOptions(bOptions BoxOptions) (O.Options, error) { slog.Log(nil, log.LevelTrace, "Starting buildOptions", "path", bOptions.BasePath) opts := baseOpts(bOptions.BasePath) + filterBaseOptions(&opts) slog.Debug("Base options initialized") if env.GetBool(env.UseSocks) { @@ -301,22 +278,7 @@ func buildOptions(bOptions BoxOptions) (O.Options, error) { } opts.Inbounds = []O.Inbound{socksIn} } else { - switch common.Platform { - case "android": - opts.Route.OverrideAndroidVPN = true - kv := kernelVersion() - slog.Debug("detected kernel version", "kernel", kv) - if kv == "" { - slog.Warn("kernel version unknown, keeping default TUN stack") - } else if kernelBelow(kv, minAndroidSystemStackKernel) { - opts.Inbounds[0].Options.(*O.TunInboundOptions).Stack = "gvisor" - slog.Info("kernel below 5.10, using gvisor TUN stack", "kernel", kv) - } - slog.Debug("Android platform detected, OverrideAndroidVPN set to true") - case "linux": - opts.Inbounds[0].Options.(*O.TunInboundOptions).AutoRedirect = true - slog.Debug("Linux platform detected, AutoRedirect set to true") - } + applyPlatformTunnelOptions(&opts) } // add smart routing and ad block rules @@ -373,7 +335,7 @@ func buildOptions(bOptions BoxOptions) (O.Options, error) { // writeBoxOptions marshals the options as JSON and stores them in a file so we can debug them // we can ignore the errors here since the tunnel will error out anyway if something is wrong func writeBoxOptions(path string, opts O.Options) []byte { - buf, err := json.MarshalContext(box.BaseContext(), opts) + buf, err := json.MarshalContext(boxctx.BaseContext(), opts) if err != nil { slog.Warn("failed to marshal options while writing debug box options", slog.Any("error", err)) return nil diff --git a/vpn/boxoptions_stealth_novpn_test.go b/vpn/boxoptions_stealth_novpn_test.go new file mode 100644 index 00000000..e449e71d --- /dev/null +++ b/vpn/boxoptions_stealth_novpn_test.go @@ -0,0 +1,40 @@ +//go:build stealth_novpn + +package vpn + +import ( + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rlog "github.com/getlantern/radiance/log" +) + +func TestStealthNoVPNBuildOptionsExcludeSplitTunnel(t *testing.T) { + cfg := testConfig(t) + opts, err := buildOptions(BoxOptions{ + BasePath: t.TempDir(), + Options: cfg.Options, + }) + require.NoError(t, err) + require.NotNil(t, opts.Route) + + for _, ruleSet := range opts.Route.RuleSet { + assert.NotEqual(t, splitTunnelTag, ruleSet.Tag) + } + for _, rule := range opts.Route.Rules { + assert.False(t, slices.Contains(rule.DefaultOptions.RuleSet, splitTunnelTag)) + } +} + +func TestStealthNoVPNSplitTunnelHandlerDoesNotCreateRuleFile(t *testing.T) { + dataPath := t.TempDir() + st, err := NewSplitTunnelHandler(dataPath, rlog.NoOpLogger()) + require.NoError(t, err) + require.NotNil(t, st) + + assert.NoFileExists(t, filepath.Join(dataPath, splitTunnelFile)) +} diff --git a/vpn/build_options_default.go b/vpn/build_options_default.go new file mode 100644 index 00000000..5f4491d2 --- /dev/null +++ b/vpn/build_options_default.go @@ -0,0 +1,9 @@ +//go:build !stealth_novpn + +package vpn + +import O "github.com/sagernet/sing-box/option" + +func filterBuildOptions(*BoxOptions) {} + +func filterBaseOptions(*O.Options) {} diff --git a/vpn/build_options_stealth_novpn.go b/vpn/build_options_stealth_novpn.go new file mode 100644 index 00000000..c37c73a5 --- /dev/null +++ b/vpn/build_options_stealth_novpn.go @@ -0,0 +1,36 @@ +//go:build stealth_novpn + +package vpn + +import ( + "slices" + + lbC "github.com/getlantern/lantern-box/constant" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" +) + +func filterBuildOptions(options *BoxOptions) { + options.Options.Endpoints = nil + options.Options.Outbounds = slices.DeleteFunc(options.Options.Outbounds, func(outbound option.Outbound) bool { + switch outbound.Type { + case C.TypeWireGuard, lbC.TypeAmnezia: + return true + default: + return false + } + }) +} + +func filterBaseOptions(options *option.Options) { + if options.Route == nil { + return + } + options.Route.RuleSet = slices.DeleteFunc(options.Route.RuleSet, func(ruleSet option.RuleSet) bool { + return ruleSet.Tag == splitTunnelTag + }) + options.Route.Rules = slices.DeleteFunc(options.Route.Rules, func(rule option.Rule) bool { + return rule.Type == C.RuleTypeDefault && + slices.Contains(rule.DefaultOptions.RuleSet, splitTunnelTag) + }) +} diff --git a/vpn/inbounds_default.go b/vpn/inbounds_default.go new file mode 100644 index 00000000..91ce8888 --- /dev/null +++ b/vpn/inbounds_default.go @@ -0,0 +1,62 @@ +//go:build !stealth_novpn + +package vpn + +import ( + "log/slog" + "net/netip" + + "github.com/getlantern/radiance/bypass" + "github.com/getlantern/radiance/common" + C "github.com/sagernet/sing-box/constant" + O "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" +) + +func baseInbounds() []O.Inbound { + loopbackAddr := badoption.Addr(netip.MustParseAddr("127.0.0.1")) + return []O.Inbound{ + { + Type: "tun", + Tag: "tun-in", + Options: &O.TunInboundOptions{ + InterfaceName: "utun225", + Address: []netip.Prefix{ + netip.MustParsePrefix("10.10.1.1/30"), + }, + AutoRoute: true, + StrictRoute: true, + EndpointIndependentNat: true, // needed for QUIC migration and hole-punching + }, + }, + { + Type: C.TypeMixed, + Tag: bypass.BypassInboundTag, + Options: &O.HTTPMixedInboundOptions{ + ListenOptions: O.ListenOptions{ + Listen: &loopbackAddr, + ListenPort: bypass.ProxyPort, + }, + }, + }, + } +} + +func applyPlatformTunnelOptions(opts *O.Options) { + switch common.Platform { + case "android": + opts.Route.OverrideAndroidVPN = true + kv := kernelVersion() + slog.Debug("detected kernel version", "kernel", kv) + if kv == "" { + slog.Warn("kernel version unknown, keeping default TUN stack") + } else if kernelBelow(kv, minAndroidSystemStackKernel) { + opts.Inbounds[0].Options.(*O.TunInboundOptions).Stack = "gvisor" + slog.Info("kernel below 5.10, using gvisor TUN stack", "kernel", kv) + } + slog.Debug("Android platform detected, OverrideAndroidVPN set to true") + case "linux": + opts.Inbounds[0].Options.(*O.TunInboundOptions).AutoRedirect = true + slog.Debug("Linux platform detected, AutoRedirect set to true") + } +} diff --git a/vpn/inbounds_stealth_novpn.go b/vpn/inbounds_stealth_novpn.go new file mode 100644 index 00000000..f9119c72 --- /dev/null +++ b/vpn/inbounds_stealth_novpn.go @@ -0,0 +1,30 @@ +//go:build stealth_novpn + +package vpn + +import ( + "net/netip" + + "github.com/getlantern/radiance/bypass" + C "github.com/sagernet/sing-box/constant" + O "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" +) + +func baseInbounds() []O.Inbound { + loopbackAddr := badoption.Addr(netip.MustParseAddr("127.0.0.1")) + return []O.Inbound{ + { + Type: C.TypeMixed, + Tag: bypass.BypassInboundTag, + Options: &O.HTTPMixedInboundOptions{ + ListenOptions: O.ListenOptions{ + Listen: &loopbackAddr, + ListenPort: bypass.ProxyPort, + }, + }, + }, + } +} + +func applyPlatformTunnelOptions(opts *O.Options) {} diff --git a/vpn/libbox_setup_default.go b/vpn/libbox_setup_default.go new file mode 100644 index 00000000..44e89b6a --- /dev/null +++ b/vpn/libbox_setup_default.go @@ -0,0 +1,14 @@ +//go:build !stealth_novpn + +package vpn + +import ( + "github.com/getlantern/radiance/common" + "github.com/sagernet/sing-box/experimental/libbox" +) + +func configureLibboxSetupOptions(setupOpts *libbox.SetupOptions) { + if common.Platform == "android" { + setupOpts.FixAndroidStack = true + } +} diff --git a/vpn/split_tunnel.go b/vpn/split_tunnel.go index 2f8334e5..ec810a3e 100644 --- a/vpn/split_tunnel.go +++ b/vpn/split_tunnel.go @@ -51,6 +51,9 @@ type SplitTunnel struct { func NewSplitTunnelHandler(dataPath string, logger *slog.Logger) (*SplitTunnel, error) { s := newSplitTunnel(dataPath, logger) + if !splitTunnelSupported() { + return s, nil + } if err := s.loadRule(); err != nil { return nil, fmt.Errorf("loading split tunnel rule file %s: %w", s.ruleFile, err) } @@ -68,9 +71,11 @@ func newSplitTunnel(path string, logger *slog.Logger) *SplitTunnel { logger: logger, } s.initRuleMap() - if _, err := os.Stat(s.ruleFile); errors.Is(err, fs.ErrNotExist) { - logger.Debug("Creating initial split tunnel rule file", "file", s.ruleFile) - s.saveToFile() + if splitTunnelSupported() { + if _, err := os.Stat(s.ruleFile); errors.Is(err, fs.ErrNotExist) { + logger.Debug("Creating initial split tunnel rule file", "file", s.ruleFile) + s.saveToFile() + } } return s } diff --git a/vpn/split_tunnel_build_default.go b/vpn/split_tunnel_build_default.go new file mode 100644 index 00000000..50c8ed5a --- /dev/null +++ b/vpn/split_tunnel_build_default.go @@ -0,0 +1,7 @@ +//go:build !stealth_novpn + +package vpn + +func splitTunnelSupported() bool { + return true +} diff --git a/vpn/split_tunnel_build_stealth_novpn.go b/vpn/split_tunnel_build_stealth_novpn.go new file mode 100644 index 00000000..47f129e1 --- /dev/null +++ b/vpn/split_tunnel_build_stealth_novpn.go @@ -0,0 +1,7 @@ +//go:build stealth_novpn + +package vpn + +func splitTunnelSupported() bool { + return false +} diff --git a/vpn/split_tunnel_test.go b/vpn/split_tunnel_test.go index 13b63f3b..e43c541a 100644 --- a/vpn/split_tunnel_test.go +++ b/vpn/split_tunnel_test.go @@ -1,3 +1,5 @@ +//go:build !stealth_novpn + package vpn import ( diff --git a/vpn/tunnel.go b/vpn/tunnel.go index 5fa27686..01009e46 100644 --- a/vpn/tunnel.go +++ b/vpn/tunnel.go @@ -1,3 +1,5 @@ +//go:build !stealth_novpn + package vpn import ( @@ -127,9 +129,7 @@ func (t *tunnel) init(ctx context.Context, options string, platformIfce libbox.P if !common.IsWindows() { setupOpts.WorkingPath = dataPath } - if common.Platform == "android" { - setupOpts.FixAndroidStack = true - } + configureLibboxSetupOptions(setupOpts) slog.Log(nil, rlog.LevelTrace, "Setting up libbox", "setup_options", setupOpts) if err := traceSpan(ctx, "libbox.Setup", func() error { @@ -354,16 +354,16 @@ func (t *tunnel) addOutbounds(list servers.ServerList) (err error) { if t.clientContextTracker != nil { // preemptively merge the new lantern tags into the clientContextInjector match bounds to // capture any new connections before finished adding the servers. - lanternTags := make([]string, 0, len(newList.Servers)) + managedTags := make([]string, 0, len(newList.Servers)) for _, srv := range newList.Servers { - if srv.IsLantern { - lanternTags = append(lanternTags, srv.Tag) + if srv.Managed { + managedTags = append(managedTags, srv.Tag) } } - if len(lanternTags) > 0 { + if len(managedTags) > 0 { slog.Log(nil, rlog.LevelTrace, "Temporarily merging new lantern tags into ClientContextInjector") matchBounds := t.clientContextTracker.MatchBounds() - matchBounds.Outbound = append(matchBounds.Outbound, lanternTags...) + matchBounds.Outbound = append(matchBounds.Outbound, managedTags...) t.clientContextTracker.SetBounds(matchBounds) } defer func() { @@ -374,7 +374,7 @@ func (t *tunnel) addOutbounds(list servers.ServerList) (err error) { mb := t.clientContextTracker.MatchBounds() mb.Outbound = slices.DeleteFunc(mb.Outbound, func(tag string) bool { _, loaded := t.optsMap.Load(tag) - return slices.Contains(lanternTags, tag) && !loaded + return slices.Contains(managedTags, tag) && !loaded }) t.clientContextTracker.SetBounds(mb) }() diff --git a/vpn/tunnel_test.go b/vpn/tunnel_test.go index 6165e338..cf1a4362 100644 --- a/vpn/tunnel_test.go +++ b/vpn/tunnel_test.go @@ -1,3 +1,5 @@ +//go:build !stealth_novpn + package vpn import ( diff --git a/vpn/vpn.go b/vpn/vpn.go index 6506e2c8..a1487563 100644 --- a/vpn/vpn.go +++ b/vpn/vpn.go @@ -1,3 +1,5 @@ +//go:build !stealth_novpn + // Package vpn provides high-level management of VPN tunnels, including connecting to the best // available server, connecting to specific servers, disconnecting, reconnecting, and querying // tunnel status. diff --git a/vpn/vpn_stealth_novpn.go b/vpn/vpn_stealth_novpn.go new file mode 100644 index 00000000..52e5ee90 --- /dev/null +++ b/vpn/vpn_stealth_novpn.go @@ -0,0 +1,511 @@ +//go:build stealth_novpn + +package vpn + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "runtime" + "sync" + "sync/atomic" + "time" + + lbAdapter "github.com/getlantern/lantern-box/adapter" + sbox "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/urltest" + "github.com/sagernet/sing-box/experimental" + "github.com/sagernet/sing-box/option" + sbjson "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "github.com/getlantern/radiance/common" + "github.com/getlantern/radiance/events" + "github.com/getlantern/radiance/internal/boxctx" + "github.com/getlantern/radiance/kindling" + "github.com/getlantern/radiance/servers" + "github.com/getlantern/radiance/traces" +) + +const tracerName = "github.com/getlantern/radiance/core" + +var ( + ErrTunnelNotConnected = errors.New("session not connected") + ErrTunnelAlreadyConnected = errors.New("session already connected") +) + +type VPNStatus string + +const ( + Connecting VPNStatus = "connecting" + Connected VPNStatus = "connected" + Disconnecting VPNStatus = "disconnecting" + Disconnected VPNStatus = "disconnected" + Restarting VPNStatus = "restarting" + ErrorStatus VPNStatus = "error" +) + +func (s *VPNStatus) String() string { + return string(*s) +} + +type PlatformInterface interface { + RestartService() error + PostServiceClose() +} + +type VPNClient struct { + session *proxySession + + platformIfce PlatformInterface + logger *slog.Logger + + offlineTestCancel context.CancelFunc + offlineTestDone chan struct{} + + status atomic.Value + + mu sync.RWMutex +} + +type proxySession struct { + ctx context.Context + cancel context.CancelFunc + instance *sbox.Box + history adapter.URLTestHistoryStorage + clashServer *clashServer + outboundMgr adapter.OutboundManager + urlTestSeed map[string]adapter.URLTestHistory + clientContext io.Closer + closeResources []io.Closer +} + +func NewVPNClient(dataPath string, logger *slog.Logger, platformIfce PlatformInterface) *VPNClient { + if logger == nil { + logger = slog.Default() + } + done := make(chan struct{}) + close(done) + c := &VPNClient{ + platformIfce: platformIfce, + logger: logger, + offlineTestCancel: func() {}, + offlineTestDone: done, + } + c.status.Store(Disconnected) + return c +} + +func (c *VPNClient) Connect(boxOptions BoxOptions) error { + ctx, span := otel.Tracer(tracerName).Start(context.Background(), "connect") + defer span.End() + + c.mu.Lock() + c.offlineTestCancel() + done := c.offlineTestDone + c.mu.Unlock() + <-done + + c.mu.Lock() + defer c.mu.Unlock() + if c.session != nil { + switch status := c.Status(); status { + case Connected: + return ErrTunnelAlreadyConnected + case Restarting, Connecting, Disconnecting: + return fmt.Errorf("session is currently %s", status) + case Disconnected, ErrorStatus: + c.session = nil + default: + return fmt.Errorf("session is in unexpected state: %s", status) + } + } + + options, err := buildOptions(boxOptions) + if err != nil { + return traces.RecordError(ctx, fmt.Errorf("failed to build options: %w", err)) + } + return traces.RecordError(ctx, c.start(ctx, boxOptions.BasePath, options, false, boxOptions.URLTestSeed)) +} + +func (c *VPNClient) Disconnect() error { + ctx, span := otel.Tracer(tracerName).Start(context.Background(), "disconnect") + defer span.End() + c.mu.Lock() + defer c.mu.Unlock() + if c.session == nil { + return nil + } + c.logger.Info("Disconnecting session") + return traces.RecordError(ctx, c.close()) +} + +func (c *VPNClient) start(ctx context.Context, path string, options option.Options, isRestart bool, urlTestSeed map[string]adapter.URLTestHistory) error { + c.logger.Debug("Starting session") + c.setStatus(Connecting, nil) + s := proxySession{urlTestSeed: urlTestSeed} + if err := s.start(ctx, path, options, isRestart); err != nil { + c.setStatus(ErrorStatus, err) + return err + } + c.session = &s + c.setStatus(Connected, nil) + return nil +} + +func (c *VPNClient) close() error { + s := c.session + c.session = nil + + c.logger.Info("Closing session") + c.setStatus(Disconnecting, nil) + if err := s.close(); err != nil { + c.setStatus(ErrorStatus, err) + return err + } + c.setStatus(Disconnected, nil) + if c.platformIfce != nil { + c.platformIfce.PostServiceClose() + } + c.logger.Debug("Session closed") + runtime.GC() + return nil +} + +func (c *VPNClient) Restart(boxOptions BoxOptions) error { + ctx, span := otel.Tracer(tracerName).Start(context.Background(), "client.restart") + defer span.End() + + c.mu.Lock() + if c.session == nil || c.Status() != Connected { + c.mu.Unlock() + return ErrTunnelNotConnected + } + c.setStatus(Restarting, nil) + c.logger.Info("Restarting session") + if c.platformIfce != nil { + span.SetAttributes(attribute.String("path", "platform_ifce")) + c.mu.Unlock() + if err := c.platformIfce.RestartService(); err != nil { + c.setStatus(ErrorStatus, err) + return traces.RecordError(ctx, fmt.Errorf("platform restart failed: %w", err)) + } + c.logger.Info("Session restarted") + return nil + } + span.SetAttributes(attribute.String("path", "direct")) + defer c.mu.Unlock() + if err := c.close(); err != nil { + return traces.RecordError(ctx, fmt.Errorf("closing session: %w", err)) + } + options, err := buildOptions(boxOptions) + if err != nil { + c.setStatus(ErrorStatus, err) + return traces.RecordError(ctx, fmt.Errorf("failed to build options: %w", err)) + } + if err := c.start(ctx, boxOptions.BasePath, options, true, boxOptions.URLTestSeed); err != nil { + return traces.RecordError(ctx, fmt.Errorf("starting session: %w", err)) + } + c.logger.Info("Session restarted") + return nil +} + +func (c *VPNClient) isOpen() bool { + return c.Status() == Connected +} + +func (c *VPNClient) Status() VPNStatus { + s, _ := c.status.Load().(VPNStatus) + return s +} + +func (c *VPNClient) setStatus(s VPNStatus, err error) { + if cur, _ := c.status.Load().(VPNStatus); cur == Restarting && s != Connected && s != ErrorStatus { + return + } + c.status.Store(s) + c.logger.Info("[vpn-state-trace]", "hop", "daemon_setstatus", "status", s, "ts_ms", time.Now().UnixMilli()) + evt := StatusUpdateEvent{Status: s} + if err != nil { + evt.Error = err.Error() + } + events.Emit(evt) +} + +func (c *VPNClient) HistoryStorage() adapter.URLTestHistoryStorage { + c.mu.RLock() + defer c.mu.RUnlock() + if c.session == nil { + return nil + } + return c.session.history +} + +func (c *VPNClient) SelectServer(tag string) error { + c.mu.RLock() + defer c.mu.RUnlock() + if c.session == nil || c.Status() != Connected { + return ErrTunnelNotConnected + } + if tag == AutoSelectTag || tag == "" { + return c.session.selectMode(AutoSelectTag) + } + c.logger.Info("Selecting server", "tag", tag) + return c.session.selectOutbound(tag) +} + +// Live outbound mutation is intentionally not implemented in this build path. +// The noVPN client runs a reduced sing-box proxy session without the libbox +// tunnel helpers used by the default build for add/update/remove. Config or +// custom-server changes are accepted in storage and take effect on the next +// Connect/Restart, while the active proxy keeps the options it started with. +func (c *VPNClient) UpdateOutbounds(servers.ServerList) error { + c.mu.RLock() + defer c.mu.RUnlock() + if c.session == nil { + return ErrTunnelNotConnected + } + return nil +} + +func (c *VPNClient) AddOutbounds(servers.ServerList) error { + c.mu.RLock() + defer c.mu.RUnlock() + if c.session == nil { + return ErrTunnelNotConnected + } + return nil +} + +func (c *VPNClient) RemoveOutbounds([]string) error { + c.mu.RLock() + defer c.mu.RUnlock() + if c.session == nil { + return ErrTunnelNotConnected + } + return nil +} + +func (c *VPNClient) Connections() ([]Connection, error) { + c.mu.RLock() + defer c.mu.RUnlock() + if c.session == nil { + return nil, fmt.Errorf("failed to get connections: %w", ErrTunnelNotConnected) + } + tm := c.session.clashServer.TrafficManager() + activeConns := tm.Connections() + closedConns := tm.ClosedConnections() + connections := make([]Connection, 0, len(activeConns)+len(closedConns)) + for _, conn := range activeConns { + connections = append(connections, newConnection(conn)) + } + for _, conn := range closedConns { + connections = append(connections, newConnection(conn)) + } + return connections, nil +} + +func (c *VPNClient) Bytes() (up, down int64, ok bool) { + c.mu.RLock() + defer c.mu.RUnlock() + if c.session == nil { + return 0, 0, false + } + up, down = c.session.clashServer.TrafficManager().Total() + return up, down, true +} + +func (c *VPNClient) Throughput() (ThroughputSnapshot, error) { + c.mu.RLock() + defer c.mu.RUnlock() + if c.session == nil { + return ThroughputSnapshot{}, ErrTunnelNotConnected + } + tt := c.session.clashServer.ThroughputTracker() + tm := c.session.clashServer.TrafficManager() + active := tm.Connections() + perOut := make(map[string]int, len(active)) + for _, m := range active { + perOut[m.Outbound]++ + } + return ThroughputSnapshot{ + Global: tt.Global(), + PerOutbound: tt.PerOutbound(), + ActiveConnections: len(active), + ActivePerOutbound: perOut, + }, nil +} + +type AutoSelectedEvent struct { + events.Event + Selected string `json:"selected"` +} + +func (c *VPNClient) CurrentAutoSelectedServer() (string, error) { + if !c.isOpen() { + return "", nil + } + c.mu.RLock() + defer c.mu.RUnlock() + if c.session == nil { + return "", ErrTunnelNotConnected + } + outbound, loaded := c.session.outboundMgr.Outbound(AutoSelectTag) + if !loaded { + return "", fmt.Errorf("auto select group not found") + } + return outbound.(adapter.OutboundGroup).Now(), nil +} + +func (c *VPNClient) CurrentSelectedServer() (string, error) { + if !c.isOpen() { + return "", nil + } + c.mu.RLock() + defer c.mu.RUnlock() + if c.session == nil { + return "", ErrTunnelNotConnected + } + mode := c.session.clashServer.Mode() + outbound, loaded := c.session.outboundMgr.Outbound(mode) + if !loaded { + return "", fmt.Errorf("%s group not found", mode) + } + return outbound.(adapter.OutboundGroup).Now(), nil +} + +func (c *VPNClient) AutoSelectedChangeListener(ctx context.Context) { + go func() { + var prev string + tick := time.NewTicker(10 * time.Second) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + curr, err := c.CurrentAutoSelectedServer() + if err != nil { + continue + } + if curr != prev { + prev = curr + events.Emit(AutoSelectedEvent{Selected: curr}) + } + } + } + }() +} + +func (c *VPNClient) RunOfflineURLTests(string, []option.Outbound, map[string]string) (map[string]uint16, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.session != nil { + return nil, ErrTunnelAlreadyConnected + } + return map[string]uint16{}, nil +} + +func AttemptFixNetState() {} + +func (s *proxySession) start(ctx context.Context, path string, options option.Options, isRestart bool) (err error) { + encodedOptions, _ := sbjson.Marshal(options) + ctx, span := otel.Tracer(tracerName).Start(ctx, "session.start", + trace.WithAttributes( + attribute.Int("options_size", len(encodedOptions)), + attribute.String("platform", common.Platform), + attribute.Bool("is_restart", isRestart), + )) + defer span.End() + defer func() { + if err != nil { + err = errors.Join(err, s.close()) + } + }() + + baseCtx := lbAdapter.ContextWithDirectTransport(boxctx.BaseContext(), streamingRoundTripper{inner: kindling.HTTPClient().Transport}) + s.ctx, s.cancel = context.WithCancel(baseCtx) + s.ctx = filemanager.WithDefault(s.ctx, path, filepath.Join(path, "temp"), os.Getuid(), os.Getgid()) + experimental.RegisterClashServerConstructor(newClashServer) + history := urltest.NewHistoryStorage() + for tag, h := range s.urlTestSeed { + history.StoreURLTestHistory(tag, &h) + } + service.MustRegister[adapter.URLTestHistoryStorage](s.ctx, history) + s.history = history + s.closeResources = append(s.closeResources, history) + + instance, err := sbox.New(sbox.Options{ + Context: s.ctx, + Options: options, + }) + if err != nil { + return fmt.Errorf("create session service: %w", err) + } + s.instance = instance + s.closeResources = append(s.closeResources, instance) + if err := instance.Start(); err != nil { + return fmt.Errorf("starting session service: %w", err) + } + clash, _ := service.FromContext[adapter.ClashServer](s.ctx).(*clashServer) + if clash == nil { + return errors.New("session state service unavailable") + } + s.clashServer = clash + s.outboundMgr = instance.Outbound() + return nil +} + +func (s *proxySession) selectMode(mode string) error { + if s.instance == nil { + return ErrTunnelNotConnected + } + return s.clashServer.SetMode(mode) +} + +func (s *proxySession) selectOutbound(tag string) error { + if err := s.selectMode(ManualSelectTag); err != nil { + return err + } + outbound, loaded := s.outboundMgr.Outbound(ManualSelectTag) + if !loaded { + return fmt.Errorf("manual select group not found") + } + outbound.(Selector).SelectOutbound(tag) + return nil +} + +func (s *proxySession) close() error { + if s.cancel != nil { + s.cancel() + } + var errs []error + for i := len(s.closeResources) - 1; i >= 0; i-- { + errs = append(errs, s.closeResources[i].Close()) + } + s.closeResources = nil + s.instance = nil + return errors.Join(errs...) +} + +type streamingRoundTripper struct { + inner http.RoundTripper +} + +func (s streamingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Header.Get("Accept") == "" { + req = req.Clone(req.Context()) + req.Header.Set("Accept", "text/event-stream") + } + return s.inner.RoundTrip(req) +} diff --git a/vpn/vpn_test.go b/vpn/vpn_test.go index eca0c09d..05a0443a 100644 --- a/vpn/vpn_test.go +++ b/vpn/vpn_test.go @@ -1,3 +1,5 @@ +//go:build !stealth_novpn + package vpn import (