diff --git a/cmd/sing-box/cmd_api.go b/cmd/sing-box/cmd_api.go new file mode 100644 index 00000000..dea0073d --- /dev/null +++ b/cmd/sing-box/cmd_api.go @@ -0,0 +1,107 @@ +package main + +import ( + "os" + "strings" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var ( + commandAPIFlagURL string + commandAPIFlagSecret string + commandAPIServerURL string +) + +var commandAPI = &cobra.Command{ + Use: "api ", + Short: "API service client", + DisableFlagParsing: true, + Run: func(cmd *cobra.Command, args []string) { + err := runAPI(args) + if err != nil { + log.Fatal(err) + } + }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + targetCommand, remainingArgs, err := commandAPIRoot.Find(args) + if err != nil || len(remainingArgs) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return common.Map(common.Filter(targetCommand.Commands(), func(it *cobra.Command) bool { + return it.IsAvailableCommand() && strings.HasPrefix(it.Name(), toComplete) + }), func(it *cobra.Command) string { + return it.Name() + "\t" + it.Short + }), cobra.ShellCompDirectiveNoFileComp + }, +} + +var commandAPIRoot = &cobra.Command{ + Use: "api", + Short: "API service client", + SilenceUsage: true, + SilenceErrors: true, + CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, +} + +func init() { + commandAPIRoot.PersistentFlags().StringVar(&commandAPIFlagURL, "url", "", "API service URL (default: $BOX_API_URL)") + commandAPIRoot.PersistentFlags().StringVar(&commandAPIFlagSecret, "secret", "", "API service secret (default: $BOX_API_SECRET)") + mainCommand.AddCommand(commandAPI) +} + +func runAPI(args []string) error { + commandAPIRoot.SetArgs(append([]string{}, args...)) + err := commandAPIRoot.Execute() + if err == nil { + return nil + } + grpcStatus, isStatus := status.FromError(err) + if !isStatus { + return err + } + switch grpcStatus.Code() { + case codes.Unavailable: + return E.New("failed to connect to API service at ", commandAPIServerURL, ": ", grpcStatus.Message()) + case codes.Unknown: + return E.New(grpcStatus.Message()) + case codes.Unimplemented: + return E.New(grpcStatus.Code().String(), ": ", grpcStatus.Message(), " (client API version ", daemon.APIVersion, ")") + default: + return E.New(grpcStatus.Code().String(), ": ", grpcStatus.Message()) + } +} + +func createAPIClient() (*grpc.ClientConn, daemon.StartedServiceClient, error) { + serverURL := commandAPIFlagURL + if serverURL == "" { + serverURL = os.Getenv("BOX_API_URL") + } + if serverURL == "" { + return nil, nil, E.New("missing API service URL, set --url or BOX_API_URL") + } + if !strings.Contains(serverURL, "://") { + serverURL = "http://" + serverURL + } + commandAPIServerURL = serverURL + secret := commandAPIFlagSecret + if secret == "" { + secret = os.Getenv("BOX_API_SECRET") + } + clientConn, err := daemon.NewRemoteClient(daemon.RemoteClientOptions{ + ServerURL: serverURL, + Secret: secret, + }) + if err != nil { + return nil, nil, err + } + return clientConn, daemon.NewStartedServiceClient(clientConn), nil +} diff --git a/cmd/sing-box/cmd_api_connection.go b/cmd/sing-box/cmd_api_connection.go new file mode 100644 index 00000000..80b1f7d4 --- /dev/null +++ b/cmd/sing-box/cmd_api_connection.go @@ -0,0 +1,37 @@ +package main + +import ( + "time" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing/common" + + "github.com/spf13/cobra" +) + +var commandAPIConnection = &cobra.Command{ + Use: "connection", + Short: "Manage connections", +} + +func init() { + commandAPIRoot.AddCommand(commandAPIConnection) +} + +func fetchConnections(client daemon.StartedServiceClient) ([]*daemon.Connection, error) { + stream, err := client.SubscribeConnections(globalCtx, &daemon.SubscribeConnectionsRequest{Interval: int64(time.Second)}) + if err != nil { + return nil, err + } + events, err := stream.Recv() + if err != nil { + return nil, err + } + connections := common.FilterNotNil(common.Map(events.GetEvents(), func(it *daemon.ConnectionEvent) *daemon.Connection { + return it.GetConnection() + })) + common.SortBy(connections, func(it *daemon.Connection) int64 { + return it.GetCreatedAt() + }) + return connections, nil +} diff --git a/cmd/sing-box/cmd_api_connection_close.go b/cmd/sing-box/cmd_api_connection_close.go new file mode 100644 index 00000000..892a0550 --- /dev/null +++ b/cmd/sing-box/cmd_api_connection_close.go @@ -0,0 +1,47 @@ +package main + +import ( + "github.com/sagernet/sing-box/daemon" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" +) + +var commandAPIConnectionCloseFlagAll bool + +var commandAPIConnectionClose = &cobra.Command{ + Use: "close ", + Short: "Close connections", + Long: "Close connections.\n\nThe id must be a full UUID; the service reports success for an unknown or already closed connection.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIConnectionClose(args) + }, +} + +func init() { + commandAPIConnectionClose.Flags().BoolVar(&commandAPIConnectionCloseFlagAll, "all", false, "Close all connections") + commandAPIConnection.AddCommand(commandAPIConnectionClose) +} + +func runAPIConnectionClose(args []string) error { + if commandAPIConnectionCloseFlagAll { + if len(args) > 0 { + return E.New("--all takes no connection id") + } + } else if len(args) == 0 { + return E.New("missing connection id") + } + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + if commandAPIConnectionCloseFlagAll { + _, err = client.CloseAllConnections(globalCtx, &emptypb.Empty{}) + } else { + _, err = client.CloseConnection(globalCtx, &daemon.CloseConnectionRequest{Id: args[0]}) + } + return err +} diff --git a/cmd/sing-box/cmd_api_connection_list.go b/cmd/sing-box/cmd_api_connection_list.go new file mode 100644 index 00000000..28595701 --- /dev/null +++ b/cmd/sing-box/cmd_api_connection_list.go @@ -0,0 +1,201 @@ +package main + +import ( + "slices" + "strings" + "time" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/byteformats" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var commandAPIConnectionListFlagColumns []string + +type connectionRate struct { + uplink int64 + downlink int64 +} + +type connectionColumn struct { + header string + value func(connection *daemon.Connection, rates map[string]connectionRate) string +} + +var connectionColumnNames = []string{ + "id", "network", "source", "destination", "inbound", "outbound", + "chain", "rule", "protocol", "user", "process", "created", "rate", "total", +} + +var connectionColumns = map[string]connectionColumn{ + "id": {"ID", func(connection *daemon.Connection, _ map[string]connectionRate) string { + return connection.GetId() + }}, + "network": {"NETWORK", func(connection *daemon.Connection, _ map[string]connectionRate) string { + return connection.GetNetwork() + }}, + "source": {"SOURCE", func(connection *daemon.Connection, _ map[string]connectionRate) string { + return connection.GetSource() + }}, + "destination": {"DESTINATION", func(connection *daemon.Connection, _ map[string]connectionRate) string { + return connectionDestination(connection) + }}, + "inbound": {"INBOUND", func(connection *daemon.Connection, _ map[string]connectionRate) string { + if connection.GetInbound() == "" { + return connection.GetInboundType() + } + return connection.GetInboundType() + "/" + connection.GetInbound() + }}, + "outbound": {"OUTBOUND", func(connection *daemon.Connection, _ map[string]connectionRate) string { + return connection.GetOutbound() + }}, + "chain": {"CHAIN", func(connection *daemon.Connection, _ map[string]connectionRate) string { + chain := slices.Clone(connection.GetChainList()) + slices.Reverse(chain) + return strings.Join(chain, "/") + }}, + "rule": {"RULE", func(connection *daemon.Connection, _ map[string]connectionRate) string { + return connection.GetRule() + }}, + "protocol": {"PROTOCOL", func(connection *daemon.Connection, _ map[string]connectionRate) string { + return connection.GetProtocol() + }}, + "user": {"USER", func(connection *daemon.Connection, _ map[string]connectionRate) string { + return connection.GetUser() + }}, + "process": {"PROCESS", func(connection *daemon.Connection, _ map[string]connectionRate) string { + processInfo := connection.GetProcessInfo() + if processInfo.GetProcessPath() != "" { + return processInfo.GetProcessPath() + } + if len(processInfo.GetPackageNames()) > 0 { + return processInfo.GetPackageNames()[0] + } + return "" + }}, + "created": {"CREATED", func(connection *daemon.Connection, _ map[string]connectionRate) string { + return formatConnectionTime(connection.GetCreatedAt()) + }}, + "rate": {"RATE", func(connection *daemon.Connection, rates map[string]connectionRate) string { + rate, found := rates[connection.GetId()] + if !found || (rate.uplink == 0 && rate.downlink == 0) { + return "" + } + return "↑" + byteformats.FormatBytes(uint64(rate.uplink)) + "/s ↓" + byteformats.FormatBytes(uint64(rate.downlink)) + "/s" + }}, + "total": {"TOTAL", func(connection *daemon.Connection, _ map[string]connectionRate) string { + return "↑" + byteformats.FormatBytes(uint64(connection.GetUplinkTotal())) + " ↓" + byteformats.FormatBytes(uint64(connection.GetDownlinkTotal())) + }}, +} + +var commandAPIConnectionList = &cobra.Command{ + Use: "list", + Short: "List open connections", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIConnectionList() + }, +} + +func init() { + commandAPIConnectionList.Flags().StringSliceVar( + &commandAPIConnectionListFlagColumns, + "columns", + []string{"id", "network", "destination", "inbound", "outbound", "total"}, + "Columns to display (available: "+strings.Join(connectionColumnNames, ", ")+")", + ) + commandAPIConnection.AddCommand(commandAPIConnectionList) +} + +func runAPIConnectionList() error { + columns := make([]connectionColumn, 0, len(commandAPIConnectionListFlagColumns)) + sampleRates := false + for _, name := range commandAPIConnectionListFlagColumns { + column, found := connectionColumns[name] + if !found { + return E.New("unknown column: ", name, ", available: ", strings.Join(connectionColumnNames, ", ")) + } + if name == "rate" { + sampleRates = true + } + columns = append(columns, column) + } + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + var ( + connections []*daemon.Connection + rates map[string]connectionRate + ) + if sampleRates { + connections, rates, err = fetchConnectionsAndRates(client) + } else { + connections, err = fetchConnections(client) + } + if err != nil { + return err + } + table := tableWriter{ + header: common.Map(columns, func(it connectionColumn) string { + return it.header + }), + emptyMessage: "no connections", + } + for _, connection := range connections { + if connection.GetClosedAt() != 0 { + continue + } + table.addRow(common.Map(columns, func(it connectionColumn) string { + return it.value(connection, rates) + })...) + } + table.flush() + return nil +} + +func connectionDestination(connection *daemon.Connection) string { + destination := connection.GetDestination() + domain := connection.GetDomain() + if domain == "" { + return destination + } + portIndex := strings.LastIndex(destination, ":") + if portIndex == -1 { + return domain + } + return domain + destination[portIndex:] +} + +func fetchConnectionsAndRates(client daemon.StartedServiceClient) ([]*daemon.Connection, map[string]connectionRate, error) { + stream, err := client.SubscribeConnections(globalCtx, &daemon.SubscribeConnectionsRequest{Interval: int64(time.Second)}) + if err != nil { + return nil, nil, err + } + initialEvents, err := stream.Recv() + if err != nil { + return nil, nil, err + } + connections := common.FilterNotNil(common.Map(initialEvents.GetEvents(), func(it *daemon.ConnectionEvent) *daemon.Connection { + return it.GetConnection() + })) + common.SortBy(connections, func(it *daemon.Connection) int64 { + return it.GetCreatedAt() + }) + updateEvents, err := stream.Recv() + if err != nil { + return nil, nil, err + } + rates := make(map[string]connectionRate, len(updateEvents.GetEvents())) + for _, event := range updateEvents.GetEvents() { + rates[event.GetId()] = connectionRate{ + uplink: event.GetUplinkDelta(), + downlink: event.GetDownlinkDelta(), + } + } + return connections, rates, nil +} diff --git a/cmd/sing-box/cmd_api_connection_show.go b/cmd/sing-box/cmd_api_connection_show.go new file mode 100644 index 00000000..1bffe3d4 --- /dev/null +++ b/cmd/sing-box/cmd_api_connection_show.go @@ -0,0 +1,112 @@ +package main + +import ( + "strings" + "time" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/byteformats" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + + "github.com/spf13/cobra" +) + +var commandAPIConnectionShow = &cobra.Command{ + Use: "show ", + Short: "Print connection details", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIConnectionShow(args[0]) + }, +} + +func init() { + commandAPIConnection.AddCommand(commandAPIConnectionShow) +} + +func runAPIConnectionShow(connectionID string) error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + connections, err := fetchConnections(client) + if err != nil { + return err + } + connection := common.Find(connections, func(it *daemon.Connection) bool { + return it.GetId() == connectionID + }) + if connection == nil { + return E.New("connection not found: ", connectionID) + } + state := "open" + if connection.GetClosedAt() != 0 { + state = "closed" + } + var ipVersion string + if connection.GetIpVersion() != 0 { + ipVersion = F.ToString(connection.GetIpVersion()) + } + inbound := connection.GetInboundType() + if connection.GetInbound() != "" { + inbound = connection.GetInboundType() + "/" + connection.GetInbound() + } + outbound := connection.GetOutbound() + if outbound != "" && connection.GetOutboundType() != "" { + outbound = F.ToString(outbound, " (", connection.GetOutboundType(), ")") + } + var block blockWriter + block.addLine("ID", connection.GetId()) + block.addLine("State", state) + block.addLine("Created", formatConnectionTime(connection.GetCreatedAt())) + block.addLine("Closed", formatConnectionTime(connection.GetClosedAt())) + block.addLine("Network", connection.GetNetwork()) + block.addLine("IP version", ipVersion) + block.addLine("Protocol", connection.GetProtocol()) + block.addLine("Inbound", inbound) + block.addLine("Source", connection.GetSource()) + block.addLine("Destination", connection.GetDestination()) + block.addLine("Domain", connection.GetDomain()) + block.addLine("User", connection.GetUser()) + block.addLine("Process", formatProcessInfo(connection.GetProcessInfo())) + block.addLine("Rule", connection.GetRule()) + block.addLine("Outbound", outbound) + block.addLine("Chain", strings.Join(connection.GetChainList(), " <- ")) + block.addLine("From outbound", connection.GetFromOutbound()) + block.addLine("Uplink", byteformats.FormatBytes(uint64(connection.GetUplinkTotal()))) + block.addLine("Downlink", byteformats.FormatBytes(uint64(connection.GetDownlinkTotal()))) + block.flush() + return nil +} + +func formatConnectionTime(timestamp int64) string { + if timestamp == 0 { + return "" + } + return time.UnixMilli(timestamp).Local().Format(time.RFC3339) +} + +func formatProcessInfo(processInfo *daemon.ProcessInfo) string { + if processInfo == nil { + return "" + } + var process string + if processInfo.GetProcessPath() != "" { + process = processInfo.GetProcessPath() + } else if len(processInfo.GetPackageNames()) > 0 { + process = processInfo.GetPackageNames()[0] + } + if process == "" { + if processInfo.GetUserId() != -1 { + process = F.ToString(processInfo.GetUserId()) + } + } else if processInfo.GetUserName() != "" { + process = F.ToString(process, " (", processInfo.GetUserName(), ")") + } else if processInfo.GetUserId() != -1 { + process = F.ToString(process, " (", processInfo.GetUserId(), ")") + } + return process +} diff --git a/cmd/sing-box/cmd_api_group.go b/cmd/sing-box/cmd_api_group.go new file mode 100644 index 00000000..6e0b63f2 --- /dev/null +++ b/cmd/sing-box/cmd_api_group.go @@ -0,0 +1,29 @@ +package main + +import ( + "github.com/sagernet/sing-box/daemon" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" +) + +var commandAPIGroup = &cobra.Command{ + Use: "group", + Short: "Manage outbound groups", +} + +func init() { + commandAPIRoot.AddCommand(commandAPIGroup) +} + +func fetchGroups(client daemon.StartedServiceClient) ([]*daemon.Group, error) { + stream, err := client.SubscribeGroups(globalCtx, &emptypb.Empty{}) + if err != nil { + return nil, err + } + groups, err := stream.Recv() + if err != nil { + return nil, err + } + return groups.GetGroup(), nil +} diff --git a/cmd/sing-box/cmd_api_group_list.go b/cmd/sing-box/cmd_api_group_list.go new file mode 100644 index 00000000..2366623e --- /dev/null +++ b/cmd/sing-box/cmd_api_group_list.go @@ -0,0 +1,39 @@ +package main + +import ( + "github.com/spf13/cobra" +) + +var commandAPIGroupList = &cobra.Command{ + Use: "list", + Short: "List outbound groups", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIGroupList() + }, +} + +func init() { + commandAPIGroup.AddCommand(commandAPIGroupList) +} + +func runAPIGroupList() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + groups, err := fetchGroups(client) + if err != nil { + return err + } + table := tableWriter{ + header: []string{"TAG", "TYPE", "SELECTED"}, + emptyMessage: "no groups", + } + for _, group := range groups { + table.addRow(group.GetTag(), group.GetType(), group.GetSelected()) + } + table.flush() + return nil +} diff --git a/cmd/sing-box/cmd_api_group_select.go b/cmd/sing-box/cmd_api_group_select.go new file mode 100644 index 00000000..7ea725c8 --- /dev/null +++ b/cmd/sing-box/cmd_api_group_select.go @@ -0,0 +1,33 @@ +package main + +import ( + "github.com/sagernet/sing-box/daemon" + + "github.com/spf13/cobra" +) + +var commandAPIGroupSelect = &cobra.Command{ + Use: "select ", + Short: "Select an outbound in a group", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIGroupSelect(args[0], args[1]) + }, +} + +func init() { + commandAPIGroup.AddCommand(commandAPIGroupSelect) +} + +func runAPIGroupSelect(groupTag string, outboundTag string) error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + _, err = client.SelectOutbound(globalCtx, &daemon.SelectOutboundRequest{ + GroupTag: groupTag, + OutboundTag: outboundTag, + }) + return err +} diff --git a/cmd/sing-box/cmd_api_group_show.go b/cmd/sing-box/cmd_api_group_show.go new file mode 100644 index 00000000..86de1fea --- /dev/null +++ b/cmd/sing-box/cmd_api_group_show.go @@ -0,0 +1,51 @@ +package main + +import ( + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var commandAPIGroupShow = &cobra.Command{ + Use: "show ", + Short: "Show an outbound group", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIGroupShow(args[0]) + }, +} + +func init() { + commandAPIGroup.AddCommand(commandAPIGroupShow) +} + +func runAPIGroupShow(groupTag string) error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + groups, err := fetchGroups(client) + if err != nil { + return err + } + for _, group := range groups { + if group.GetTag() != groupTag { + continue + } + block := blockWriter{} + block.addLine("Tag", group.GetTag()) + block.addLine("Type", group.GetType()) + block.addLine("Selected", group.GetSelected()) + block.flush() + table := tableWriter{ + header: []string{"TAG", "TYPE", "DELAY"}, + } + for _, item := range group.GetItems() { + table.addRow(item.GetTag(), item.GetType(), formatDelay(item.GetUrlTestDelay())) + } + table.flush() + return nil + } + return E.New("group not found: ", groupTag) +} diff --git a/cmd/sing-box/cmd_api_group_urltest.go b/cmd/sing-box/cmd_api_group_urltest.go new file mode 100644 index 00000000..2dbc59fb --- /dev/null +++ b/cmd/sing-box/cmd_api_group_urltest.go @@ -0,0 +1,31 @@ +package main + +import ( + "github.com/sagernet/sing-box/daemon" + + "github.com/spf13/cobra" +) + +var commandAPIGroupURLTest = &cobra.Command{ + Use: "urltest ", + Short: "Start a URL test", + Long: "Start a URL test.\n\nThe tests are only spawned: results appear in `outbounds --group ` a few seconds later.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIGroupURLTest(args[0]) + }, +} + +func init() { + commandAPIGroup.AddCommand(commandAPIGroupURLTest) +} + +func runAPIGroupURLTest(groupTag string) error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + _, err = client.URLTest(globalCtx, &daemon.URLTestRequest{OutboundTag: groupTag}) + return err +} diff --git a/cmd/sing-box/cmd_api_logs.go b/cmd/sing-box/cmd_api_logs.go new file mode 100644 index 00000000..5e196c90 --- /dev/null +++ b/cmd/sing-box/cmd_api_logs.go @@ -0,0 +1,96 @@ +package main + +import ( + "os" + "os/signal" + "strings" + "syscall" + + "github.com/sagernet/sing-box/log" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" +) + +var ( + commandAPILogsFlagFollow bool + commandAPILogsFlagLevel string + commandAPILogsFlagSearch string +) + +var commandAPILogs = &cobra.Command{ + Use: "logs", + Short: "Print the service logs", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPILogs() + }, +} + +func init() { + commandAPILogs.Flags().BoolVarP(&commandAPILogsFlagFollow, "follow", "f", false, "Keep printing new log entries until interrupted") + commandAPILogs.Flags().StringVar(&commandAPILogsFlagLevel, "level", "", "Print entries at this level or more severe (default: the service log level)") + commandAPILogs.Flags().StringVar(&commandAPILogsFlagSearch, "search", "", "Print entries containing this text, case-insensitive") + commandAPIRoot.AddCommand(commandAPILogs) +} + +func runAPILogs() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + ctx, cancel := signal.NotifyContext(globalCtx, os.Interrupt, syscall.SIGTERM) + defer cancel() + var level log.Level + if commandAPILogsFlagLevel != "" { + level, err = log.ParseLevel(commandAPILogsFlagLevel) + if err != nil { + return err + } + } else { + defaultLevel, levelErr := client.GetDefaultLogLevel(ctx, &emptypb.Empty{}) + if levelErr != nil { + return levelErr + } + level = log.Level(defaultLevel.GetLevel()) + } + stream, err := client.SubscribeLog(ctx, &emptypb.Empty{}) + if err != nil { + return err + } + searchQuery := strings.ToLower(strings.TrimSpace(commandAPILogsFlagSearch)) + for backlog := true; ; backlog = false { + message, recvErr := stream.Recv() + if recvErr != nil { + if ctx.Err() != nil { + return nil + } + return recvErr + } + if message.GetReset_() && len(message.GetMessages()) == 0 && !backlog { + writeStderrLine("log buffer cleared") + continue + } + var output strings.Builder + for _, entry := range message.GetMessages() { + if log.Level(entry.GetLevel()) > level { + continue + } + plainMessage := stripColors(entry.GetMessage()) + if searchQuery != "" && !strings.Contains(strings.ToLower(plainMessage), searchQuery) { + continue + } + if stdoutIsTerminal { + output.WriteString(entry.GetMessage()) + } else { + output.WriteString(plainMessage) + } + output.WriteString("\n") + } + os.Stdout.WriteString(output.String()) + if backlog && !commandAPILogsFlagFollow { + return nil + } + } +} diff --git a/cmd/sing-box/cmd_api_mode.go b/cmd/sing-box/cmd_api_mode.go new file mode 100644 index 00000000..e8b4da63 --- /dev/null +++ b/cmd/sing-box/cmd_api_mode.go @@ -0,0 +1,39 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" +) + +var commandAPIMode = &cobra.Command{ + Use: "mode", + Short: "Print the current clash mode", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIMode() + }, +} + +func init() { + commandAPIRoot.AddCommand(commandAPIMode) +} + +func runAPIMode() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + modeStatus, err := client.GetClashModeStatus(globalCtx, &emptypb.Empty{}) + if err != nil { + return err + } + currentMode := modeStatus.GetCurrentMode() + if currentMode == "" { + currentMode = "-" + } + os.Stdout.WriteString(currentMode + "\n") + return nil +} diff --git a/cmd/sing-box/cmd_api_mode_list.go b/cmd/sing-box/cmd_api_mode_list.go new file mode 100644 index 00000000..17badee4 --- /dev/null +++ b/cmd/sing-box/cmd_api_mode_list.go @@ -0,0 +1,45 @@ +package main + +import ( + "os" + "strings" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" +) + +var commandAPIModeList = &cobra.Command{ + Use: "list", + Short: "List clash modes", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIModeList() + }, +} + +func init() { + commandAPIMode.AddCommand(commandAPIModeList) +} + +func runAPIModeList() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + modeStatus, err := client.GetClashModeStatus(globalCtx, &emptypb.Empty{}) + if err != nil { + return err + } + if len(modeStatus.GetModeList()) == 0 { + writeStderrLine("no clash modes") + return nil + } + var output strings.Builder + for _, mode := range modeStatus.GetModeList() { + output.WriteString(mode) + output.WriteString("\n") + } + os.Stdout.WriteString(output.String()) + return nil +} diff --git a/cmd/sing-box/cmd_api_mode_set.go b/cmd/sing-box/cmd_api_mode_set.go new file mode 100644 index 00000000..025e3b31 --- /dev/null +++ b/cmd/sing-box/cmd_api_mode_set.go @@ -0,0 +1,31 @@ +package main + +import ( + "github.com/sagernet/sing-box/daemon" + + "github.com/spf13/cobra" +) + +var commandAPIModeSet = &cobra.Command{ + Use: "set ", + Short: "Set the clash mode", + Long: "Set the clash mode.\n\nThe value is not validated against the mode list: setting an unknown mode reports success.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIModeSet(args[0]) + }, +} + +func init() { + commandAPIMode.AddCommand(commandAPIModeSet) +} + +func runAPIModeSet(mode string) error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + _, err = client.SetClashMode(globalCtx, &daemon.ClashMode{Mode: mode}) + return err +} diff --git a/cmd/sing-box/cmd_api_networkquality.go b/cmd/sing-box/cmd_api_networkquality.go new file mode 100644 index 00000000..f0b69bb2 --- /dev/null +++ b/cmd/sing-box/cmd_api_networkquality.go @@ -0,0 +1,131 @@ +package main + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/sagernet/sing-box/common/networkquality" + "github.com/sagernet/sing-box/daemon" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var ( + commandAPINetworkQualityFlagConfigURL string + commandAPINetworkQualityFlagSerial bool + commandAPINetworkQualityFlagMaxRuntime int + commandAPINetworkQualityFlagHTTP3 bool + commandAPINetworkQualityFlagOutbound string +) + +var commandAPINetworkQuality = &cobra.Command{ + Use: "networkquality", + Short: "Run a network quality test", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPINetworkQuality() + }, +} + +func init() { + commandAPINetworkQuality.Flags().StringVar( + &commandAPINetworkQualityFlagConfigURL, + "config-url", "", + "Network quality test config URL (default: Apple mensura)", + ) + commandAPINetworkQuality.Flags().BoolVar( + &commandAPINetworkQualityFlagSerial, + "serial", false, + "Run download and upload tests sequentially instead of in parallel", + ) + commandAPINetworkQuality.Flags().IntVar( + &commandAPINetworkQualityFlagMaxRuntime, + "max-runtime", int(networkquality.DefaultMaxRuntime/time.Second), + "Network quality maximum runtime in seconds", + ) + commandAPINetworkQuality.Flags().BoolVar( + &commandAPINetworkQualityFlagHTTP3, + "http3", false, + "Use HTTP/3 (QUIC) for measurement traffic", + ) + commandAPINetworkQuality.Flags().StringVarP( + &commandAPINetworkQualityFlagOutbound, + "outbound", "o", "", + "Use specified tag instead of default outbound", + ) + commandAPIRoot.AddCommand(commandAPINetworkQuality) +} + +func runAPINetworkQuality() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + stream, err := client.StartNetworkQualityTest(globalCtx, &daemon.NetworkQualityTestRequest{ + ConfigURL: commandAPINetworkQualityFlagConfigURL, + OutboundTag: commandAPINetworkQualityFlagOutbound, + Serial: commandAPINetworkQualityFlagSerial, + MaxRuntimeSeconds: int32(commandAPINetworkQualityFlagMaxRuntime), + Http3: commandAPINetworkQualityFlagHTTP3, + }) + if err != nil { + return err + } + writeStderrLine("==== NETWORK QUALITY TEST ====") + for { + progress, recvErr := stream.Recv() + if recvErr != nil { + return recvErr + } + if !progress.GetIsFinal() { + writeNetworkQualityProgress(progress) + continue + } + writeStderrLine("") + if progress.GetError() != "" { + return E.New(progress.GetError()) + } + writeStderrLine(strings.Repeat("-", 40)) + fmt.Fprintf(os.Stdout, "Idle Latency: %d ms\n", progress.GetIdleLatencyMs()) + fmt.Fprintf(os.Stdout, "Download Capacity: %-20s Accuracy: %s\n", + networkquality.FormatBitrate(progress.GetDownloadCapacity()), + networkquality.Accuracy(progress.GetDownloadCapacityAccuracy())) + fmt.Fprintf(os.Stdout, "Upload Capacity: %-20s Accuracy: %s\n", + networkquality.FormatBitrate(progress.GetUploadCapacity()), + networkquality.Accuracy(progress.GetUploadCapacityAccuracy())) + fmt.Fprintf(os.Stdout, "Download Responsiveness: %-20s Accuracy: %s\n", + fmt.Sprintf("%d RPM", progress.GetDownloadRPM()), + networkquality.Accuracy(progress.GetDownloadRPMAccuracy())) + fmt.Fprintf(os.Stdout, "Upload Responsiveness: %-20s Accuracy: %s\n", + fmt.Sprintf("%d RPM", progress.GetUploadRPM()), + networkquality.Accuracy(progress.GetUploadRPMAccuracy())) + return nil + } +} + +func writeNetworkQualityProgress(progress *daemon.NetworkQualityTestProgress) { + if !commandAPINetworkQualityFlagSerial && networkquality.Phase(progress.GetPhase()) != networkquality.PhaseIdle { + writeProgress(fmt.Sprintf("Download: %s RPM: %d Upload: %s RPM: %d", + networkquality.FormatBitrate(progress.GetDownloadCapacity()), progress.GetDownloadRPM(), + networkquality.FormatBitrate(progress.GetUploadCapacity()), progress.GetUploadRPM())) + return + } + switch networkquality.Phase(progress.GetPhase()) { + case networkquality.PhaseIdle: + if progress.GetIdleLatencyMs() > 0 { + writeProgress(fmt.Sprintf("Idle Latency: %d ms", progress.GetIdleLatencyMs())) + } else { + writeProgress("Measuring idle latency...") + } + case networkquality.PhaseDownload: + writeProgress(fmt.Sprintf("Download: %s RPM: %d", + networkquality.FormatBitrate(progress.GetDownloadCapacity()), progress.GetDownloadRPM())) + case networkquality.PhaseUpload: + writeProgress(fmt.Sprintf("Upload: %s RPM: %d", + networkquality.FormatBitrate(progress.GetUploadCapacity()), progress.GetUploadRPM())) + } +} diff --git a/cmd/sing-box/cmd_api_openconnect.go b/cmd/sing-box/cmd_api_openconnect.go new file mode 100644 index 00000000..041b5faf --- /dev/null +++ b/cmd/sing-box/cmd_api_openconnect.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "errors" + "io" + + "github.com/sagernet/sing-box/daemon" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + + "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" +) + +var commandAPIOpenConnect = &cobra.Command{ + Use: "openconnect", + Short: "Manage OpenConnect authentication", +} + +func init() { + commandAPIRoot.AddCommand(commandAPIOpenConnect) +} + +func subscribeOpenConnectStatus(ctx context.Context, client daemon.StartedServiceClient) (grpc.ServerStreamingClient[daemon.OpenConnectStatusUpdate], []*daemon.OpenConnectEndpointStatus, error) { + stream, err := client.SubscribeOpenConnectStatus(ctx, &emptypb.Empty{}) + if err != nil { + return nil, nil, err + } + endpoints, err := recvOpenConnectStatus(stream) + if err != nil { + return nil, nil, err + } + return stream, endpoints, nil +} + +func recvOpenConnectStatus(stream grpc.ServerStreamingClient[daemon.OpenConnectStatusUpdate]) ([]*daemon.OpenConnectEndpointStatus, error) { + update, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + return nil, E.New("api service closed the status stream") + } + return nil, err + } + return update.GetEndpoints(), nil +} + +func openConnectChallengeSummary(challenge *daemon.OpenConnectAuthChallenge) string { + form := challenge.GetForm() + if form != nil { + return F.ToString("form (", len(form.GetFields()), " fields)") + } + browser := challenge.GetBrowser() + if browser == nil { + return "unknown" + } + mode, err := deriveOpenConnectBrowserMode(browser) + if err != nil { + return "browser (invalid)" + } + return "browser (" + mode + ")" +} diff --git a/cmd/sing-box/cmd_api_openconnect_auth.go b/cmd/sing-box/cmd_api_openconnect_auth.go new file mode 100644 index 00000000..12c67a5a --- /dev/null +++ b/cmd/sing-box/cmd_api_openconnect_auth.go @@ -0,0 +1,297 @@ +package main + +import ( + "context" + "errors" + "os" + "os/signal" + "slices" + "strconv" + "strings" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/daemon" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var ( + commandAPIOpenConnectAuthFlagEndpoint string + commandAPIOpenConnectAuthFlagCallbackPort uint16 +) + +var commandAPIOpenConnectAuth = &cobra.Command{ + Use: "auth", + Short: "Answer OpenConnect authentication challenges", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + err := runAPIOpenConnectAuth() + if errors.Is(err, errAuthInterrupted) { + writeAuthLine(`interrupted; the challenge is still pending — run "sing-box api openconnect auth" again, or "sing-box api openconnect cancel" to restart authentication`) + os.Exit(130) + } + return wrapAuthError("openconnect", err) + }, +} + +func init() { + commandAPIOpenConnectAuth.Flags().StringVar(&commandAPIOpenConnectAuthFlagEndpoint, "endpoint", "", "OpenConnect endpoint tag (default: the only configured endpoint)") + commandAPIOpenConnectAuth.Flags().Uint16Var(&commandAPIOpenConnectAuthFlagCallbackPort, "callback-port", 8020, "Local port for the browser single sign-on callback listener") + commandAPIOpenConnect.AddCommand(commandAPIOpenConnectAuth) +} + +func runAPIOpenConnectAuth() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + ctx, cancel := signal.NotifyContext(globalCtx, os.Interrupt, syscall.SIGTERM) + defer cancel() + stream, endpoints, err := subscribeOpenConnectStatus(ctx, client) + if err != nil { + return err + } + endpointStatus, err := resolveVPNEndpoint(endpoints, commandAPIOpenConnectAuthFlagEndpoint, "openconnect") + if err != nil { + return err + } + endpointTag := endpointStatus.GetEndpointTag() + if endpointStatus.GetAuthChallenge() == nil { + switch endpointStatus.GetState() { + case adapter.OpenConnectStateConnected: + return E.New("endpoint ", endpointTag, " is already connected") + case adapter.OpenConnectStateError: + return E.New("endpoint ", endpointTag, " failed: ", endpointStatus.GetError()) + } + } + watcher := newVPNStatusWatcher(endpoints, func() ([]*daemon.OpenConnectEndpointStatus, error) { + return recvOpenConnectStatus(stream) + }) + err = openConnectAuthLoop(ctx, client, watcher, newInteractiveInput(), endpointTag) + if err != nil && ctx.Err() != nil { + return errAuthInterrupted + } + return err +} + +func openConnectAuthLoop( + ctx context.Context, + client daemon.StartedServiceClient, + watcher *vpnStatusWatcher[*daemon.OpenConnectEndpointStatus], + input *interactiveInput, + endpointTag string, +) error { + var ( + renderedID string + waitingPrinted bool + ) + for { + endpoints, updated, streamErr := watcher.current() + if streamErr != nil { + return streamErr + } + index := slices.IndexFunc(endpoints, func(it *daemon.OpenConnectEndpointStatus) bool { + return it.GetEndpointTag() == endpointTag + }) + if index == -1 { + return E.New("endpoint not found: ", endpointTag) + } + endpointStatus := endpoints[index] + challenge := endpointStatus.GetAuthChallenge() + switch { + case challenge == nil && endpointStatus.GetState() == adapter.OpenConnectStateConnected: + os.Stdout.WriteString(endpointTag + ": connected\n") + return nil + case challenge == nil && endpointStatus.GetState() == adapter.OpenConnectStateError: + return E.New("endpoint ", endpointTag, " failed: ", endpointStatus.GetError()) + case challenge != nil && challenge.GetId() != renderedID: + renderedID = challenge.GetId() + waitingPrinted = false + handleErr := handleOpenConnectChallenge(ctx, client, watcher, input, endpointTag, challenge) + switch { + case handleErr == nil: + case errors.Is(handleErr, errAuthChallengeWithdrawn): + writeAuthLine(errAuthChallengeWithdrawn.Error()) + default: + return handleErr + } + continue + case challenge == nil && !waitingPrinted: + waitingPrinted = true + writeAuthLine("waiting for an authentication challenge on " + endpointTag + "...") + } + select { + case <-updated: + case <-ctx.Done(): + return errAuthInterrupted + } + } +} + +func handleOpenConnectChallenge( + ctx context.Context, + client daemon.StartedServiceClient, + watcher *vpnStatusWatcher[*daemon.OpenConnectEndpointStatus], + input *interactiveInput, + endpointTag string, + challenge *daemon.OpenConnectAuthChallenge, +) error { + prompter := &authPrompter{ctx: ctx, input: input, aborted: make(chan struct{})} + watchCtx, cancelWatch := context.WithCancel(ctx) + defer cancelWatch() + go watchOpenConnectChallenge(watchCtx, watcher, endpointTag, challenge.GetId(), prompter) + form := challenge.GetForm() + browser := challenge.GetBrowser() + switch { + case form != nil: + return submitOpenConnectForm(ctx, client, prompter, endpointTag, challenge, form) + case browser != nil: + return submitOpenConnectBrowser(ctx, client, prompter, endpointTag, challenge, browser) + default: + return E.New("unsupported authentication challenge") + } +} + +func watchOpenConnectChallenge( + ctx context.Context, + watcher *vpnStatusWatcher[*daemon.OpenConnectEndpointStatus], + endpointTag string, + challengeID string, + prompter *authPrompter, +) { + for { + endpoints, updated, streamErr := watcher.current() + if streamErr != nil { + prompter.abort(streamErr) + return + } + index := slices.IndexFunc(endpoints, func(it *daemon.OpenConnectEndpointStatus) bool { + return it.GetEndpointTag() == endpointTag + }) + if index == -1 || endpoints[index].GetAuthChallenge().GetId() != challengeID { + prompter.abort(errAuthChallengeWithdrawn) + return + } + select { + case <-updated: + case <-ctx.Done(): + return + } + } +} + +func submitOpenConnectForm( + ctx context.Context, + client daemon.StartedServiceClient, + prompter *authPrompter, + endpointTag string, + challenge *daemon.OpenConnectAuthChallenge, + form *daemon.OpenConnectAuthForm, +) error { + if !authInputIsTerminal { + return errAuthNotInteractive + } + writeAuthHeader(endpointTag, "authentication") + preambleWritten := false + if challenge.GetBanner() != "" { + writeAuthBanner(challenge.GetBanner()) + preambleWritten = true + } + if challenge.GetError() != "" { + writeAuthLine("previous attempt failed: " + challenge.GetError()) + preambleWritten = true + } + if challenge.GetMessage() != "" { + writeAuthLine(challenge.GetMessage()) + preambleWritten = true + } + if preambleWritten { + writeAuthLine("") + } + for { + values := make(map[string]string, len(form.GetFields())) + for _, field := range form.GetFields() { + value, err := promptOpenConnectField(prompter, field) + if err != nil { + return err + } + values[field.GetSubmissionKey()] = value + } + _, err := client.SubmitOpenConnectAuthResponse(ctx, &daemon.OpenConnectAuthResponseSubmission{ + EndpointTag: endpointTag, + ChallengeID: challenge.GetId(), + Response: &daemon.OpenConnectAuthResponseSubmission_Form{ + Form: &daemon.OpenConnectAuthFormResponse{Values: values}, + }, + }) + if err == nil { + return nil + } + outcome, message := classifySubmitError(err) + switch outcome { + case submitStale: + return errAuthChallengeWithdrawn + case submitFatal: + return err + } + writeAuthError("openconnect", "submit rejected: "+message) + } +} + +func promptOpenConnectField(prompter *authPrompter, field *daemon.OpenConnectAuthFormField) (string, error) { + label := field.GetLabel() + if label == "" { + label = field.GetName() + } + switch field.GetKind() { + case "text": + return prompter.promptText(label, field.GetValue()) + case "password": + return prompter.promptPassword(label, field.GetValue()) + case "select": + return promptOpenConnectSelect(prompter, label, field.GetOptions(), field.GetValue()) + default: + return "", E.New("unsupported authentication field kind: ", field.GetKind()) + } +} + +func promptOpenConnectSelect(prompter *authPrompter, label string, options []*daemon.OpenConnectAuthFormChoice, defaultValue string) (string, error) { + prompt := strings.TrimSuffix(label, ":") + var menu strings.Builder + menu.WriteString(prompt + ":\n") + for index, option := range options { + optionLabel := option.GetLabel() + if optionLabel == "" { + optionLabel = option.GetValue() + } + menu.WriteString(" " + strconv.Itoa(index+1) + ") " + optionLabel) + if option.GetValue() == defaultValue { + menu.WriteString(" [default]") + } + menu.WriteString("\n") + } + os.Stderr.WriteString(menu.String()) + for { + line, err := prompter.read(prompt+": ", false) + if err != nil { + return "", err + } + line = strings.TrimSpace(line) + if line == "" && defaultValue != "" { + return defaultValue, nil + } + selected, parseErr := strconv.Atoi(line) + if parseErr == nil && selected >= 1 && selected <= len(options) { + return options[selected-1].GetValue(), nil + } + if slices.ContainsFunc(options, func(it *daemon.OpenConnectAuthFormChoice) bool { + return it.GetValue() == line + }) { + return line, nil + } + writeAuthLine("select a number between 1 and " + strconv.Itoa(len(options))) + } +} diff --git a/cmd/sing-box/cmd_api_openconnect_auth_browser.go b/cmd/sing-box/cmd_api_openconnect_auth_browser.go new file mode 100644 index 00000000..67a52049 --- /dev/null +++ b/cmd/sing-box/cmd_api_openconnect_auth_browser.go @@ -0,0 +1,290 @@ +package main + +import ( + "context" + "net" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +const ( + openConnectBrowserModeCallback = "callback" + openConnectBrowserModeCookies = "cookies" + openConnectBrowserModeHeaders = "headers" +) + +const openConnectCallbackPage = ` + +single sign-on + +

Single sign-on completed

+

You may close this tab and return to the terminal.

+ + +` + +func deriveOpenConnectBrowserMode(request *daemon.OpenConnectBrowserRequest) (string, error) { + callbackMode := len(request.GetCallbackURLPrefixes()) > 0 + cookieMode := request.GetFinalURL() != "" || len(request.GetCookieNames()) > 0 || len(request.GetEarlyCookieNames()) > 0 + headerMode := len(request.GetHeaderNames()) > 0 + selectedModes := common.Filter([]bool{callbackMode, cookieMode, headerMode}, func(it bool) bool { + return it + }) + invalidRequest := E.New("openconnect browser request must select exactly one completion mode") + if len(selectedModes) != 1 { + return "", invalidRequest + } + switch { + case callbackMode: + if len(common.Uniq(request.GetCallbackURLPrefixes())) != len(request.GetCallbackURLPrefixes()) { + return "", invalidRequest + } + return openConnectBrowserModeCallback, nil + case cookieMode: + cookieNames := append(slices.Clone(request.GetCookieNames()), request.GetEarlyCookieNames()...) + if len(common.Uniq(cookieNames)) != len(cookieNames) { + return "", invalidRequest + } + return openConnectBrowserModeCookies, nil + default: + headerNames := common.Map(request.GetHeaderNames(), strings.ToLower) + if len(common.Uniq(headerNames)) != len(headerNames) { + return "", invalidRequest + } + return openConnectBrowserModeHeaders, nil + } +} + +func submitOpenConnectBrowser( + ctx context.Context, + client daemon.StartedServiceClient, + prompter *authPrompter, + endpointTag string, + challenge *daemon.OpenConnectAuthChallenge, + request *daemon.OpenConnectBrowserRequest, +) error { + mode, err := deriveOpenConnectBrowserMode(request) + if err != nil { + return err + } + writeAuthHeader(endpointTag, "browser authentication") + if challenge.GetError() != "" { + writeAuthLine("previous attempt failed: " + challenge.GetError()) + } + if challenge.GetMessage() != "" { + writeAuthLine(challenge.GetMessage()) + } + for { + result, earlyFailure, collectErr := collectOpenConnectBrowserResult(ctx, prompter, mode, request) + if collectErr != nil { + return collectErr + } + warnPlaintextAPIConnection() + _, submitErr := client.SubmitOpenConnectAuthResponse(ctx, &daemon.OpenConnectAuthResponseSubmission{ + EndpointTag: endpointTag, + ChallengeID: challenge.GetId(), + Response: &daemon.OpenConnectAuthResponseSubmission_Browser{Browser: result}, + }) + if submitErr == nil { + if earlyFailure { + writeAuthLine("single sign-on failed; the client will retry authentication") + } + return nil + } + outcome, message := classifySubmitError(submitErr) + switch outcome { + case submitStale: + return errAuthChallengeWithdrawn + case submitFatal: + return submitErr + } + writeAuthError("openconnect", "browser authentication rejected: "+message) + } +} + +func collectOpenConnectBrowserResult( + ctx context.Context, + prompter *authPrompter, + mode string, + request *daemon.OpenConnectBrowserRequest, +) (*daemon.OpenConnectBrowserResult, bool, error) { + switch { + case mode == openConnectBrowserModeCallback: + target, err := parseOpenConnectCallbackTarget(request.GetCallbackURLPrefixes()) + if err != nil { + return nil, false, err + } + if !authInputIsTerminal { + return nil, false, errAuthNotInteractive + } + finalURL, err := runOpenConnectCallbackListener(ctx, prompter, target, request.GetUrl()) + if err != nil { + return nil, false, err + } + return &daemon.OpenConnectBrowserResult{FinalURL: finalURL}, false, nil + case mode == openConnectBrowserModeCookies && len(request.GetCookieNames()) > 0: + if !authInputIsTerminal { + return nil, false, errAuthNotInteractive + } + return promptOpenConnectBrowserCookies(prompter, request) + case mode == openConnectBrowserModeHeaders: + return nil, false, E.New("this single sign-on requires reading HTTP response headers, which cannot be done manually; use the sing-box desktop application") + default: + return nil, false, E.New("this single sign-on cannot be completed manually; use the sing-box desktop application") + } +} + +type openConnectCallbackTarget struct { + scheme string + host string + port string +} + +func (t openConnectCallbackTarget) resolve(requestURI string) string { + return t.scheme + "://" + net.JoinHostPort(t.host, t.port) + requestURI +} + +func parseOpenConnectCallbackTarget(prefixes []string) (openConnectCallbackTarget, error) { + var target openConnectCallbackTarget + for _, prefix := range prefixes { + parsed, err := url.Parse(prefix) + if err != nil || !isLoopbackHost(parsed.Hostname()) { + return target, E.New("callback URL prefix is not on loopback: ", prefix) + } + if target.scheme == "" { + target.scheme = parsed.Scheme + target.host = parsed.Hostname() + } + if target.port == "" { + target.port = parsed.Port() + } + } + if target.port == "" { + target.port = strconv.Itoa(int(commandAPIOpenConnectAuthFlagCallbackPort)) + } + return target, nil +} + +func runOpenConnectCallbackListener(ctx context.Context, prompter *authPrompter, target openConnectCallbackTarget, loginURL string) (string, error) { + listener, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", target.port)) + if err != nil { + return "", E.New("cannot listen on 127.0.0.1:", target.port, ": ", err.Error(), "; pass --callback-port") + } + defer listener.Close() + writeAuthLine("Complete single sign-on in your browser; this command finishes automatically.") + writeAuthLine("") + writeAuthLine(" listening on " + target.resolve("/")) + writeAuthLine(" url " + loginURL) + writeAuthLine("") + confirmed, err := prompter.promptConfirm("Open it now? [Y/n] ") + if err != nil { + return "", err + } + if !confirmed { + writeAuthLine("waiting for the callback...") + } else { + openErr := openURLInBrowser(loginURL) + if openErr != nil { + writeAuthLine("failed to open the default browser: " + openErr.Error()) + writeAuthLine("waiting for the callback...") + } else { + writeAuthLine("opened in the default browser; waiting for the callback...") + } + } + requestURIs := make(chan string, 1) + server := &http.Server{Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + select { + case requestURIs <- request.RequestURI: + default: + } + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + writer.Header().Set("Connection", "close") + writer.WriteHeader(http.StatusOK) + writer.Write([]byte(openConnectCallbackPage)) + })} + go func() { + _ = server.Serve(listener) + }() + defer server.Close() + select { + case requestURI := <-requestURIs: + writeAuthLine("received callback") + return target.resolve(requestURI), nil + case <-prompter.aborted: + return "", prompter.abortErr + case <-ctx.Done(): + return "", errAuthInterrupted + } +} + +func promptOpenConnectBrowserCookies(prompter *authPrompter, request *daemon.OpenConnectBrowserRequest) (*daemon.OpenConnectBrowserResult, bool, error) { + writeAuthLine("This single sign-on must be completed manually.") + writeAuthLine("") + step := 1 + writeAuthLine(" " + strconv.Itoa(step) + ". Open this URL in any browser:") + writeAuthLine(" " + request.GetUrl()) + if request.GetFinalURL() != "" { + step++ + writeAuthLine(" " + strconv.Itoa(step) + ". Sign in until the browser lands on:") + writeAuthLine(" " + request.GetFinalURL()) + } + step++ + writeAuthLine(" " + strconv.Itoa(step) + ". Open the developer tools (F12) > Application > Cookies, and read the") + writeAuthLine(" value of the cookie listed below for that page.") + writeAuthLine("") + earlyCookieNames := request.GetEarlyCookieNames() + var cookies []*daemon.OpenConnectBrowserCookie + for index, name := range request.GetCookieNames() { + prompt := `Cookie "` + name + `": ` + if index == 0 && len(earlyCookieNames) > 0 { + prompt = `Cookie "` + name + `" (or "!" if the page reported an error): ` + } + for { + value, err := prompter.read(prompt, true) + if err != nil { + return nil, false, err + } + if index == 0 && len(earlyCookieNames) > 0 && value == "!" { + earlyCookie, earlyErr := promptOpenConnectEarlyCookie(prompter, earlyCookieNames[0]) + if earlyErr != nil { + return nil, false, earlyErr + } + return &daemon.OpenConnectBrowserResult{Cookies: []*daemon.OpenConnectBrowserCookie{earlyCookie}}, true, nil + } + if value == "" { + writeAuthLine("cookie value must not be empty") + continue + } + cookies = append(cookies, &daemon.OpenConnectBrowserCookie{Name: name, Value: value}) + break + } + } + if len(cookies) == 1 { + writeAuthLine("submitting 1 cookie") + } else { + writeAuthLine(F.ToString("submitting ", len(cookies), " cookies")) + } + return &daemon.OpenConnectBrowserResult{FinalURL: request.GetFinalURL(), Cookies: cookies}, false, nil +} + +func promptOpenConnectEarlyCookie(prompter *authPrompter, name string) (*daemon.OpenConnectBrowserCookie, error) { + for { + value, err := prompter.read(`Error cookie "`+name+`": `, true) + if err != nil { + return nil, err + } + if value == "" { + writeAuthLine("cookie value must not be empty") + continue + } + return &daemon.OpenConnectBrowserCookie{Name: name, Value: value}, nil + } +} diff --git a/cmd/sing-box/cmd_api_openconnect_cancel.go b/cmd/sing-box/cmd_api_openconnect_cancel.go new file mode 100644 index 00000000..a09b3769 --- /dev/null +++ b/cmd/sing-box/cmd_api_openconnect_cancel.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "os" + + "github.com/sagernet/sing-box/daemon" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var commandAPIOpenConnectCancelFlagEndpoint string + +var commandAPIOpenConnectCancel = &cobra.Command{ + Use: "cancel", + Short: "Cancel the pending OpenConnect authentication challenge", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIOpenConnectCancel() + }, +} + +func init() { + commandAPIOpenConnectCancel.Flags().StringVar(&commandAPIOpenConnectCancelFlagEndpoint, "endpoint", "", "OpenConnect endpoint tag (default: the only configured endpoint)") + commandAPIOpenConnect.AddCommand(commandAPIOpenConnectCancel) +} + +func runAPIOpenConnectCancel() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + ctx, cancel := context.WithCancel(globalCtx) + defer cancel() + _, endpoints, err := subscribeOpenConnectStatus(ctx, client) + if err != nil { + return err + } + endpointStatus, err := resolveVPNEndpoint(endpoints, commandAPIOpenConnectCancelFlagEndpoint, "openconnect") + if err != nil { + return err + } + endpointTag := endpointStatus.GetEndpointTag() + challenge := endpointStatus.GetAuthChallenge() + if challenge == nil { + return E.New("no pending authentication challenge on ", endpointTag) + } + _, err = client.CancelOpenConnectAuthChallenge(ctx, &daemon.OpenConnectAuthChallengeCancel{ + EndpointTag: endpointTag, + ChallengeID: challenge.GetId(), + }) + if err != nil { + return err + } + os.Stdout.WriteString(endpointTag + ": authentication challenge canceled; the client will restart authentication\n") + return nil +} diff --git a/cmd/sing-box/cmd_api_openconnect_openvpn.go b/cmd/sing-box/cmd_api_openconnect_openvpn.go new file mode 100644 index 00000000..260b98b2 --- /dev/null +++ b/cmd/sing-box/cmd_api_openconnect_openvpn.go @@ -0,0 +1,331 @@ +package main + +import ( + "context" + "net/netip" + "net/url" + "os" + "os/exec" + "runtime" + "slices" + "strings" + "sync" + "time" + + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/term" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var ( + errAuthInterrupted = E.New("interrupted") + errAuthChallengeWithdrawn = E.New("challenge no longer pending, waiting for the next one") + errAuthDeadlineExpired = E.New("challenge deadline expired; the server will retry the connection") + errAuthNotInteractive = E.New("authentication requires an interactive terminal") +) + +var authInputIsTerminal = term.IsTerminal(int(os.Stdin.Fd())) && stderrIsTerminal + +type vpnEndpointStatus interface { + GetEndpointTag() string +} + +func resolveVPNEndpoint[T vpnEndpointStatus](endpoints []T, endpointTag string, domain string) (T, error) { + var zero T + if endpointTag != "" { + index := slices.IndexFunc(endpoints, func(it T) bool { + return it.GetEndpointTag() == endpointTag + }) + if index == -1 { + return zero, E.New("endpoint not found: ", endpointTag) + } + return endpoints[index], nil + } + switch len(endpoints) { + case 0: + return zero, E.New("no ", domain, " endpoint is configured") + case 1: + return endpoints[0], nil + default: + return zero, E.New("multiple ", domain, " endpoints; select one with -e: ", strings.Join(common.Map(endpoints, func(it T) string { + return it.GetEndpointTag() + }), ", ")) + } +} + +type vpnStatusWatcher[T any] struct { + access sync.Mutex + updated chan struct{} + endpoints []T + err error +} + +func newVPNStatusWatcher[T any](endpoints []T, recv func() ([]T, error)) *vpnStatusWatcher[T] { + watcher := &vpnStatusWatcher[T]{ + updated: make(chan struct{}), + endpoints: endpoints, + } + go watcher.run(recv) + return watcher +} + +func (w *vpnStatusWatcher[T]) run(recv func() ([]T, error)) { + for { + endpoints, err := recv() + w.access.Lock() + if err != nil { + w.err = err + } else { + w.endpoints = endpoints + } + close(w.updated) + w.updated = make(chan struct{}) + w.access.Unlock() + if err != nil { + return + } + } +} + +func (w *vpnStatusWatcher[T]) current() ([]T, <-chan struct{}, error) { + w.access.Lock() + defer w.access.Unlock() + return w.endpoints, w.updated, w.err +} + +type interactiveReadResult struct { + line string + err error +} + +type interactiveReadRequest struct { + prompt string + hidden bool + result chan interactiveReadResult +} + +type interactiveInput struct { + requests chan interactiveReadRequest +} + +func newInteractiveInput() *interactiveInput { + input := &interactiveInput{requests: make(chan interactiveReadRequest)} + go input.run() + return input +} + +func (i *interactiveInput) run() { + for request := range i.requests { + os.Stderr.WriteString(request.prompt) + line, err := readTerminalLine(request.hidden) + request.result <- interactiveReadResult{line: line, err: err} + } +} + +func readTerminalLine(hidden bool) (string, error) { + if hidden { + line, err := term.ReadPassword(int(os.Stdin.Fd())) + os.Stderr.WriteString("\n") + if err != nil { + return "", err + } + return string(line), nil + } + var builder strings.Builder + buffer := make([]byte, 1) + for { + n, err := os.Stdin.Read(buffer) + if n > 0 { + if buffer[0] == '\n' { + break + } + builder.WriteByte(buffer[0]) + } + if err != nil { + if builder.Len() == 0 { + return "", err + } + break + } + } + return strings.TrimSuffix(builder.String(), "\r"), nil +} + +type authPrompter struct { + ctx context.Context + input *interactiveInput + once sync.Once + aborted chan struct{} + abortErr error +} + +func (p *authPrompter) abort(cause error) { + p.once.Do(func() { + p.abortErr = cause + close(p.aborted) + }) +} + +func (p *authPrompter) read(prompt string, hidden bool) (string, error) { + result := make(chan interactiveReadResult, 1) + select { + case p.input.requests <- interactiveReadRequest{prompt: prompt, hidden: hidden, result: result}: + case <-p.aborted: + return "", p.abortErr + case <-p.ctx.Done(): + return "", errAuthInterrupted + } + select { + case value := <-result: + return value.line, value.err + case <-p.aborted: + return "", p.abortErr + case <-p.ctx.Done(): + return "", errAuthInterrupted + } +} + +func (p *authPrompter) promptText(label string, value string) (string, error) { + prompt := strings.TrimSuffix(label, ":") + if value != "" { + prompt += " [" + value + "]" + } + line, err := p.read(prompt+": ", false) + if err != nil { + return "", err + } + if line == "" { + return value, nil + } + return line, nil +} + +func (p *authPrompter) promptPassword(label string, value string) (string, error) { + prompt := strings.TrimSuffix(label, ":") + if value != "" { + prompt += " (unchanged)" + } + line, err := p.read(prompt+": ", true) + if err != nil { + return "", err + } + if line == "" { + return value, nil + } + return line, nil +} + +func (p *authPrompter) promptConfirm(prompt string) (bool, error) { + line, err := p.read(prompt, false) + if err != nil { + return false, err + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "", "y", "yes": + return true, nil + default: + return false, nil + } +} + +func writeAuthLine(message string) { + os.Stderr.WriteString(message + "\n") +} + +func writeAuthError(domain string, message string) { + os.Stderr.WriteString(domain + " auth: " + message + "\n") +} + +func writeAuthHeader(endpointTag string, title string) { + os.Stderr.WriteString("\n" + endpointTag + ": " + title + "\n") +} + +func writeAuthBanner(banner string) { + var output strings.Builder + for line := range strings.SplitSeq(strings.ReplaceAll(banner, "\r\n", "\n"), "\n") { + output.WriteString(" " + line + "\n") + } + os.Stderr.WriteString(output.String()) +} + +type submitOutcome int + +const ( + submitRejected submitOutcome = iota + submitStale + submitFatal +) + +func classifySubmitError(err error) (submitOutcome, string) { + grpcStatus, isStatus := status.FromError(err) + if !isStatus { + return submitFatal, err.Error() + } + switch grpcStatus.Code() { + case codes.Unavailable, codes.Canceled, codes.DeadlineExceeded, codes.Unauthenticated, codes.Unimplemented: + return submitFatal, grpcStatus.Message() + } + if strings.Contains(grpcStatus.Message(), "no pending") { + return submitStale, grpcStatus.Message() + } + return submitRejected, grpcStatus.Message() +} + +func wrapAuthError(domain string, err error) error { + if err == nil { + return nil + } + _, isStatus := status.FromError(err) + if isStatus { + return err + } + return E.Cause(err, domain+" auth") +} + +func formatVPNConnectedSince(connectedSince int64) string { + if connectedSince == 0 { + return "" + } + since := time.Unix(connectedSince, 0).Local() + return since.Format(time.RFC3339) + " (" + time.Since(since).Truncate(time.Second).String() + ")" +} + +func formatAuthDeadline(deadline int64) string { + if deadline == 0 { + return "" + } + return max(time.Until(time.Unix(deadline, 0)).Truncate(time.Second), 0).String() +} + +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + address, err := netip.ParseAddr(host) + if err != nil { + return false + } + return address.IsLoopback() +} + +func warnPlaintextAPIConnection() { + parsed, err := url.Parse(commandAPIServerURL) + if err != nil || parsed.Scheme == "https" || isLoopbackHost(parsed.Hostname()) { + return + } + writeAuthLine("warning: submitting single sign-on credentials over a plaintext API connection") +} + +func openURLInBrowser(target string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", target).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", target).Start() + default: + return exec.Command("xdg-open", target).Start() + } +} diff --git a/cmd/sing-box/cmd_api_openconnect_status.go b/cmd/sing-box/cmd_api_openconnect_status.go new file mode 100644 index 00000000..912c9718 --- /dev/null +++ b/cmd/sing-box/cmd_api_openconnect_status.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "os" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/daemon" + F "github.com/sagernet/sing/common/format" + + "github.com/spf13/cobra" +) + +var commandAPIOpenConnectStatus = &cobra.Command{ + Use: "status", + Short: "Print OpenConnect endpoint status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIOpenConnectStatus() + }, +} + +func init() { + commandAPIOpenConnect.AddCommand(commandAPIOpenConnectStatus) +} + +func runAPIOpenConnectStatus() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + ctx, cancel := context.WithCancel(globalCtx) + defer cancel() + _, endpoints, err := subscribeOpenConnectStatus(ctx, client) + if err != nil { + return err + } + if len(endpoints) == 0 { + writeStderrLine("no openconnect endpoint is configured") + return nil + } + for index, endpointStatus := range endpoints { + if index > 0 { + os.Stdout.WriteString("\n") + } + writeOpenConnectStatusBlock(endpointStatus) + } + return nil +} + +func writeOpenConnectStatusBlock(endpointStatus *daemon.OpenConnectEndpointStatus) { + var block blockWriter + block.addLine("Endpoint", endpointStatus.GetEndpointTag()) + block.addLine("State", endpointStatus.GetState()) + challenge := endpointStatus.GetAuthChallenge() + tunnelInfo := endpointStatus.GetTunnelInfo() + switch { + case challenge != nil: + block.addLine("Challenge", openConnectChallengeSummary(challenge)) + if challenge.GetMessage() != "" { + block.addLine("Message", challenge.GetMessage()) + } + if challenge.GetError() != "" { + block.addLine("Error", challenge.GetError()) + } + case tunnelInfo != nil: + block.addLine("Server", tunnelInfo.GetServer()) + block.addLine("Flavor", tunnelInfo.GetFlavor()) + block.addLine("Transport", tunnelInfo.GetTransport()) + if len(tunnelInfo.GetIpv4()) > 0 { + block.addLine("IPv4", strings.Join(tunnelInfo.GetIpv4(), ", ")) + } + if len(tunnelInfo.GetIpv6()) > 0 { + block.addLine("IPv6", strings.Join(tunnelInfo.GetIpv6(), ", ")) + } + if len(tunnelInfo.GetDns()) > 0 { + block.addLine("DNS", strings.Join(tunnelInfo.GetDns(), ", ")) + } + if tunnelInfo.GetMtu() > 0 { + block.addLine("MTU", F.ToString(tunnelInfo.GetMtu())) + } + block.addLine("Connected since", formatVPNConnectedSince(tunnelInfo.GetConnectedSince())) + case endpointStatus.GetState() == adapter.OpenConnectStateError: + block.addLine("Error", endpointStatus.GetError()) + } + block.flush() + if challenge != nil { + writeStderrLine("") + writeStderrLine(`run "sing-box api openconnect auth" to answer`) + } +} diff --git a/cmd/sing-box/cmd_api_openvpn.go b/cmd/sing-box/cmd_api_openvpn.go new file mode 100644 index 00000000..e9fc2036 --- /dev/null +++ b/cmd/sing-box/cmd_api_openvpn.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "errors" + "io" + + "github.com/sagernet/sing-box/daemon" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" +) + +const ( + openVPNChallengeCredentials = "credentials" + openVPNChallengeSecret = "secret" + openVPNChallengeMessage = "message" + openVPNChallengeOpenURL = "open-url" +) + +var commandAPIOpenVPN = &cobra.Command{ + Use: "openvpn", + Short: "Manage OpenVPN authentication", +} + +func init() { + commandAPIRoot.AddCommand(commandAPIOpenVPN) +} + +func subscribeOpenVPNStatus(ctx context.Context, client daemon.StartedServiceClient) (grpc.ServerStreamingClient[daemon.OpenVPNStatusUpdate], []*daemon.OpenVPNEndpointStatus, error) { + stream, err := client.SubscribeOpenVPNStatus(ctx, &emptypb.Empty{}) + if err != nil { + return nil, nil, err + } + endpoints, err := recvOpenVPNStatus(stream) + if err != nil { + return nil, nil, err + } + return stream, endpoints, nil +} + +func recvOpenVPNStatus(stream grpc.ServerStreamingClient[daemon.OpenVPNStatusUpdate]) ([]*daemon.OpenVPNEndpointStatus, error) { + update, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + return nil, E.New("api service closed the status stream") + } + return nil, err + } + return update.GetEndpoints(), nil +} + +func openVPNChallengeSummary(challenge *daemon.OpenVPNChallenge) string { + switch challenge.GetKind() { + case openVPNChallengeMessage, openVPNChallengeOpenURL: + return challenge.GetKind() + " (not answerable)" + default: + return challenge.GetKind() + } +} diff --git a/cmd/sing-box/cmd_api_openvpn_auth.go b/cmd/sing-box/cmd_api_openvpn_auth.go new file mode 100644 index 00000000..f9755511 --- /dev/null +++ b/cmd/sing-box/cmd_api_openvpn_auth.go @@ -0,0 +1,355 @@ +package main + +import ( + "context" + "errors" + "os" + "os/signal" + "slices" + "strings" + "syscall" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/daemon" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var commandAPIOpenVPNAuthFlagEndpoint string + +var commandAPIOpenVPNAuth = &cobra.Command{ + Use: "auth", + Short: "Answer OpenVPN authentication challenges", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + err := runAPIOpenVPNAuth() + if errors.Is(err, errAuthInterrupted) { + writeAuthLine(`interrupted; the challenge is still pending — run "sing-box api openvpn auth" again, or "sing-box api openvpn cancel" to stop the client`) + os.Exit(130) + } + return wrapAuthError("openvpn", err) + }, +} + +func init() { + commandAPIOpenVPNAuth.Flags().StringVar(&commandAPIOpenVPNAuthFlagEndpoint, "endpoint", "", "OpenVPN endpoint tag (default: the only configured endpoint)") + commandAPIOpenVPN.AddCommand(commandAPIOpenVPNAuth) +} + +func runAPIOpenVPNAuth() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + ctx, cancel := signal.NotifyContext(globalCtx, os.Interrupt, syscall.SIGTERM) + defer cancel() + stream, endpoints, err := subscribeOpenVPNStatus(ctx, client) + if err != nil { + return err + } + endpointStatus, err := resolveVPNEndpoint(endpoints, commandAPIOpenVPNAuthFlagEndpoint, "openvpn") + if err != nil { + return err + } + endpointTag := endpointStatus.GetEndpointTag() + if endpointStatus.GetChallenge() == nil { + switch endpointStatus.GetState() { + case adapter.OpenVPNStateConnected: + return E.New("endpoint ", endpointTag, " is already connected") + case adapter.OpenVPNStateError: + return E.New("endpoint ", endpointTag, " failed: ", endpointStatus.GetError()) + } + } + watcher := newVPNStatusWatcher(endpoints, func() ([]*daemon.OpenVPNEndpointStatus, error) { + return recvOpenVPNStatus(stream) + }) + err = openVPNAuthLoop(ctx, client, watcher, newInteractiveInput(), endpointTag) + if err != nil && ctx.Err() != nil { + return errAuthInterrupted + } + return err +} + +func openVPNAuthLoop( + ctx context.Context, + client daemon.StartedServiceClient, + watcher *vpnStatusWatcher[*daemon.OpenVPNEndpointStatus], + input *interactiveInput, + endpointTag string, +) error { + var ( + renderedID string + waitingPrinted bool + ) + for { + endpoints, updated, streamErr := watcher.current() + if streamErr != nil { + return streamErr + } + index := slices.IndexFunc(endpoints, func(it *daemon.OpenVPNEndpointStatus) bool { + return it.GetEndpointTag() == endpointTag + }) + if index == -1 { + return E.New("endpoint not found: ", endpointTag) + } + endpointStatus := endpoints[index] + challenge := endpointStatus.GetChallenge() + switch { + case challenge == nil && endpointStatus.GetState() == adapter.OpenVPNStateConnected: + os.Stdout.WriteString(endpointTag + ": connected\n") + return nil + case challenge == nil && endpointStatus.GetState() == adapter.OpenVPNStateError: + return E.New("endpoint ", endpointTag, " failed: ", endpointStatus.GetError()) + case challenge != nil && challenge.GetId() != renderedID: + renderedID = challenge.GetId() + waitingPrinted = false + handleErr := handleOpenVPNChallenge(ctx, client, watcher, input, endpointTag, challenge) + switch { + case handleErr == nil: + case errors.Is(handleErr, errAuthChallengeWithdrawn): + writeAuthLine(errAuthChallengeWithdrawn.Error()) + case errors.Is(handleErr, errAuthDeadlineExpired): + writeAuthError("openvpn", errAuthDeadlineExpired.Error()) + default: + return handleErr + } + continue + case challenge == nil && !waitingPrinted: + waitingPrinted = true + writeAuthLine("waiting for an authentication challenge on " + endpointTag + "...") + } + select { + case <-updated: + case <-ctx.Done(): + return errAuthInterrupted + } + } +} + +func handleOpenVPNChallenge( + ctx context.Context, + client daemon.StartedServiceClient, + watcher *vpnStatusWatcher[*daemon.OpenVPNEndpointStatus], + input *interactiveInput, + endpointTag string, + challenge *daemon.OpenVPNChallenge, +) error { + prompter := &authPrompter{ctx: ctx, input: input, aborted: make(chan struct{})} + watchCtx, cancelWatch := context.WithCancel(ctx) + defer cancelWatch() + go watchOpenVPNChallenge(watchCtx, watcher, endpointTag, challenge.GetId(), prompter) + switch challenge.GetKind() { + case openVPNChallengeCredentials: + return submitOpenVPNCredentials(ctx, client, prompter, endpointTag, challenge) + case openVPNChallengeSecret: + return submitOpenVPNSecret(ctx, client, prompter, endpointTag, challenge) + case openVPNChallengeMessage: + writeAuthHeader(endpointTag, "notice") + writeAuthLine(challenge.GetMessage() + openVPNRemainingSuffix(challenge)) + return nil + case openVPNChallengeOpenURL: + return openOpenVPNChallengeURL(prompter, endpointTag, challenge) + default: + return E.New("unsupported authentication challenge kind: ", challenge.GetKind()) + } +} + +func watchOpenVPNChallenge( + ctx context.Context, + watcher *vpnStatusWatcher[*daemon.OpenVPNEndpointStatus], + endpointTag string, + challengeID string, + prompter *authPrompter, +) { + timer := time.NewTimer(time.Hour) + timer.Stop() + defer timer.Stop() + for { + endpoints, updated, streamErr := watcher.current() + if streamErr != nil { + prompter.abort(streamErr) + return + } + index := slices.IndexFunc(endpoints, func(it *daemon.OpenVPNEndpointStatus) bool { + return it.GetEndpointTag() == endpointTag + }) + if index == -1 || endpoints[index].GetChallenge().GetId() != challengeID { + prompter.abort(errAuthChallengeWithdrawn) + return + } + var expired <-chan time.Time + deadline := endpoints[index].GetChallenge().GetDeadline() + if deadline != 0 { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(time.Until(time.Unix(deadline, 0))) + expired = timer.C + } + select { + case <-updated: + case <-expired: + prompter.abort(errAuthDeadlineExpired) + return + case <-ctx.Done(): + return + } + } +} + +func submitOpenVPNCredentials( + ctx context.Context, + client daemon.StartedServiceClient, + prompter *authPrompter, + endpointTag string, + challenge *daemon.OpenVPNChallenge, +) error { + if !authInputIsTerminal { + return errAuthNotInteractive + } + writeAuthHeader(endpointTag, "authentication") + if challenge.GetPreviousError() != "" { + writeAuthLine("previous attempt failed: " + challenge.GetPreviousError()) + writeAuthLine("") + } + secretLabel := challenge.GetSecretMessage() + if secretLabel == "" { + secretLabel = "Secret" + } + for { + username, err := prompter.promptText("Username", challenge.GetUsername()) + if err != nil { + return err + } + password, err := prompter.promptPassword("Password", "") + if err != nil { + return err + } + secret, err := prompter.read(strings.TrimSuffix(secretLabel, ":")+": ", !challenge.GetEcho()) + if err != nil { + return err + } + answered, err := submitOpenVPNChallengeResponse(ctx, client, &daemon.OpenVPNChallengeSubmission{ + EndpointTag: endpointTag, + ChallengeID: challenge.GetId(), + Username: username, + Password: password, + Secret: secret, + }) + if err != nil { + return err + } + if answered { + return nil + } + } +} + +func submitOpenVPNSecret( + ctx context.Context, + client daemon.StartedServiceClient, + prompter *authPrompter, + endpointTag string, + challenge *daemon.OpenVPNChallenge, +) error { + if !authInputIsTerminal { + return errAuthNotInteractive + } + writeAuthHeader(endpointTag, "authentication") + contextWritten := false + if challenge.GetPreviousError() != "" { + writeAuthLine("previous attempt failed: " + challenge.GetPreviousError()) + contextWritten = true + } + if challenge.GetUsername() != "" { + writeAuthLine("user: " + challenge.GetUsername()) + contextWritten = true + } + if contextWritten { + writeAuthLine("") + } + label := challenge.GetMessage() + if challenge.GetDeadline() != 0 { + if label != "" { + writeAuthLine(label + openVPNRemainingSuffix(challenge)) + } + label = "Code" + } + if label == "" { + label = "Secret" + } + for { + secret, err := prompter.read(strings.TrimSuffix(label, ":")+": ", !challenge.GetEcho()) + if err != nil { + return err + } + answered, err := submitOpenVPNChallengeResponse(ctx, client, &daemon.OpenVPNChallengeSubmission{ + EndpointTag: endpointTag, + ChallengeID: challenge.GetId(), + Secret: secret, + }) + if err != nil { + return err + } + if answered { + return nil + } + } +} + +func submitOpenVPNChallengeResponse(ctx context.Context, client daemon.StartedServiceClient, submission *daemon.OpenVPNChallengeSubmission) (bool, error) { + _, err := client.SubmitOpenVPNChallengeResponse(ctx, submission) + if err == nil { + return true, nil + } + outcome, message := classifySubmitError(err) + switch outcome { + case submitStale: + return false, errAuthChallengeWithdrawn + case submitFatal: + return false, err + } + writeAuthError("openvpn", "submit rejected: "+message) + return false, nil +} + +func openOpenVPNChallengeURL(prompter *authPrompter, endpointTag string, challenge *daemon.OpenVPNChallenge) error { + writeAuthHeader(endpointTag, "authentication") + if challenge.GetPreviousError() != "" { + writeAuthLine("previous attempt failed: " + challenge.GetPreviousError()) + } + writeAuthLine("Complete authentication in your browser:") + writeAuthLine("") + writeAuthLine(" " + challenge.GetUrl()) + writeAuthLine("") + if authInputIsTerminal { + confirmed, err := prompter.promptConfirm("Open it now? [Y/n] ") + if err != nil { + return err + } + if confirmed { + openErr := openURLInBrowser(challenge.GetUrl()) + if openErr != nil { + writeAuthLine("failed to open the default browser: " + openErr.Error()) + } else { + writeAuthLine("opened in the default browser; waiting for the server" + openVPNRemainingSuffix(challenge)) + return nil + } + } + } + writeAuthLine("waiting for the server" + openVPNRemainingSuffix(challenge)) + return nil +} + +func openVPNRemainingSuffix(challenge *daemon.OpenVPNChallenge) string { + if challenge.GetDeadline() == 0 { + return "" + } + return " (" + formatAuthDeadline(challenge.GetDeadline()) + " remaining)" +} diff --git a/cmd/sing-box/cmd_api_openvpn_cancel.go b/cmd/sing-box/cmd_api_openvpn_cancel.go new file mode 100644 index 00000000..db0ab081 --- /dev/null +++ b/cmd/sing-box/cmd_api_openvpn_cancel.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "os" + + "github.com/sagernet/sing-box/daemon" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var commandAPIOpenVPNCancelFlagEndpoint string + +var commandAPIOpenVPNCancel = &cobra.Command{ + Use: "cancel", + Short: "Cancel the pending OpenVPN challenge and stop the client", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIOpenVPNCancel() + }, +} + +func init() { + commandAPIOpenVPNCancel.Flags().StringVar(&commandAPIOpenVPNCancelFlagEndpoint, "endpoint", "", "OpenVPN endpoint tag (default: the only configured endpoint)") + commandAPIOpenVPN.AddCommand(commandAPIOpenVPNCancel) +} + +func runAPIOpenVPNCancel() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + ctx, cancel := context.WithCancel(globalCtx) + defer cancel() + _, endpoints, err := subscribeOpenVPNStatus(ctx, client) + if err != nil { + return err + } + endpointStatus, err := resolveVPNEndpoint(endpoints, commandAPIOpenVPNCancelFlagEndpoint, "openvpn") + if err != nil { + return err + } + endpointTag := endpointStatus.GetEndpointTag() + challenge := endpointStatus.GetChallenge() + if challenge == nil { + return E.New("no pending authentication challenge on ", endpointTag) + } + _, err = client.CancelOpenVPNChallenge(ctx, &daemon.OpenVPNChallengeCancel{ + EndpointTag: endpointTag, + ChallengeID: challenge.GetId(), + }) + if err != nil { + return err + } + // sing-openvpn treats a canceled challenge as terminal: unlike OpenConnect, the client does not + // reconnect afterwards. + os.Stdout.WriteString(endpointTag + ": authentication challenge canceled; the client has stopped\n") + return nil +} diff --git a/cmd/sing-box/cmd_api_openvpn_status.go b/cmd/sing-box/cmd_api_openvpn_status.go new file mode 100644 index 00000000..735aa047 --- /dev/null +++ b/cmd/sing-box/cmd_api_openvpn_status.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "os" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/daemon" + F "github.com/sagernet/sing/common/format" + + "github.com/spf13/cobra" +) + +var commandAPIOpenVPNStatus = &cobra.Command{ + Use: "status", + Short: "Print OpenVPN endpoint status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIOpenVPNStatus() + }, +} + +func init() { + commandAPIOpenVPN.AddCommand(commandAPIOpenVPNStatus) +} + +func runAPIOpenVPNStatus() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + ctx, cancel := context.WithCancel(globalCtx) + defer cancel() + _, endpoints, err := subscribeOpenVPNStatus(ctx, client) + if err != nil { + return err + } + if len(endpoints) == 0 { + writeStderrLine("no openvpn endpoint is configured") + return nil + } + for index, endpointStatus := range endpoints { + if index > 0 { + os.Stdout.WriteString("\n") + } + writeOpenVPNStatusBlock(endpointStatus) + } + return nil +} + +func writeOpenVPNStatusBlock(endpointStatus *daemon.OpenVPNEndpointStatus) { + var block blockWriter + block.addLine("Endpoint", endpointStatus.GetEndpointTag()) + block.addLine("State", endpointStatus.GetState()) + challenge := endpointStatus.GetChallenge() + tunnelInfo := endpointStatus.GetTunnelInfo() + switch { + case challenge != nil: + block.addLine("Challenge", openVPNChallengeSummary(challenge)) + if challenge.GetMessage() != "" { + block.addLine("Message", challenge.GetMessage()) + } + if challenge.GetUrl() != "" { + block.addLine("URL", challenge.GetUrl()) + } + if challenge.GetDeadline() != 0 { + block.addLine("Deadline", "in "+formatAuthDeadline(challenge.GetDeadline())) + } + if challenge.GetPreviousError() != "" { + block.addLine("Error", challenge.GetPreviousError()) + } + case tunnelInfo != nil: + block.addLine("Server", tunnelInfo.GetServer()) + block.addLine("Network", tunnelInfo.GetNetwork()) + block.addLine("Cipher", tunnelInfo.GetCipher()) + if len(tunnelInfo.GetIpv4()) > 0 { + block.addLine("IPv4", strings.Join(tunnelInfo.GetIpv4(), ", ")) + } + if len(tunnelInfo.GetIpv6()) > 0 { + block.addLine("IPv6", strings.Join(tunnelInfo.GetIpv6(), ", ")) + } + if len(tunnelInfo.GetDns()) > 0 { + block.addLine("DNS", strings.Join(tunnelInfo.GetDns(), ", ")) + } + if tunnelInfo.GetMtu() > 0 { + block.addLine("MTU", F.ToString(tunnelInfo.GetMtu())) + } + block.addLine("Connected since", formatVPNConnectedSince(tunnelInfo.GetConnectedSince())) + case endpointStatus.GetState() == adapter.OpenVPNStateError: + block.addLine("Error", endpointStatus.GetError()) + } + block.flush() + if challenge != nil { + writeStderrLine("") + writeStderrLine(`run "sing-box api openvpn auth" to continue`) + } +} diff --git a/cmd/sing-box/cmd_api_outbounds.go b/cmd/sing-box/cmd_api_outbounds.go new file mode 100644 index 00000000..b8840fdc --- /dev/null +++ b/cmd/sing-box/cmd_api_outbounds.go @@ -0,0 +1,53 @@ +package main + +import ( + F "github.com/sagernet/sing/common/format" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" +) + +var commandAPIOutbounds = &cobra.Command{ + Use: "outbounds", + Short: "List outbounds", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIOutbounds() + }, +} + +func init() { + commandAPIRoot.AddCommand(commandAPIOutbounds) +} + +func runAPIOutbounds() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + stream, err := client.SubscribeOutbounds(globalCtx, &emptypb.Empty{}) + if err != nil { + return err + } + outbounds, err := stream.Recv() + if err != nil { + return err + } + table := tableWriter{ + header: []string{"TAG", "TYPE", "DELAY"}, + emptyMessage: "no outbounds", + } + for _, item := range outbounds.GetOutbounds() { + table.addRow(item.GetTag(), item.GetType(), formatDelay(item.GetUrlTestDelay())) + } + table.flush() + return nil +} + +func formatDelay(delay int32) string { + if delay <= 0 { + return "" + } + return F.ToString(delay, " ms") +} diff --git a/cmd/sing-box/cmd_api_output.go b/cmd/sing-box/cmd_api_output.go new file mode 100644 index 00000000..f56dca30 --- /dev/null +++ b/cmd/sing-box/cmd_api_output.go @@ -0,0 +1,149 @@ +package main + +import ( + "os" + "strings" + + "github.com/sagernet/sing/common" + + "github.com/mattn/go-runewidth" + "golang.org/x/term" +) + +var ( + stdoutIsTerminal = term.IsTerminal(int(os.Stdout.Fd())) + stderrIsTerminal = term.IsTerminal(int(os.Stderr.Fd())) +) + +func writeStderrLine(message string) { + if !stderrIsTerminal { + return + } + os.Stderr.WriteString(message + "\n") +} + +func writeProgress(message string) { + if !stderrIsTerminal { + return + } + os.Stderr.WriteString("\r" + message) +} + +func stripColors(message string) string { + if !strings.Contains(message, "\x1b[") { + return message + } + var builder strings.Builder + start := 0 + for index := 0; index < len(message); { + if message[index] != '\x1b' || index+1 >= len(message) || message[index+1] != '[' { + index++ + continue + } + end := index + 2 + for end < len(message) && message[end] != 'm' { + end++ + } + if end >= len(message) { + break + } + builder.WriteString(message[start:index]) + index = end + 1 + start = index + } + builder.WriteString(message[start:]) + return builder.String() +} + +type tableWriter struct { + header []string + emptyMessage string + rows [][]string +} + +func (t *tableWriter) addRow(cells ...string) { + t.rows = append(t.rows, common.Map(cells, func(it string) string { + if it == "" { + return "-" + } + return it + })) +} + +func (t *tableWriter) flush() { + if len(t.rows) == 0 { + writeStderrLine(t.emptyMessage) + return + } + if !stdoutIsTerminal { + var output strings.Builder + for _, row := range t.rows { + output.WriteString(strings.Join(row, "\t")) + output.WriteString("\n") + } + os.Stdout.WriteString(output.String()) + return + } + widths := common.Map(t.header, func(it string) int { + return runewidth.StringWidth(it) + }) + for _, row := range t.rows { + for index, cell := range row { + widths[index] = max(widths[index], runewidth.StringWidth(cell)) + } + } + renderRow := func(cells []string) string { + var builder strings.Builder + for index, cell := range cells { + if index > 0 { + builder.WriteString(" ") + } + builder.WriteString(cell) + if index < len(cells)-1 { + builder.WriteString(strings.Repeat(" ", widths[index]-runewidth.StringWidth(cell))) + } + } + return builder.String() + } + writeStderrLine(renderRow(t.header)) + var output strings.Builder + for _, row := range t.rows { + output.WriteString(renderRow(row)) + output.WriteString("\n") + } + os.Stdout.WriteString(output.String()) +} + +type blockLine struct { + label string + value string +} + +type blockWriter struct { + lines []blockLine +} + +func (b *blockWriter) addLine(label string, value string) { + if value == "" { + value = "-" + } + b.lines = append(b.lines, blockLine{label: label, value: value}) +} + +func (b *blockWriter) flush() { + if len(b.lines) == 0 { + return + } + labelWidth := len(common.MaxBy(b.lines, func(it blockLine) int { + return len(it.label) + }).label) + 3 + var output strings.Builder + for _, line := range b.lines { + output.WriteString(line.label) + output.WriteString(":") + output.WriteString(strings.Repeat(" ", labelWidth-len(line.label)-1)) + output.WriteString(line.value) + output.WriteString("\n") + } + os.Stdout.WriteString(output.String()) +} diff --git a/cmd/sing-box/cmd_api_status.go b/cmd/sing-box/cmd_api_status.go new file mode 100644 index 00000000..2ac0c0d5 --- /dev/null +++ b/cmd/sing-box/cmd_api_status.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing/common/byteformats" + F "github.com/sagernet/sing/common/format" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" +) + +var commandAPIStatus = &cobra.Command{ + Use: "status", + Short: "Print the service status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIStatus() + }, +} + +func init() { + commandAPIRoot.AddCommand(commandAPIStatus) +} + +func runAPIStatus() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + ctx, cancel := context.WithCancel(globalCtx) + defer cancel() + statusStream, err := client.SubscribeStatus(ctx, &daemon.SubscribeStatusRequest{Interval: int64(time.Second)}) + if err != nil { + return err + } + var ( + waitGroup sync.WaitGroup + serviceStatus *daemon.ServiceStatus + startedAt *daemon.StartedAt + ) + waitGroup.Go(func() { + serviceStatusStream, statusErr := client.SubscribeServiceStatus(ctx, &emptypb.Empty{}) + if statusErr != nil { + return + } + serviceStatus, _ = serviceStatusStream.Recv() + }) + waitGroup.Go(func() { + startedAt, _ = client.GetStartedAt(ctx, &emptypb.Empty{}) + }) + status, err := statusStream.Recv() + if err != nil { + return err + } + rateStatus, err := statusStream.Recv() + if err == nil { + status = rateStatus + } + waitGroup.Wait() + + var state string + if serviceStatus != nil { + state = strings.ToLower(serviceStatus.GetStatus().String()) + } + var uptime string + if startedAt.GetStartedAt() > 0 { + uptime = time.Since(time.UnixMilli(startedAt.GetStartedAt())).Truncate(time.Second).String() + } + var connections string + if status.GetTrafficAvailable() { + connections = F.ToString(status.GetConnectionsIn(), " in / ", status.GetConnectionsOut(), " out") + } else { + connections = F.ToString("- in / ", status.GetConnectionsOut(), " out") + } + var uplink, downlink string + if status.GetTrafficAvailable() { + uplink = F.ToString(byteformats.FormatBytes(uint64(status.GetUplink())), "/s (", byteformats.FormatBytes(uint64(status.GetUplinkTotal())), " total)") + downlink = F.ToString(byteformats.FormatBytes(uint64(status.GetDownlink())), "/s (", byteformats.FormatBytes(uint64(status.GetDownlinkTotal())), " total)") + } + var block blockWriter + block.addLine("State", state) + block.addLine("Uptime", uptime) + block.addLine("Memory", byteformats.FormatMemoryBytes(status.GetMemory())) + block.addLine("Goroutines", F.ToString(status.GetGoroutines())) + block.addLine("Connections", connections) + block.addLine("Uplink", uplink) + block.addLine("Downlink", downlink) + if serviceStatus.GetStatus() == daemon.ServiceStatus_FATAL { + block.addLine("Error", serviceStatus.GetErrorMessage()) + } + block.flush() + return nil +} diff --git a/cmd/sing-box/cmd_api_stun.go b/cmd/sing-box/cmd_api_stun.go new file mode 100644 index 00000000..0fd54bdd --- /dev/null +++ b/cmd/sing-box/cmd_api_stun.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "os" + + "github.com/sagernet/sing-box/common/stun" + "github.com/sagernet/sing-box/daemon" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var ( + commandAPISTUNFlagServer string + commandAPISTUNFlagOutbound string +) + +var commandAPISTUN = &cobra.Command{ + Use: "stun", + Short: "Run a STUN test", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPISTUN() + }, +} + +func init() { + commandAPISTUN.Flags().StringVar(&commandAPISTUNFlagServer, "server", stun.DefaultServer, "STUN server address") + commandAPISTUN.Flags().StringVarP(&commandAPISTUNFlagOutbound, "outbound", "o", "", "Use specified tag instead of default outbound") + commandAPIRoot.AddCommand(commandAPISTUN) +} + +func runAPISTUN() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + stream, err := client.StartSTUNTest(globalCtx, &daemon.STUNTestRequest{ + Server: commandAPISTUNFlagServer, + OutboundTag: commandAPISTUNFlagOutbound, + }) + if err != nil { + return err + } + writeStderrLine("==== STUN TEST ====") + for { + progress, recvErr := stream.Recv() + if recvErr != nil { + return recvErr + } + if !progress.GetIsFinal() { + switch stun.Phase(progress.GetPhase()) { + case stun.PhaseBinding: + if progress.GetExternalAddr() != "" { + writeProgress(fmt.Sprintf("External Address: %s (%d ms)", progress.GetExternalAddr(), progress.GetLatencyMs())) + } else { + writeProgress("Sending binding request...") + } + case stun.PhaseNATMapping: + writeProgress("Detecting NAT mapping behavior...") + case stun.PhaseNATFiltering: + writeProgress("Detecting NAT filtering behavior...") + } + continue + } + writeStderrLine("") + if progress.GetError() != "" { + return E.New(progress.GetError()) + } + fmt.Fprintf(os.Stdout, "External Address: %s\n", progress.GetExternalAddr()) + fmt.Fprintf(os.Stdout, "Latency: %d ms\n", progress.GetLatencyMs()) + if progress.GetNatTypeSupported() { + fmt.Fprintf(os.Stdout, "NAT Mapping: %s\n", stun.NATMapping(progress.GetNatMapping())) + fmt.Fprintf(os.Stdout, "NAT Filtering: %s\n", stun.NATFiltering(progress.GetNatFiltering())) + } else { + fmt.Fprintln(os.Stdout, "NAT Type Detection: not supported by server") + } + return nil + } +} diff --git a/cmd/sing-box/cmd_api_tailscale.go b/cmd/sing-box/cmd_api_tailscale.go new file mode 100644 index 00000000..2511c283 --- /dev/null +++ b/cmd/sing-box/cmd_api_tailscale.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "strings" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" +) + +const commandAPITailscaleEndpointUsage = "Tailscale endpoint tag (default: the only Tailscale endpoint)" + +var commandAPITailscaleFlagEndpoint string + +var commandAPITailscale = &cobra.Command{ + Use: "tailscale", + Short: "Manage Tailscale endpoints", +} + +func init() { + commandAPIRoot.AddCommand(commandAPITailscale) +} + +func fetchTailscaleStatus(client daemon.StartedServiceClient) ([]*daemon.TailscaleEndpointStatus, error) { + ctx, cancel := context.WithCancel(globalCtx) + defer cancel() + stream, err := client.SubscribeTailscaleStatus(ctx, &emptypb.Empty{}) + if err != nil { + return nil, err + } + update, err := stream.Recv() + if err != nil { + return nil, err + } + endpoints := update.GetEndpoints() + common.SortBy(endpoints, func(it *daemon.TailscaleEndpointStatus) string { + return it.GetEndpointTag() + }) + return endpoints, nil +} + +func resolveTailscaleEndpointStatus(endpoints []*daemon.TailscaleEndpointStatus) (*daemon.TailscaleEndpointStatus, error) { + if len(endpoints) == 0 { + return nil, E.New("no tailscale endpoint found") + } + if commandAPITailscaleFlagEndpoint != "" { + endpoint := common.Find(endpoints, func(it *daemon.TailscaleEndpointStatus) bool { + return it.GetEndpointTag() == commandAPITailscaleFlagEndpoint + }) + if endpoint == nil { + return nil, E.New("unknown tailscale endpoint: ", commandAPITailscaleFlagEndpoint, "\nknown endpoints:\n", formatTailscaleEndpointTags(endpoints)) + } + return endpoint, nil + } + if len(endpoints) > 1 { + return nil, E.New("multiple tailscale endpoints, use --endpoint to select one:\n", formatTailscaleEndpointTags(endpoints)) + } + return endpoints[0], nil +} + +func fetchTailscaleEndpoint(client daemon.StartedServiceClient) (*daemon.TailscaleEndpointStatus, error) { + endpoints, err := fetchTailscaleStatus(client) + if err != nil { + return nil, err + } + return resolveTailscaleEndpointStatus(endpoints) +} + +func resolveTailscaleEndpointTag(client daemon.StartedServiceClient) (string, error) { + if commandAPITailscaleFlagEndpoint != "" { + return commandAPITailscaleFlagEndpoint, nil + } + endpoint, err := fetchTailscaleEndpoint(client) + if err != nil { + return "", err + } + return endpoint.GetEndpointTag(), nil +} + +func formatTailscaleEndpointTags(endpoints []*daemon.TailscaleEndpointStatus) string { + return strings.Join(common.Map(endpoints, func(it *daemon.TailscaleEndpointStatus) string { + return " " + it.GetEndpointTag() + }), "\n") +} diff --git a/cmd/sing-box/cmd_api_tailscale_exit_node.go b/cmd/sing-box/cmd_api_tailscale_exit_node.go new file mode 100644 index 00000000..d056a648 --- /dev/null +++ b/cmd/sing-box/cmd_api_tailscale_exit_node.go @@ -0,0 +1,48 @@ +package main + +import ( + "os" + + F "github.com/sagernet/sing/common/format" + + "github.com/spf13/cobra" +) + +var commandAPITailscaleExitNode = &cobra.Command{ + Use: "exit-node", + Short: "Print the current Tailscale exit node", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPITailscaleExitNode() + }, +} + +func init() { + commandAPITailscaleExitNode.PersistentFlags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage) + commandAPITailscale.AddCommand(commandAPITailscaleExitNode) +} + +func runAPITailscaleExitNode() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + endpoint, err := fetchTailscaleEndpoint(client) + if err != nil { + return err + } + exitNode := endpoint.GetExitNode() + if exitNode == nil { + os.Stdout.WriteString("none\n") + return nil + } + name := tailscalePeerName(exitNode) + address := tailscalePeerAddress(exitNode) + if address == "" { + os.Stdout.WriteString(name + "\n") + return nil + } + os.Stdout.WriteString(F.ToString(name, " (", address, ")", "\n")) + return nil +} diff --git a/cmd/sing-box/cmd_api_tailscale_exit_node_clear.go b/cmd/sing-box/cmd_api_tailscale_exit_node_clear.go new file mode 100644 index 00000000..276a76dc --- /dev/null +++ b/cmd/sing-box/cmd_api_tailscale_exit_node_clear.go @@ -0,0 +1,36 @@ +package main + +import ( + "github.com/sagernet/sing-box/daemon" + + "github.com/spf13/cobra" +) + +var commandAPITailscaleExitNodeClear = &cobra.Command{ + Use: "clear", + Short: "Stop using a Tailscale exit node", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPITailscaleExitNodeClear() + }, +} + +func init() { + commandAPITailscaleExitNode.AddCommand(commandAPITailscaleExitNodeClear) +} + +func runAPITailscaleExitNodeClear() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + endpointTag, err := resolveTailscaleEndpointTag(client) + if err != nil { + return err + } + _, err = client.SetTailscaleExitNode(globalCtx, &daemon.SetTailscaleExitNodeRequest{ + EndpointTag: endpointTag, + }) + return err +} diff --git a/cmd/sing-box/cmd_api_tailscale_exit_node_list.go b/cmd/sing-box/cmd_api_tailscale_exit_node_list.go new file mode 100644 index 00000000..6c1b2198 --- /dev/null +++ b/cmd/sing-box/cmd_api_tailscale_exit_node_list.go @@ -0,0 +1,55 @@ +package main + +import ( + "github.com/sagernet/sing/common" + + "github.com/spf13/cobra" +) + +var commandAPITailscaleExitNodeList = &cobra.Command{ + Use: "list", + Short: "List available Tailscale exit nodes", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPITailscaleExitNodeList() + }, +} + +func init() { + commandAPITailscaleExitNode.AddCommand(commandAPITailscaleExitNodeList) +} + +func runAPITailscaleExitNodeList() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + endpoint, err := fetchTailscaleEndpoint(client) + if err != nil { + return err + } + selectedStableID := endpoint.GetExitNode().GetStableID() + candidates := common.Filter(tailscalePeerEntries(endpoint), func(it tailscalePeerEntry) bool { + return !it.self && it.peer.GetExitNodeOption() + }) + sortTailscalePeerEntries(candidates) + table := tableWriter{ + header: []string{"DNS NAME", "IP", "ONLINE", "STATUS"}, + emptyMessage: "no exit nodes", + } + for _, entry := range candidates { + var exitNodeStatus string + if selectedStableID != "" && entry.peer.GetStableID() == selectedStableID { + exitNodeStatus = "selected" + } + table.addRow( + tailscalePeerName(entry.peer), + tailscalePeerAddress(entry.peer), + formatYesNo(entry.peer.GetOnline()), + exitNodeStatus, + ) + } + table.flush() + return nil +} diff --git a/cmd/sing-box/cmd_api_tailscale_exit_node_set.go b/cmd/sing-box/cmd_api_tailscale_exit_node_set.go new file mode 100644 index 00000000..29d4c74b --- /dev/null +++ b/cmd/sing-box/cmd_api_tailscale_exit_node_set.go @@ -0,0 +1,41 @@ +package main + +import ( + "github.com/sagernet/sing-box/daemon" + + "github.com/spf13/cobra" +) + +var commandAPITailscaleExitNodeSet = &cobra.Command{ + Use: "set ", + Short: "Use a Tailscale peer as exit node", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAPITailscaleExitNodeSet(args[0]) + }, +} + +func init() { + commandAPITailscaleExitNode.AddCommand(commandAPITailscaleExitNodeSet) +} + +func runAPITailscaleExitNodeSet(selector string) error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + endpoint, err := fetchTailscaleEndpoint(client) + if err != nil { + return err + } + entry, err := resolveTailscalePeer(tailscalePeerEntries(endpoint), selector) + if err != nil { + return err + } + _, err = client.SetTailscaleExitNode(globalCtx, &daemon.SetTailscaleExitNodeRequest{ + EndpointTag: endpoint.GetEndpointTag(), + StableID: entry.peer.GetStableID(), + }) + return err +} diff --git a/cmd/sing-box/cmd_api_tailscale_logout.go b/cmd/sing-box/cmd_api_tailscale_logout.go new file mode 100644 index 00000000..cfab0a4a --- /dev/null +++ b/cmd/sing-box/cmd_api_tailscale_logout.go @@ -0,0 +1,37 @@ +package main + +import ( + "github.com/sagernet/sing-box/daemon" + + "github.com/spf13/cobra" +) + +var commandAPITailscaleLogout = &cobra.Command{ + Use: "logout", + Short: "Log out of the Tailscale network", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPITailscaleLogout() + }, +} + +func init() { + commandAPITailscaleLogout.Flags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage) + commandAPITailscale.AddCommand(commandAPITailscaleLogout) +} + +func runAPITailscaleLogout() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + endpointTag, err := resolveTailscaleEndpointTag(client) + if err != nil { + return err + } + _, err = client.TailscaleLogout(globalCtx, &daemon.TailscaleLogoutRequest{ + EndpointTag: endpointTag, + }) + return err +} diff --git a/cmd/sing-box/cmd_api_tailscale_peer.go b/cmd/sing-box/cmd_api_tailscale_peer.go new file mode 100644 index 00000000..43cc0c1d --- /dev/null +++ b/cmd/sing-box/cmd_api_tailscale_peer.go @@ -0,0 +1,161 @@ +package main + +import ( + "net/netip" + "slices" + "strings" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var commandAPITailscalePeer = &cobra.Command{ + Use: "peer", + Short: "Manage Tailscale peers", +} + +func init() { + commandAPITailscalePeer.PersistentFlags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage) + commandAPITailscale.AddCommand(commandAPITailscalePeer) +} + +type tailscalePeerEntry struct { + peer *daemon.TailscalePeer + group *daemon.TailscaleUserGroup + self bool +} + +func tailscalePeerEntries(endpoint *daemon.TailscaleEndpointStatus) []tailscalePeerEntry { + var entries []tailscalePeerEntry + if endpoint.GetSelf() != nil { + entries = append(entries, tailscalePeerEntry{peer: endpoint.GetSelf(), self: true}) + } + for _, group := range endpoint.GetUserGroups() { + for _, peer := range group.GetPeers() { + entries = append(entries, tailscalePeerEntry{peer: peer, group: group}) + } + } + return entries +} + +func resolveTailscalePeer(entries []tailscalePeerEntry, selector string) (tailscalePeerEntry, error) { + selectorAddress, addressErr := netip.ParseAddr(selector) + matchers := []func(peer *daemon.TailscalePeer) bool{ + func(peer *daemon.TailscalePeer) bool { + return peer.GetStableID() == selector + }, + func(peer *daemon.TailscalePeer) bool { + if addressErr != nil { + return false + } + return slices.ContainsFunc(peer.GetTailscaleIPs(), func(it string) bool { + address, parseErr := netip.ParseAddr(it) + if parseErr != nil { + return false + } + return address.Unmap() == selectorAddress.Unmap() + }) + }, + func(peer *daemon.TailscalePeer) bool { + dnsName := peer.GetDnsName() + if dnsName == "" { + return false + } + return strings.EqualFold(dnsName, selector) || strings.EqualFold(dns.FqdnToDomain(dnsName), selector) + }, + func(peer *daemon.TailscalePeer) bool { + label, _, _ := strings.Cut(peer.GetDnsName(), ".") + if label == "" { + return false + } + return strings.EqualFold(label, selector) + }, + func(peer *daemon.TailscalePeer) bool { + hostName := peer.GetHostName() + if hostName == "" { + return false + } + return strings.EqualFold(hostName, selector) + }, + } + for _, matcher := range matchers { + matches := common.Filter(entries, func(it tailscalePeerEntry) bool { + return matcher(it.peer) + }) + if len(matches) == 1 { + return matches[0], nil + } + if len(matches) > 1 { + return tailscalePeerEntry{}, newTailscaleAmbiguousPeerError(selector, matches) + } + } + return tailscalePeerEntry{}, E.New("peer not found: ", selector) +} + +func newTailscaleAmbiguousPeerError(selector string, matches []tailscalePeerEntry) error { + sortTailscalePeerEntries(matches) + names := common.Map(matches, func(it tailscalePeerEntry) string { + return tailscalePeerName(it.peer) + }) + addresses := common.Map(matches, func(it tailscalePeerEntry) string { + address := tailscalePeerAddress(it.peer) + if address == "" { + return "-" + } + return address + }) + nameWidth := len(common.MaxBy(names, func(it string) int { + return len(it) + })) + addressWidth := len(common.MaxBy(addresses, func(it string) int { + return len(it) + })) + var builder strings.Builder + builder.WriteString("ambiguous peer: ") + builder.WriteString(selector) + for index, entry := range matches { + builder.WriteString("\n ") + builder.WriteString(names[index]) + builder.WriteString(strings.Repeat(" ", nameWidth-len(names[index])+3)) + builder.WriteString(addresses[index]) + builder.WriteString(strings.Repeat(" ", addressWidth-len(addresses[index])+3)) + builder.WriteString(entry.peer.GetStableID()) + } + return E.New(builder.String()) +} + +func sortTailscalePeerEntries(entries []tailscalePeerEntry) { + common.SortBy(entries, func(it tailscalePeerEntry) string { + return strings.ToLower(tailscalePeerName(it.peer)) + }) +} + +func tailscalePeerName(peer *daemon.TailscalePeer) string { + name := dns.FqdnToDomain(peer.GetDnsName()) + if name == "" { + name = peer.GetHostName() + } + if name == "" { + name = peer.GetStableID() + } + return name +} + +func tailscalePeerAddress(peer *daemon.TailscalePeer) string { + addresses := peer.GetTailscaleIPs() + if len(addresses) == 0 { + return "" + } + return addresses[0] +} + +func formatYesNo(value bool) string { + if value { + return "yes" + } + return "no" +} diff --git a/cmd/sing-box/cmd_api_tailscale_peer_list.go b/cmd/sing-box/cmd_api_tailscale_peer_list.go new file mode 100644 index 00000000..eadbeb5b --- /dev/null +++ b/cmd/sing-box/cmd_api_tailscale_peer_list.go @@ -0,0 +1,49 @@ +package main + +import ( + "github.com/spf13/cobra" +) + +var commandAPITailscalePeerList = &cobra.Command{ + Use: "list", + Short: "List Tailscale peers", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPITailscalePeerList() + }, +} + +func init() { + commandAPITailscalePeer.AddCommand(commandAPITailscalePeerList) +} + +func runAPITailscalePeerList() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + endpoint, err := fetchTailscaleEndpoint(client) + if err != nil { + return err + } + entries := tailscalePeerEntries(endpoint) + sortableEntries := entries + if len(sortableEntries) > 0 && sortableEntries[0].self { + sortableEntries = sortableEntries[1:] + } + sortTailscalePeerEntries(sortableEntries) + table := tableWriter{ + header: []string{"DNS NAME", "IP", "ONLINE"}, + emptyMessage: "no peers", + } + for _, entry := range entries { + table.addRow( + tailscalePeerName(entry.peer), + tailscalePeerAddress(entry.peer), + formatYesNo(entry.peer.GetOnline()), + ) + } + table.flush() + return nil +} diff --git a/cmd/sing-box/cmd_api_tailscale_peer_show.go b/cmd/sing-box/cmd_api_tailscale_peer_show.go new file mode 100644 index 00000000..4aefa6a9 --- /dev/null +++ b/cmd/sing-box/cmd_api_tailscale_peer_show.go @@ -0,0 +1,102 @@ +package main + +import ( + "strings" + "time" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/dns" + F "github.com/sagernet/sing/common/format" + + "github.com/spf13/cobra" +) + +const commandAPITailscalePeerLabelWidth = len("SSH host keys") + 3 + +var commandAPITailscalePeerShow = &cobra.Command{ + Use: "show ", + Short: "Print Tailscale peer details", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAPITailscalePeerShow(args[0]) + }, +} + +func init() { + commandAPITailscalePeer.AddCommand(commandAPITailscalePeerShow) +} + +func runAPITailscalePeerShow(selector string) error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + endpoint, err := fetchTailscaleEndpoint(client) + if err != nil { + return err + } + entry, err := resolveTailscalePeer(tailscalePeerEntries(endpoint), selector) + if err != nil { + return err + } + peer := entry.peer + exitNode := "no" + if peer.GetExitNode() { + exitNode = "in use" + } else if peer.GetExitNodeOption() { + exitNode = "offered" + } + var block blockWriter + block.addLine("DNS name", dns.FqdnToDomain(peer.GetDnsName())) + block.addLine("Host name", peer.GetHostName()) + block.addLine("Stable ID", peer.GetStableID()) + block.addLine("User", formatTailscaleUser(entry.group)) + block.addLine("OS", peer.GetOs()) + block.addLine("IPs", strings.Join(peer.GetTailscaleIPs(), ", ")) + block.addLine("Online", formatYesNo(peer.GetOnline())) + block.addLine("Active", formatYesNo(peer.GetActive())) + block.addLine("Expired", formatYesNo(peer.GetExpired())) + block.addLine("Sharee node", formatYesNo(peer.GetShareeNode())) + block.addLine("Exit node", exitNode) + block.addLine("Rx", F.ToString(peer.GetRxBytes())) + block.addLine("Tx", F.ToString(peer.GetTxBytes())) + block.addLine("Key expiry", formatTailscaleTime(peer.GetKeyExpiry())) + block.addLine("Last seen", formatTailscaleTime(peer.GetLastSeen())) + block.addLine("SSH host keys", formatTailscaleSSHHostKeys(peer.GetSshHostKeys())) + block.flush() + return nil +} + +func formatTailscaleUser(group *daemon.TailscaleUserGroup) string { + loginName := group.GetLoginName() + displayName := group.GetDisplayName() + if displayName == "" || displayName == loginName { + return loginName + } + if loginName == "" { + return displayName + } + return F.ToString(loginName, " (", displayName, ")") +} + +func formatTailscaleTime(timestamp int64) string { + if timestamp == 0 { + return "" + } + return time.Unix(timestamp, 0).Local().Format(time.RFC3339) +} + +func formatTailscaleSSHHostKeys(hostKeys []string) string { + if len(hostKeys) == 0 { + return "" + } + var builder strings.Builder + builder.WriteString(F.ToString(len(hostKeys))) + for _, hostKey := range hostKeys { + builder.WriteString("\n") + builder.WriteString(strings.Repeat(" ", commandAPITailscalePeerLabelWidth)) + builder.WriteString(hostKey) + } + return builder.String() +} diff --git a/cmd/sing-box/cmd_api_tailscale_ping.go b/cmd/sing-box/cmd_api_tailscale_ping.go new file mode 100644 index 00000000..0269e48f --- /dev/null +++ b/cmd/sing-box/cmd_api_tailscale_ping.go @@ -0,0 +1,133 @@ +package main + +import ( + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/sagernet/sing-box/daemon" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + + "github.com/spf13/cobra" +) + +const ( + commandAPITailscalePingCount = 10 + commandAPITailscalePingTimeout = 5 * time.Second +) + +var commandAPITailscalePing = &cobra.Command{ + Use: "ping ", + Short: "Ping a Tailscale peer", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAPITailscalePing(args[0]) + }, +} + +func init() { + commandAPITailscalePing.Flags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage) + commandAPITailscale.AddCommand(commandAPITailscalePing) +} + +func runAPITailscalePing(selector string) error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + endpoint, err := fetchTailscaleEndpoint(client) + if err != nil { + return err + } + entry, err := resolveTailscalePeer(tailscalePeerEntries(endpoint), selector) + if err != nil { + return err + } + peerAddress := tailscalePeerAddress(entry.peer) + if peerAddress == "" { + return E.New("peer has no tailscale address: ", tailscalePeerName(entry.peer)) + } + peerName, _, _ := strings.Cut(tailscalePeerName(entry.peer), ".") + ctx, cancel := signal.NotifyContext(globalCtx, os.Interrupt, syscall.SIGTERM) + defer cancel() + stream, err := client.StartTailscalePing(ctx, &daemon.TailscalePingRequest{ + EndpointTag: endpoint.GetEndpointTag(), + PeerIP: peerAddress, + }) + if err != nil { + return err + } + responses := make(chan *daemon.TailscalePingResponse) + streamErrors := make(chan error, 1) + go func() { + for { + pingResponse, pingErr := stream.Recv() + if pingErr != nil { + streamErrors <- pingErr + return + } + select { + case responses <- pingResponse: + case <-ctx.Done(): + return + } + } + }() + timer := time.NewTimer(commandAPITailscalePingTimeout) + defer timer.Stop() + var pongCount int + for pongCount < commandAPITailscalePingCount { + var ( + response *daemon.TailscalePingResponse + recvErr error + ) + select { + case response = <-responses: + case recvErr = <-streamErrors: + case <-timer.C: + return E.New("no reply from ", peerName, " (", peerAddress, ") after ", commandAPITailscalePingTimeout.String()) + case <-ctx.Done(): + } + if ctx.Err() != nil { + if pongCount > 0 { + return nil + } + return E.New("interrupted") + } + if recvErr != nil { + return recvErr + } + if response.GetError() != "" { + return E.New("ping error: ", response.GetError()) + } + os.Stdout.WriteString(formatTailscalePong(peerName, peerAddress, response) + "\n") + pongCount++ + if response.GetEndpoint() != "" { + return nil + } + timer.Reset(commandAPITailscalePingTimeout) + } + os.Stdout.WriteString("direct connection not established\n") + return nil +} + +func formatTailscalePong(peerName string, peerAddress string, response *daemon.TailscalePingResponse) string { + via := response.GetEndpoint() + if via == "" { + if response.GetDerpRegionCode() != "" { + via = F.ToString("DERP(", response.GetDerpRegionCode(), ")") + } else { + via = F.ToString("DERP(", response.GetDerpRegionID(), ")") + } + } + latency := time.Duration(response.GetLatencyMs() * float64(time.Millisecond)) + rounded := latency.Round(time.Millisecond) + if rounded == 0 { + rounded = latency.Round(time.Microsecond) + } + return F.ToString("pong from ", peerName, " (", peerAddress, ") via ", via, " in ", rounded.String()) +} diff --git a/cmd/sing-box/cmd_api_tailscale_ssh.go b/cmd/sing-box/cmd_api_tailscale_ssh.go new file mode 100644 index 00000000..f6bbcc46 --- /dev/null +++ b/cmd/sing-box/cmd_api_tailscale_ssh.go @@ -0,0 +1,78 @@ +package main + +import ( + "os/exec" + "os/user" + "strings" + + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var commandAPITailscaleSSH = &cobra.Command{ + Use: "ssh [user@]", + Short: "SSH into a Tailscale peer", + Long: "SSH into a Tailscale peer.\n\n" + + "The local ssh binary is executed against the Tailscale address of the peer, so the machine running " + + "this command must be able to route Tailscale addresses into the Tailscale endpoint itself, " + + "usually by running behind a sing-box instance with a tun inbound.\n\n" + + "The user defaults to the current user, and Tailscale SSH authentication is not available: " + + "the peer is reached as an ordinary SSH server.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAPITailscaleSSH(args[0]) + }, +} + +func init() { + commandAPITailscaleSSH.Flags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage) + commandAPITailscale.AddCommand(commandAPITailscaleSSH) +} + +func runAPITailscaleSSH(target string) error { + loginName := "" + selector := target + nameIndex := strings.LastIndex(target, "@") + if nameIndex != -1 { + loginName = target[:nameIndex] + selector = target[nameIndex+1:] + } + if loginName == "" { + currentUser, userErr := user.Current() + if userErr == nil { + loginName = currentUser.Username + _, domainUserName, isDomainUser := strings.Cut(loginName, "\\") + if isDomainUser { + loginName = domainUserName + } + } + } + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + endpoint, err := fetchTailscaleEndpoint(client) + if err != nil { + return err + } + entry, err := resolveTailscalePeer(tailscalePeerEntries(endpoint), selector) + if err != nil { + return err + } + peerAddress := tailscalePeerAddress(entry.peer) + if peerAddress == "" { + return E.New("peer has no tailscale address: ", tailscalePeerName(entry.peer)) + } + clientConn.Close() + sshPath, err := exec.LookPath("ssh") + if err != nil { + return E.New("ssh not found in PATH") + } + destination := peerAddress + if loginName != "" { + destination = loginName + "@" + peerAddress + } + return executeSSH(sshPath, []string{"ssh", destination}) +} diff --git a/cmd/sing-box/cmd_api_tailscale_ssh_unix.go b/cmd/sing-box/cmd_api_tailscale_ssh_unix.go new file mode 100644 index 00000000..509d49b7 --- /dev/null +++ b/cmd/sing-box/cmd_api_tailscale_ssh_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package main + +import ( + "os" + "syscall" +) + +func executeSSH(path string, argv []string) error { + return syscall.Exec(path, argv, os.Environ()) +} diff --git a/cmd/sing-box/cmd_api_tailscale_ssh_windows.go b/cmd/sing-box/cmd_api_tailscale_ssh_windows.go new file mode 100644 index 00000000..d7e24e68 --- /dev/null +++ b/cmd/sing-box/cmd_api_tailscale_ssh_windows.go @@ -0,0 +1,20 @@ +package main + +import ( + "errors" + "os" + "os/exec" +) + +func executeSSH(path string, argv []string) error { + command := exec.Command(path, argv[1:]...) + command.Stdin = os.Stdin + command.Stdout = os.Stdout + command.Stderr = os.Stderr + err := command.Run() + var exitError *exec.ExitError + if errors.As(err, &exitError) { + os.Exit(exitError.ExitCode()) + } + return err +} diff --git a/cmd/sing-box/cmd_api_tailscale_status.go b/cmd/sing-box/cmd_api_tailscale_status.go new file mode 100644 index 00000000..61990484 --- /dev/null +++ b/cmd/sing-box/cmd_api_tailscale_status.go @@ -0,0 +1,56 @@ +package main + +import ( + "os" + + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var commandAPITailscaleStatus = &cobra.Command{ + Use: "status", + Short: "Print the status of Tailscale endpoints", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPITailscaleStatus() + }, +} + +func init() { + commandAPITailscale.AddCommand(commandAPITailscaleStatus) +} + +func runAPITailscaleStatus() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + endpoints, err := fetchTailscaleStatus(client) + if err != nil { + return err + } + if len(endpoints) == 0 { + return E.New("no tailscale endpoint found") + } + for index, endpoint := range endpoints { + if index > 0 { + os.Stdout.WriteString("\n") + } + os.Stdout.WriteString(endpoint.GetEndpointTag() + "\n") + var block blockWriter + block.addLine(" State", endpoint.GetBackendState()) + if endpoint.GetNetworkName() != "" { + block.addLine(" Network", endpoint.GetNetworkName()) + } + if endpoint.GetKeyAuth() { + block.addLine(" Auth", "auth key") + } + if endpoint.GetAuthURL() != "" { + block.addLine(" Log in", endpoint.GetAuthURL()) + } + block.flush() + } + return nil +} diff --git a/cmd/sing-box/cmd_api_usbip.go b/cmd/sing-box/cmd_api_usbip.go new file mode 100644 index 00000000..1deab7f3 --- /dev/null +++ b/cmd/sing-box/cmd_api_usbip.go @@ -0,0 +1,65 @@ +package main + +import ( + "github.com/spf13/cobra" +) + +var ( + commandAPIUsbipStatusFlagService string + commandAPIUsbipShareFlagService string + commandAPIUsbipShareFlagAll bool + commandAPIUsbipShareFlagCapture bool +) + +var commandAPIUsbip = &cobra.Command{ + Use: "usbip", + Short: "Manage USB/IP device sharing", +} + +var commandAPIUsbipDevice = &cobra.Command{ + Use: "device", + Short: "Manage local USB devices", +} + +var commandAPIUsbipDeviceList = &cobra.Command{ + Use: "list", + Short: "List local USB devices", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIUsbipDeviceList() + }, +} + +var commandAPIUsbipDeviceShow = &cobra.Command{ + Use: "show ", + Short: "Print a local USB device", + Long: "Print a local USB device.\n\n" + + "The device is selected by bus id, by vid:pid, or by vid:pid:serial.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIUsbipDeviceShow(args[0]) + }, +} + +var commandAPIUsbipShare = &cobra.Command{ + Use: "share ...", + Short: "Share local USB devices through a usbip-server", + Long: "Share local USB devices through a usbip-server.\n\n" + + "Each device is selected by bus id, by vid:pid, or by vid:pid:serial.\n" + + "Sharing lasts until the command is interrupted.", + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIUsbipShare(args) + }, +} + +func init() { + commandAPIUsbipShare.Flags().StringVar(&commandAPIUsbipShareFlagService, "service", "", "usbip-server tag (default: the only usbip-server with dynamic provider)") + commandAPIUsbipShare.Flags().BoolVar(&commandAPIUsbipShareFlagAll, "all", false, "Share every local USB device, including newly plugged ones") + commandAPIUsbipShare.Flags().BoolVar(&commandAPIUsbipShareFlagCapture, "capture", false, "Detach devices from their current driver before sharing (requires root or Administrator)") + commandAPIUsbipDevice.AddCommand(commandAPIUsbipDeviceList) + commandAPIUsbipDevice.AddCommand(commandAPIUsbipDeviceShow) + commandAPIUsbip.AddCommand(commandAPIUsbipDevice) + commandAPIUsbip.AddCommand(commandAPIUsbipShare) + commandAPIRoot.AddCommand(commandAPIUsbip) +} diff --git a/cmd/sing-box/cmd_api_usbip_local.go b/cmd/sing-box/cmd_api_usbip_local.go new file mode 100644 index 00000000..1fd884bb --- /dev/null +++ b/cmd/sing-box/cmd_api_usbip_local.go @@ -0,0 +1,274 @@ +//go:build with_usbip && (linux || (darwin && cgo) || windows) + +package main + +import ( + "cmp" + "fmt" + "os" + "slices" + "strconv" + "strings" + + "github.com/sagernet/sing-usbip" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +func runAPIUsbipDeviceList() error { + devices, err := listLocalUSBDevices() + if err != nil { + return err + } + table := tableWriter{ + header: []string{"BUSID", "VID:PID", "PRODUCT", "SERIAL"}, + emptyMessage: "no local USB devices", + } + for _, device := range devices { + table.addRow( + device.Entry.Info.BusIDString(), + usbipVendorProduct(device.Entry.Info.IDVendor, device.Entry.Info.IDProduct), + device.Entry.Product, + device.Entry.Serial, + ) + } + table.flush() + return nil +} + +func runAPIUsbipDeviceShow(selector string) error { + devices, err := listLocalUSBDevices() + if err != nil { + return err + } + device, err := resolveLocalUSBDevice(devices, selector) + if err != nil { + return err + } + info := device.Entry.Info + var block blockWriter + block.addLine("busid", info.BusIDString()) + block.addLine("stable id", device.StableID) + block.addLine("backend", device.Backend.String()) + if device.Entry.Product != "" { + block.addLine("product", device.Entry.Product) + } + if device.Entry.Serial != "" { + block.addLine("serial", device.Entry.Serial) + } + block.addLine("vendor id", fmt.Sprintf("%04x", info.IDVendor)) + block.addLine("product id", fmt.Sprintf("%04x", info.IDProduct)) + block.addLine("device version", fmt.Sprintf("%x.%02x", info.BCDDevice>>8, info.BCDDevice&0xff)) + block.addLine("bus", F.ToString(info.BusNum)) + block.addLine("device", F.ToString(info.DevNum)) + block.addLine("speed", usbipSpeedString(info.Speed)) + block.addLine("device class", usbipDeviceClassString(info.BDeviceClass)) + block.addLine("device subclass", fmt.Sprintf("%02x", info.BDeviceSubClass)) + block.addLine("device protocol", fmt.Sprintf("%02x", info.BDeviceProtocol)) + if info.BNumConfigurations > 0 { + block.addLine("configurations", F.ToString(info.BNumConfigurations, " (active #", info.BConfigurationValue, ")")) + } + for index, deviceInterface := range device.Entry.Interfaces { + block.addLine(F.ToString("interface ", index), fmt.Sprintf( + "class %02x subclass %02x protocol %02x", + deviceInterface.BInterfaceClass, + deviceInterface.BInterfaceSubClass, + deviceInterface.BInterfaceProtocol, + )) + } + block.flush() + return nil +} + +// ListLocalDevices filters hubs on linux and darwin but not on windows. +func listLocalUSBDevices() ([]usbip.LocalDeviceInfo, error) { + devices, err := usbip.ListLocalDevices() + if err != nil { + return nil, err + } + devices = common.Filter(devices, func(it usbip.LocalDeviceInfo) bool { + return it.Entry.Info.BDeviceClass != 0x09 + }) + slices.SortFunc(devices, func(a usbip.LocalDeviceInfo, b usbip.LocalDeviceInfo) int { + return compareBusID(a.Entry.Info.BusIDString(), b.Entry.Info.BusIDString()) + }) + return devices, nil +} + +func resolveLocalUSBDevice(devices []usbip.LocalDeviceInfo, selector string) (usbip.LocalDeviceInfo, error) { + index := slices.IndexFunc(devices, func(it usbip.LocalDeviceInfo) bool { + return it.Entry.Info.BusIDString() == selector + }) + if index != -1 { + return devices[index], nil + } + vendorID, productID, serial, valid := parseLocalUSBDeviceSelector(selector) + if !valid { + return usbip.LocalDeviceInfo{}, E.New("no local USB device matches ", selector) + } + matches := common.Filter(devices, func(it usbip.LocalDeviceInfo) bool { + if it.Entry.Info.IDVendor != vendorID || it.Entry.Info.IDProduct != productID { + return false + } + return serial == "" || it.Entry.Serial == serial + }) + switch len(matches) { + case 0: + return usbip.LocalDeviceInfo{}, E.New("no local USB device matches ", selector) + case 1: + return matches[0], nil + } + writeLocalUSBDeviceMatches(matches) + return usbip.LocalDeviceInfo{}, E.New(selector, " matches ", len(matches), " devices") +} + +func parseLocalUSBDeviceSelector(selector string) (vendorID uint16, productID uint16, serial string, valid bool) { + parts := strings.SplitN(selector, ":", 3) + if len(parts) < 2 { + return 0, 0, "", false + } + vendorID, valid = parseUSBIdentifier(parts[0]) + if !valid { + return 0, 0, "", false + } + productID, valid = parseUSBIdentifier(parts[1]) + if !valid { + return 0, 0, "", false + } + if len(parts) == 3 { + serial = parts[2] + if serial == "" { + return 0, 0, "", false + } + } + return vendorID, productID, serial, true +} + +func parseUSBIdentifier(value string) (uint16, bool) { + if len(value) != 4 { + return 0, false + } + parsed, err := strconv.ParseUint(value, 16, 16) + if err != nil { + return 0, false + } + return uint16(parsed), true +} + +func writeLocalUSBDeviceMatches(matches []usbip.LocalDeviceInfo) { + busIDWidth := 0 + productWidth := 0 + for _, device := range matches { + busIDWidth = max(busIDWidth, len(device.Entry.Info.BusIDString())) + productWidth = max(productWidth, len(device.Entry.Product)) + } + var output strings.Builder + for _, device := range matches { + output.WriteString(" ") + output.WriteString(padUSBIPCell(device.Entry.Info.BusIDString(), busIDWidth)) + output.WriteString(" ") + output.WriteString(usbipVendorProduct(device.Entry.Info.IDVendor, device.Entry.Info.IDProduct)) + if device.Entry.Product != "" || device.Entry.Serial != "" { + output.WriteString(" ") + output.WriteString(padUSBIPCell(device.Entry.Product, productWidth)) + } + if device.Entry.Serial != "" { + output.WriteString(" (serial ") + output.WriteString(device.Entry.Serial) + output.WriteString(")") + } + output.WriteString("\n") + } + os.Stderr.WriteString(output.String()) +} + +func padUSBIPCell(value string, width int) string { + if len(value) >= width { + return value + } + return value + strings.Repeat(" ", width-len(value)) +} + +func compareBusID(a string, b string) int { + for len(a) > 0 && len(b) > 0 { + aDigits := usbipDigitPrefix(a) + bDigits := usbipDigitPrefix(b) + if aDigits > 0 && bDigits > 0 { + aValue, aErr := strconv.ParseUint(a[:aDigits], 10, 64) + bValue, bErr := strconv.ParseUint(b[:bDigits], 10, 64) + if aErr == nil && bErr == nil { + if aValue != bValue { + return cmp.Compare(aValue, bValue) + } + a = a[aDigits:] + b = b[bDigits:] + continue + } + } + if a[0] != b[0] { + return cmp.Compare(a[0], b[0]) + } + a = a[1:] + b = b[1:] + } + return cmp.Compare(len(a), len(b)) +} + +func usbipDigitPrefix(value string) int { + index := 0 + for index < len(value) && value[index] >= '0' && value[index] <= '9' { + index++ + } + return index +} + +func usbipSpeedString(speed uint32) string { + switch speed { + case usbip.SpeedLow: + return "low (1.5 Mbps)" + case usbip.SpeedFull: + return "full (12 Mbps)" + case usbip.SpeedHigh: + return "high (480 Mbps)" + case usbip.SpeedSuper: + return "super (5 Gbps)" + case usbip.SpeedSuperPlus: + return "super+ (10 Gbps)" + default: + return F.ToString(speed) + } +} + +var usbipDeviceClassNames = map[uint8]string{ + 0x00: "defined at interface level", + 0x01: "audio", + 0x02: "communications", + 0x03: "human interface device", + 0x05: "physical", + 0x06: "image", + 0x07: "printer", + 0x08: "mass storage", + 0x09: "hub", + 0x0a: "cdc data", + 0x0b: "smart card", + 0x0d: "content security", + 0x0e: "video", + 0x0f: "personal healthcare", + 0x10: "audio/video", + 0x11: "billboard", + 0x12: "usb type-c bridge", + 0xdc: "diagnostic", + 0xe0: "wireless controller", + 0xef: "miscellaneous", + 0xfe: "application specific", + 0xff: "vendor specific", +} + +func usbipDeviceClassString(deviceClass uint8) string { + name, found := usbipDeviceClassNames[deviceClass] + if !found { + return fmt.Sprintf("%02x", deviceClass) + } + return fmt.Sprintf("%02x (%s)", deviceClass, name) +} diff --git a/cmd/sing-box/cmd_api_usbip_share.go b/cmd/sing-box/cmd_api_usbip_share.go new file mode 100644 index 00000000..23b9c115 --- /dev/null +++ b/cmd/sing-box/cmd_api_usbip_share.go @@ -0,0 +1,838 @@ +//go:build with_usbip && (linux || (darwin && cgo) || windows) + +package main + +import ( + "context" + "errors" + "io" + "maps" + "os" + "os/signal" + "runtime" + "slices" + "strings" + "sync" + "syscall" + "time" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-usbip" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + + "google.golang.org/protobuf/types/known/emptypb" +) + +const ( + usbipShareDrainTimeout = 2 * time.Second + usbipShareQueueDepth = 64 + usbipShareMaxDeviceIDTry = 9 + usbipShareStatusEIO = -5 +) + +func runAPIUsbipShare(args []string) error { + if commandAPIUsbipShareFlagAll == (len(args) > 0) { + return E.New("either --all or one or more device selectors are required") + } + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + + sessionCtx, cancelSession := context.WithCancel(globalCtx) + defer cancelSession() + statusCtx, cancelStatus := context.WithCancel(sessionCtx) + defer cancelStatus() + watchCtx, cancelWatch := context.WithCancel(sessionCtx) + defer cancelWatch() + + statusStream, err := client.SubscribeUSBIPServerStatus(statusCtx, &emptypb.Empty{}) + if err != nil { + return err + } + statusUpdate, err := statusStream.Recv() + if err != nil { + return err + } + server, err := resolveUsbipServer(statusUpdate.GetServers(), commandAPIUsbipShareFlagService) + if err != nil { + return err + } + + localDevices, err := listLocalUSBDevices() + if err != nil { + return err + } + selected, err := selectSharedUSBDevices(localDevices, args) + if err != nil { + return err + } + + stream, err := client.ProvideUSBDevices(sessionCtx) + if err != nil { + return err + } + session := &usbipShareSession{ + ctx: sessionCtx, + serverTag: server.GetServerTag(), + capture: commandAPIUsbipShareFlagCapture, + shareAll: commandAPIUsbipShareFlagAll, + stream: stream, + devices: make(map[string]*usbipSharedDevice), + states: make(map[string]daemon.USBDeviceState), + failed: make(map[string]struct{}), + } + + signalChan := make(chan os.Signal, 2) + signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signalChan) + + receiveDone := make(chan struct{}) + go func() { + defer close(receiveDone) + session.receive() + }() + go session.readStatus(statusStream) + + for _, device := range selected { + shareErr := session.share(device, "") + if shareErr != nil { + cancelStatus() + session.closeAll() + return shareErr + } + } + + // The darwin watcher fires once at registration, so it is registered only after + // the initial devices are shared. + err = usbip.WatchLocalDevices(watchCtx, session.onLocalDevicesChanged) + if err != nil { + cancelStatus() + session.closeAll() + return E.Cause(err, "watch local USB devices") + } + + select { + case <-signalChan: + cancelWatch() + cancelStatus() + session.teardown(signalChan, receiveDone) + return nil + case <-receiveDone: + cancelWatch() + cancelStatus() + return session.abort() + } +} + +func selectSharedUSBDevices(devices []usbip.LocalDeviceInfo, selectors []string) ([]usbip.LocalDeviceInfo, error) { + if len(selectors) == 0 { + return devices, nil + } + selected := make([]usbip.LocalDeviceInfo, 0, len(selectors)) + for _, selector := range selectors { + device, err := resolveLocalUSBDevice(devices, selector) + if err != nil { + return nil, err + } + busID := device.Entry.Info.BusIDString() + if slices.ContainsFunc(selected, func(it usbip.LocalDeviceInfo) bool { + return it.Entry.Info.BusIDString() == busID + }) { + continue + } + selected = append(selected, device) + } + return selected, nil +} + +type usbipDeviceIdentity struct { + vendorID uint16 + productID uint16 + serial string + product string +} + +func usbipIdentityOf(device usbip.LocalDeviceInfo) usbipDeviceIdentity { + return usbipDeviceIdentity{ + vendorID: device.Entry.Info.IDVendor, + productID: device.Entry.Info.IDProduct, + serial: device.Entry.Serial, + product: device.Entry.Product, + } +} + +type usbipReconnectIntent struct { + identity usbipDeviceIdentity + deviceID string +} + +type usbipShareSession struct { + ctx context.Context + serverTag string + capture bool + shareAll bool + stream daemon.StartedService_ProvideUSBDevicesClient + + sendAccess sync.Mutex + printAccess sync.Mutex + + access sync.Mutex + closed bool + devices map[string]*usbipSharedDevice + intents []usbipReconnectIntent + states map[string]daemon.USBDeviceState + failed map[string]struct{} + + terminalError error + terminalReason string +} + +type usbipSharedDevice struct { + session *usbipShareSession + info usbip.LocalDeviceInfo + local usbip.LocalDevice + localBusID string + identity usbipDeviceIdentity + + deviceID string + attempt int + seenInStatus bool + + queueAccess sync.Mutex + queues map[uint8]chan *daemon.USBURBRequest + closed bool + closeOnce sync.Once + closeFinished chan struct{} +} + +func (s *usbipShareSession) share(info usbip.LocalDeviceInfo, preferredDeviceID string) error { + busID := info.Entry.Info.BusIDString() + local, err := usbip.OpenLocalDevice(busID, s.capture) + if err != nil { + s.access.Lock() + s.failed[busID] = struct{}{} + s.access.Unlock() + s.printEvent("error", busID, err.Error()) + s.writeOpenHint(err) + if usbipShareFatalOpenError(err) { + return err + } + return nil + } + deviceID := preferredDeviceID + if deviceID == "" { + deviceID = busID + } + device := &usbipSharedDevice{ + session: s, + info: info, + local: local, + localBusID: busID, + identity: usbipIdentityOf(info), + deviceID: deviceID, + attempt: 1, + queues: make(map[uint8]chan *daemon.USBURBRequest), + closeFinished: make(chan struct{}), + } + s.access.Lock() + if s.closed { + s.access.Unlock() + _ = local.Close() + return nil + } + _, taken := s.devices[deviceID] + if taken { + s.access.Unlock() + device.close() + s.printEvent("error", deviceID, "device id already in use by this session") + return nil + } + s.devices[deviceID] = device + s.access.Unlock() + sendErr := s.send(usbipAttachMessage(s.serverTag, deviceID, info)) + if sendErr != nil { + s.removeDevice(deviceID) + device.close() + s.printEvent("error", deviceID, sendErr.Error()) + } + return nil +} + +func (s *usbipShareSession) send(message *daemon.USBProviderMessage) error { + s.sendAccess.Lock() + defer s.sendAccess.Unlock() + return s.stream.Send(message) +} + +func (s *usbipShareSession) detach(deviceID string) { + _ = s.send(&daemon.USBProviderMessage{Message: &daemon.USBProviderMessage_Detach{ + Detach: &daemon.USBDeviceDetach{DeviceId: deviceID}, + }}) +} + +func (s *usbipShareSession) device(deviceID string) *usbipSharedDevice { + s.access.Lock() + defer s.access.Unlock() + return s.devices[deviceID] +} + +func (s *usbipShareSession) removeDevice(deviceID string) *usbipSharedDevice { + s.access.Lock() + defer s.access.Unlock() + device := s.devices[deviceID] + if device != nil { + delete(s.devices, deviceID) + delete(s.states, deviceID) + } + return device +} + +func (s *usbipShareSession) receive() { + for { + message, err := s.stream.Recv() + if err != nil { + if s.ctx.Err() == nil { + if err == io.EOF { + s.terminalError = E.New("provider stream closed by the API service") + } else { + s.terminalError = err + } + s.terminalReason = "stream-closed" + } + return + } + switch body := message.GetMessage().(type) { + case *daemon.USBServerMessage_Ready: + s.onReady(body.Ready) + case *daemon.USBServerMessage_UrbRequest: + device := s.device(body.UrbRequest.GetDeviceId()) + if device != nil { + device.submit(body.UrbRequest) + } + case *daemon.USBServerMessage_Abort: + s.onAbort(body.Abort) + case *daemon.USBServerMessage_Error: + if s.onError(body.Error) { + return + } + } + } +} + +func (s *usbipShareSession) onReady(ready *daemon.USBDeviceReady) { + device := s.device(ready.GetDeviceId()) + if device == nil { + return + } + busID := ready.GetDeviceId() + if busID != device.localBusID { + busID = F.ToString(busID, " (local ", device.localBusID, ")") + } + s.printEvent("shared", busID, + usbipVendorProduct(device.identity.vendorID, device.identity.productID), + device.identity.product, + ) +} + +func (s *usbipShareSession) onAbort(abort *daemon.USBEndpointAbort) { + device := s.device(abort.GetDeviceId()) + if device == nil { + return + } + err := device.local.AbortEndpoint(uint8(abort.GetEndpoint())) + if err != nil { + s.printEvent("error", abort.GetDeviceId(), err.Error()) + } +} + +func (s *usbipShareSession) onError(failure *daemon.USBError) bool { + deviceID := failure.GetDeviceId() + message := failure.GetMessage() + if deviceID == "" || + strings.Contains(message, "usbip-server not found:") || + strings.Contains(message, "is not a dynamic usbip-server") { + s.terminalError = E.New(message) + s.terminalReason = "server-error" + return true + } + if strings.Contains(message, "dynamic device already provided:") { + s.retryDeviceID(deviceID, message) + return false + } + device := s.removeDevice(deviceID) + if device != nil { + device.close() + } + s.printEvent("error", deviceID, message) + return false +} + +func (s *usbipShareSession) retryDeviceID(deviceID string, message string) { + s.access.Lock() + device := s.devices[deviceID] + if device == nil || device.attempt >= usbipShareMaxDeviceIDTry { + s.access.Unlock() + if device != nil { + s.removeDevice(deviceID) + device.close() + s.printEvent("error", deviceID, message) + } + return + } + delete(s.devices, deviceID) + delete(s.states, deviceID) + device.attempt++ + device.deviceID = F.ToString(device.localBusID, "#", device.attempt) + s.devices[device.deviceID] = device + retryID := device.deviceID + s.access.Unlock() + sendErr := s.send(usbipAttachMessage(s.serverTag, retryID, device.info)) + if sendErr != nil { + s.removeDevice(retryID) + device.close() + s.printEvent("error", retryID, sendErr.Error()) + } +} + +func (s *usbipShareSession) readStatus(stream daemon.StartedService_SubscribeUSBIPServerStatusClient) { + for { + update, err := stream.Recv() + if err != nil { + return + } + index := slices.IndexFunc(update.GetServers(), func(it *daemon.USBIPServerStatus) bool { + return it.GetServerTag() == s.serverTag + }) + if index == -1 { + continue + } + snapshot := make(map[string]daemon.USBDeviceState) + for _, device := range update.GetServers()[index].GetDevices() { + snapshot[device.GetBusId()] = device.GetState() + } + s.applyStatusSnapshot(snapshot) + } +} + +func (s *usbipShareSession) applyStatusSnapshot(snapshot map[string]daemon.USBDeviceState) { + type transition struct { + deviceID string + verb string + } + var ( + transitions []transition + lost []*usbipSharedDevice + ) + s.access.Lock() + for deviceID, device := range s.devices { + state, present := snapshot[deviceID] + if !present { + if device.seenInStatus { + device.seenInStatus = false + delete(s.states, deviceID) + lost = append(lost, device) + } + continue + } + previous, tracked := s.states[deviceID] + s.states[deviceID] = state + if !device.seenInStatus { + device.seenInStatus = true + continue + } + if !tracked || previous == state { + continue + } + switch { + case state == daemon.USBDeviceState_USB_DEVICE_STATE_ATTACHED: + transitions = append(transitions, transition{deviceID: deviceID, verb: "attached"}) + case previous == daemon.USBDeviceState_USB_DEVICE_STATE_ATTACHED: + transitions = append(transitions, transition{deviceID: deviceID, verb: "released"}) + } + } + s.access.Unlock() + + for _, item := range transitions { + s.printEvent(item.verb, item.deviceID) + } + for _, device := range lost { + s.printEvent("detached", device.deviceID, "server-error") + sendErr := s.send(usbipAttachMessage(s.serverTag, device.deviceID, device.info)) + if sendErr != nil { + s.removeDevice(device.deviceID) + device.close() + s.printEvent("error", device.deviceID, sendErr.Error()) + } + } +} + +func (s *usbipShareSession) onLocalDevicesChanged() { + devices, err := listLocalUSBDevices() + if err != nil { + return + } + present := make(map[string]struct{}, len(devices)) + for _, device := range devices { + present[device.Entry.Info.BusIDString()] = struct{}{} + } + + var vanished []*usbipSharedDevice + s.access.Lock() + if s.closed { + s.access.Unlock() + return + } + // Capturing a device on windows rewrites its reported vendor and product id, so + // presence is decided by bus id alone; the identity tuple is only used to match a + // replugged device back to the export it replaces. + for deviceID, device := range s.devices { + _, found := present[device.localBusID] + if found { + continue + } + delete(s.devices, deviceID) + delete(s.states, deviceID) + vanished = append(vanished, device) + } + for _, device := range vanished { + s.intents = append(s.intents, usbipReconnectIntent{identity: device.identity, deviceID: device.deviceID}) + } + for busID := range s.failed { + _, found := present[busID] + if !found { + delete(s.failed, busID) + } + } + s.access.Unlock() + + for _, device := range vanished { + s.detach(device.deviceID) + device.close() + s.printEvent("detached", device.deviceID, "unplugged") + } + s.reshare(devices) +} + +func (s *usbipShareSession) reshare(devices []usbip.LocalDeviceInfo) { + s.access.Lock() + if s.closed { + s.access.Unlock() + return + } + shared := make(map[string]struct{}, len(s.devices)) + for _, device := range s.devices { + shared[device.localBusID] = struct{}{} + } + intents := slices.Clone(s.intents) + failed := maps.Clone(s.failed) + s.access.Unlock() + + available := common.Filter(devices, func(it usbip.LocalDeviceInfo) bool { + _, found := shared[it.Entry.Info.BusIDString()] + return !found + }) + claimed := make(map[string]struct{}) + var pending []usbipReconnectIntent + for _, intent := range intents { + matches := common.Filter(available, func(it usbip.LocalDeviceInfo) bool { + _, found := claimed[it.Entry.Info.BusIDString()] + return !found && usbipIdentityOf(it) == intent.identity + }) + if len(matches) != 1 { + pending = append(pending, intent) + continue + } + claimed[matches[0].Entry.Info.BusIDString()] = struct{}{} + s.consumeIntent(intent) + _ = s.share(matches[0], intent.deviceID) + } + if !s.shareAll { + return + } + for _, device := range available { + busID := device.Entry.Info.BusIDString() + _, claimedHere := claimed[busID] + _, failedBefore := failed[busID] + if claimedHere || failedBefore { + continue + } + if slices.ContainsFunc(pending, func(it usbipReconnectIntent) bool { + return it.identity == usbipIdentityOf(device) + }) { + continue + } + _ = s.share(device, "") + } +} + +func (s *usbipShareSession) consumeIntent(intent usbipReconnectIntent) { + s.access.Lock() + defer s.access.Unlock() + index := slices.Index(s.intents, intent) + if index != -1 { + s.intents = slices.Delete(s.intents, index, index+1) + } +} + +func (s *usbipShareSession) takeDevices() []*usbipSharedDevice { + s.access.Lock() + defer s.access.Unlock() + s.closed = true + s.intents = nil + devices := slices.Collect(maps.Values(s.devices)) + s.devices = make(map[string]*usbipSharedDevice) + clear(s.states) + slices.SortFunc(devices, func(a *usbipSharedDevice, b *usbipSharedDevice) int { + return compareBusID(a.deviceID, b.deviceID) + }) + return devices +} + +func (s *usbipShareSession) teardown(signalChan <-chan os.Signal, receiveDone <-chan struct{}) { + done := make(chan struct{}) + go func() { + defer close(done) + devices := s.takeDevices() + for _, device := range devices { + s.detach(device.deviceID) + } + _ = s.stream.CloseSend() + timer := time.NewTimer(usbipShareDrainTimeout) + defer timer.Stop() + select { + case <-receiveDone: + case <-timer.C: + } + for _, device := range devices { + device.close() + } + for _, device := range devices { + s.printEvent("detached", device.deviceID, "signal") + } + }() + select { + case <-done: + case <-signalChan: + if runtime.GOOS == "windows" { + os.Stderr.WriteString("interrupted while stopping, USB devices may remain captured\n") + } + os.Exit(130) + } +} + +func (s *usbipShareSession) abort() error { + reason := s.terminalReason + if reason == "" { + reason = "stream-closed" + } + for _, device := range s.takeDevices() { + device.close() + s.printEvent("detached", device.deviceID, reason) + } + if s.terminalError == nil { + return E.New("provider stream closed") + } + return s.terminalError +} + +func (s *usbipShareSession) closeAll() { + for _, device := range s.takeDevices() { + device.close() + } +} + +func (s *usbipShareSession) printEvent(verb string, busID string, fields ...string) { + var builder strings.Builder + builder.WriteString(padUSBIPCell(verb, 9)) + builder.WriteString(" ") + builder.WriteString(padUSBIPCell(busID, 10)) + for _, field := range fields { + builder.WriteString(" ") + if field == "" { + field = "-" + } + builder.WriteString(field) + } + line := strings.TrimRight(builder.String(), " ") + "\n" + s.printAccess.Lock() + defer s.printAccess.Unlock() + os.Stdout.WriteString(line) +} + +func (s *usbipShareSession) writeOpenHint(err error) { + if errors.Is(err, os.ErrPermission) { + os.Stderr.WriteString("hint: run sing-box with elevated privileges to access this device\n") + return + } + if !s.capture { + os.Stderr.WriteString("hint: retry with --capture to take the device from the driver using it (requires root or Administrator)\n") + } +} + +// The VBoxUSB driver installation runs before any device is touched, so these +// failures apply to every device in the session. +func usbipShareFatalOpenError(err error) bool { + message := err.Error() + return strings.Contains(message, "enable SeLoadDriverPrivilege") || + strings.Contains(message, "install VBoxUSB drivers") +} + +func (d *usbipSharedDevice) submit(request *daemon.USBURBRequest) { + endpoint := uint8(request.GetEndpoint()) + d.queueAccess.Lock() + if d.closed { + d.queueAccess.Unlock() + d.sendResponse(usbipURBErrorResponse(request)) + return + } + queue := d.queues[endpoint] + if queue == nil { + queue = make(chan *daemon.USBURBRequest, usbipShareQueueDepth) + d.queues[endpoint] = queue + go d.runQueue(queue) + } + select { + case queue <- request: + d.queueAccess.Unlock() + default: + d.queueAccess.Unlock() + d.sendResponse(usbipURBErrorResponse(request)) + } +} + +func (d *usbipSharedDevice) runQueue(queue <-chan *daemon.USBURBRequest) { + for request := range queue { + result := d.local.Submit(usbipURBRequestFromProto(request)) + d.sendResponse(usbipURBResponseToProto(request, result)) + } +} + +func (d *usbipSharedDevice) sendResponse(response *daemon.USBURBResponse) { + _ = d.session.send(&daemon.USBProviderMessage{Message: &daemon.USBProviderMessage_UrbResponse{UrbResponse: response}}) +} + +func (d *usbipSharedDevice) close() { + d.closeOnce.Do(func() { + defer close(d.closeFinished) + d.queueAccess.Lock() + d.closed = true + for endpoint, queue := range d.queues { + close(queue) + delete(d.queues, endpoint) + } + d.queueAccess.Unlock() + _ = d.local.Close() + }) + <-d.closeFinished +} + +func usbipAttachMessage(serverTag string, deviceID string, info usbip.LocalDeviceInfo) *daemon.USBProviderMessage { + entry := info.Entry + return &daemon.USBProviderMessage{Message: &daemon.USBProviderMessage_Attach{Attach: &daemon.USBDeviceAttach{ + ServerTag: serverTag, + Descriptor_: &daemon.USBDeviceDescriptor{ + DeviceId: deviceID, + BusNum: entry.Info.BusNum, + DevNum: entry.Info.DevNum, + Speed: entry.Info.Speed, + VendorId: uint32(entry.Info.IDVendor), + ProductId: uint32(entry.Info.IDProduct), + BcdDevice: uint32(entry.Info.BCDDevice), + DeviceClass: uint32(entry.Info.BDeviceClass), + DeviceSubClass: uint32(entry.Info.BDeviceSubClass), + DeviceProtocol: uint32(entry.Info.BDeviceProtocol), + ConfigurationValue: uint32(entry.Info.BConfigurationValue), + NumConfigurations: uint32(entry.Info.BNumConfigurations), + Interfaces: common.Map(entry.Interfaces, func(it usbip.DeviceInterface) *daemon.USBInterface { + return &daemon.USBInterface{ + InterfaceClass: uint32(it.BInterfaceClass), + InterfaceSubClass: uint32(it.BInterfaceSubClass), + InterfaceProtocol: uint32(it.BInterfaceProtocol), + } + }), + Serial: entry.Serial, + Product: entry.Product, + }, + }}} +} + +func usbipURBRequestFromProto(request *daemon.USBURBRequest) usbip.URBRequest { + endpoint := uint8(request.GetEndpoint()) + direction := usbip.USBIPDirOut + buffer := request.GetOutData() + if request.GetDirectionIn() { + direction = usbip.USBIPDirIn + buffer = make([]byte, request.GetTransferBufferLength()) + } + var setup [8]byte + copy(setup[:], request.GetSetup()) + isoPackets := common.Map(request.GetIsoPackets(), func(it *daemon.USBIsoPacket) usbip.IsoPacketDescriptor { + return usbip.IsoPacketDescriptor{ + Offset: it.GetOffset(), + Length: it.GetLength(), + ActualLength: it.GetActualLength(), + Status: it.GetStatus(), + } + }) + return usbip.URBRequest{ + Command: usbip.SubmitCommand{ + Header: usbip.DataHeader{ + Command: usbip.CmdSubmit, + SeqNum: uint32(request.GetSeq()), + Direction: direction, + Endpoint: uint32(endpoint & 0x0f), + }, + TransferFlags: int32(request.GetTransferFlags()), + TransferBufferLength: int32(request.GetTransferBufferLength()), + StartFrame: request.GetStartFrame(), + NumberOfPackets: request.GetNumberOfPackets(), + Interval: request.GetInterval(), + Setup: setup, + Buffer: buffer, + IsoPackets: isoPackets, + }, + Endpoint: endpoint, + Buffer: buffer, + IsoPackets: isoPackets, + } +} + +func usbipURBResponseToProto(request *daemon.USBURBRequest, result usbip.URBResponse) *daemon.USBURBResponse { + if result.Error != nil { + return usbipURBErrorResponse(request) + } + response := &daemon.USBURBResponse{ + DeviceId: request.GetDeviceId(), + Seq: request.GetSeq(), + Status: result.Status, + ActualLength: result.ActualLength, + IsoPackets: common.Map(result.IsoPackets, func(it usbip.IsoPacketDescriptor) *daemon.USBIsoPacket { + return &daemon.USBIsoPacket{ + Offset: it.Offset, + Length: it.Length, + ActualLength: it.ActualLength, + Status: it.Status, + } + }), + } + if request.GetDirectionIn() && len(result.Buffer) > 0 { + if request.GetNumberOfPackets() > 0 { + response.InData = result.Buffer + } else { + response.InData = result.Buffer[:min(max(int(result.ActualLength), 0), len(result.Buffer))] + } + } + return response +} + +func usbipURBErrorResponse(request *daemon.USBURBRequest) *daemon.USBURBResponse { + return &daemon.USBURBResponse{ + DeviceId: request.GetDeviceId(), + Seq: request.GetSeq(), + Status: usbipShareStatusEIO, + } +} diff --git a/cmd/sing-box/cmd_api_usbip_status.go b/cmd/sing-box/cmd_api_usbip_status.go new file mode 100644 index 00000000..9ba93f55 --- /dev/null +++ b/cmd/sing-box/cmd_api_usbip_status.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "fmt" + "os" + "slices" + "strings" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" +) + +var commandAPIUsbipStatus = &cobra.Command{ + Use: "status", + Short: "Print the shared USB devices", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIUsbipStatus() + }, +} + +func init() { + commandAPIUsbipStatus.Flags().StringVar(&commandAPIUsbipStatusFlagService, "service", "", "Restrict to one usbip-server tag") + commandAPIUsbip.AddCommand(commandAPIUsbipStatus) +} + +func runAPIUsbipStatus() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + ctx, cancel := context.WithCancel(globalCtx) + defer cancel() + servers, err := fetchUsbipServers(ctx, client) + if err != nil { + return err + } + if commandAPIUsbipStatusFlagService != "" { + server, findErr := resolveUsbipServer(servers, commandAPIUsbipStatusFlagService) + if findErr != nil { + return findErr + } + servers = []*daemon.USBIPServerStatus{server} + } + for index, server := range servers { + if len(servers) > 1 { + if index > 0 { + os.Stdout.WriteString("\n") + } + os.Stdout.WriteString(server.GetServerTag() + "\n") + } + table := tableWriter{ + header: []string{"BUSID", "VID:PID", "PRODUCT", "STATE"}, + emptyMessage: "no shared devices", + } + for _, device := range server.GetDevices() { + table.addRow( + device.GetBusId(), + usbipVendorProduct(uint16(device.GetDescriptor_().GetVendorId()), uint16(device.GetDescriptor_().GetProductId())), + device.GetDescriptor_().GetProduct(), + usbipDeviceStateString(device.GetState()), + ) + } + table.flush() + } + return nil +} + +// SubscribeUSBIPServerStatus never completes: with no dynamic usbip-server it sends +// a single empty update and then blocks until the client cancels. +func fetchUsbipServers(ctx context.Context, client daemon.StartedServiceClient) ([]*daemon.USBIPServerStatus, error) { + stream, err := client.SubscribeUSBIPServerStatus(ctx, &emptypb.Empty{}) + if err != nil { + return nil, err + } + update, err := stream.Recv() + if err != nil { + return nil, err + } + return update.GetServers(), nil +} + +func resolveUsbipServer(servers []*daemon.USBIPServerStatus, tag string) (*daemon.USBIPServerStatus, error) { + if tag != "" { + index := slices.IndexFunc(servers, func(it *daemon.USBIPServerStatus) bool { + return it.GetServerTag() == tag + }) + if index == -1 { + return nil, E.New("usbip-server not found: ", tag, usbipServerTagHint(servers)) + } + return servers[index], nil + } + switch len(servers) { + case 0: + return nil, E.New("no usbip-server found") + case 1: + return servers[0], nil + default: + return nil, E.New("multiple usbip-servers found, select one with --service", usbipServerTagHint(servers)) + } +} + +func usbipServerTagHint(servers []*daemon.USBIPServerStatus) string { + if len(servers) == 0 { + return " (no usbip-server with dynamic provider found)" + } + return " (known tags: " + strings.Join(common.Map(servers, func(it *daemon.USBIPServerStatus) string { + return it.GetServerTag() + }), ", ") + ")" +} + +func usbipVendorProduct(vendorID uint16, productID uint16) string { + return fmt.Sprintf("%04x:%04x", vendorID, productID) +} + +func usbipDeviceStateString(state daemon.USBDeviceState) string { + return strings.ToLower(strings.TrimPrefix(state.String(), "USB_DEVICE_STATE_")) +} diff --git a/cmd/sing-box/cmd_api_usbip_stub.go b/cmd/sing-box/cmd_api_usbip_stub.go new file mode 100644 index 00000000..32f61760 --- /dev/null +++ b/cmd/sing-box/cmd_api_usbip_stub.go @@ -0,0 +1,23 @@ +//go:build !with_usbip || !(linux || (darwin && cgo) || windows) + +package main + +import ( + E "github.com/sagernet/sing/common/exceptions" +) + +func runAPIUsbipDeviceList() error { + return errUsbipLocalNotIncluded() +} + +func runAPIUsbipDeviceShow(_ string) error { + return errUsbipLocalNotIncluded() +} + +func runAPIUsbipShare(_ []string) error { + return errUsbipLocalNotIncluded() +} + +func errUsbipLocalNotIncluded() error { + return E.New(`USB/IP is not included in this build, rebuild with -tags with_usbip (supported on Linux, Windows, and macOS with CGO)`) +} diff --git a/cmd/sing-box/cmd_api_version.go b/cmd/sing-box/cmd_api_version.go new file mode 100644 index 00000000..4615435a --- /dev/null +++ b/cmd/sing-box/cmd_api_version.go @@ -0,0 +1,41 @@ +package main + +import ( + "os" + + "github.com/sagernet/sing-box/daemon" + F "github.com/sagernet/sing/common/format" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" +) + +var commandAPIVersion = &cobra.Command{ + Use: "version", + Short: "Print the API service version", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPIVersion() + }, +} + +func init() { + commandAPIRoot.AddCommand(commandAPIVersion) +} + +func runAPIVersion() error { + clientConn, client, err := createAPIClient() + if err != nil { + return err + } + defer clientConn.Close() + version, err := client.GetVersion(globalCtx, &emptypb.Empty{}) + if err != nil { + return err + } + if version.GetApiVersion() != daemon.APIVersion { + writeStderrLine(F.ToString("warning: server API version ", version.GetApiVersion(), ", client ", daemon.APIVersion)) + } + os.Stdout.WriteString(version.GetVersion() + "\n") + return nil +} diff --git a/go.mod b/go.mod index 05279813..cc817dbe 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/libdns/cloudflare v0.2.2 github.com/libdns/libdns v1.1.1 github.com/logrusorgru/aurora v2.0.3+incompatible + github.com/mattn/go-runewidth v0.0.27 github.com/mdlayher/netlink v1.11.2 github.com/metacubex/utls v1.8.7 github.com/mholt/acmez/v3 v3.1.6 @@ -55,7 +56,7 @@ require ( github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260727093646-7cb813e07b73 github.com/sagernet/sing-tun v0.9.0-beta.4 - github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb + github.com/sagernet/sing-usbip v0.0.0-20260813125128-908a3a2fa917 github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.2 @@ -73,6 +74,7 @@ require ( golang.org/x/net v0.57.0 golang.org/x/sync v0.22.0 golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 golang.org/x/text v0.40.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 google.golang.org/grpc v1.79.1 @@ -92,6 +94,7 @@ require ( github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/database64128/netx-go v0.1.1 // indirect @@ -188,7 +191,6 @@ require ( go.uber.org/zap/exp v0.3.0 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/term v0.45.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.47.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect diff --git a/go.sum b/go.sum index 84b595ab..cc42b476 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= @@ -193,6 +195,8 @@ github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9t github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI= github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA= github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU= @@ -338,8 +342,8 @@ github.com/sagernet/sing-snell v0.0.0-20260727093646-7cb813e07b73 h1:Iyhoka9XutV github.com/sagernet/sing-snell v0.0.0-20260727093646-7cb813e07b73/go.mod h1:et8Lws4f5QbOrY65DmjevHGup3mijJkhswkto6cwciM= github.com/sagernet/sing-tun v0.9.0-beta.4 h1:gIIZU4HevhtTQubZiOdMD0/RnECD6csl2kG6666KYmQ= github.com/sagernet/sing-tun v0.9.0-beta.4/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= -github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= -github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= +github.com/sagernet/sing-usbip v0.0.0-20260813125128-908a3a2fa917 h1:aTUWExMVkwFCQFTo+uFD7JCO5m7J0xGqymr94/t3eJs= +github.com/sagernet/sing-usbip v0.0.0-20260813125128-908a3a2fa917/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478=