mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-10-19 15:54:24 +00:00
Add api command
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user