mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-09-20 23:25:47 +00:00
Add api command
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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 + ")"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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_"))
|
||||
}
|
||||
@@ -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)`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user