Add api command

This commit is contained in:
世界
2026-08-30 17:41:45 +08:00
parent fcee5656b9
commit bd059fc8aa
51 changed files with 5413 additions and 4 deletions
+107
View File
@@ -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 <command>",
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
}
+37
View File
@@ -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
}
+47
View File
@@ -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 <id>",
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
}
+201
View File
@@ -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
}
+112
View File
@@ -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 <id>",
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
}
+29
View File
@@ -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
}
+39
View File
@@ -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
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPIGroupSelect = &cobra.Command{
Use: "select <group> <outbound>",
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
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var commandAPIGroupShow = &cobra.Command{
Use: "show <group>",
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)
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPIGroupURLTest = &cobra.Command{
Use: "urltest <group>",
Short: "Start a URL test",
Long: "Start a URL test.\n\nThe tests are only spawned: results appear in `outbounds --group <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
}
+96
View File
@@ -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
}
}
}
+39
View File
@@ -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
}
+45
View File
@@ -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
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPIModeSet = &cobra.Command{
Use: "set <mode>",
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
}
+131
View File
@@ -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()))
}
}
+63
View File
@@ -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 + ")"
}
+297
View File
@@ -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)))
}
}
@@ -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 = `<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>single sign-on</title></head>
<body style="font-family:system-ui,sans-serif;text-align:center;margin-top:4em">
<h3>Single sign-on completed</h3>
<p>You may close this tab and return to the terminal.</p>
</body>
</html>
`
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
}
}
@@ -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
}
+331
View File
@@ -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()
}
}
@@ -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`)
}
}
+62
View File
@@ -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()
}
}
+355
View File
@@ -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)"
}
+61
View File
@@ -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
}
+99
View File
@@ -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`)
}
}
+53
View File
@@ -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")
}
+149
View File
@@ -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())
}
+99
View File
@@ -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
}
+82
View File
@@ -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
}
}
+88
View File
@@ -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")
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -0,0 +1,41 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPITailscaleExitNodeSet = &cobra.Command{
Use: "set <peer>",
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
}
+37
View File
@@ -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
}
+161
View File
@@ -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"
}
@@ -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
}
+102
View File
@@ -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 <peer>",
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()
}
+133
View File
@@ -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 <peer>",
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())
}
+78
View File
@@ -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@]<peer>",
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})
}
@@ -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())
}
@@ -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
}
+56
View File
@@ -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
}
+65
View File
@@ -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 <device>",
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 <device>...",
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)
}
+274
View File
@@ -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)
}
+838
View File
@@ -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,
}
}
+124
View File
@@ -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_"))
}
+23
View File
@@ -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)`)
}
+41
View File
@@ -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
}
+4 -2
View File
@@ -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
+6 -2
View File
@@ -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=