mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-09-15 21:00:27 +00:00
332 lines
7.5 KiB
Go
332 lines
7.5 KiB
Go
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()
|
|
}
|
|
}
|