mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-09-15 21:00:27 +00:00
Merge tag 'v1.14.0'
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
//go:build with_acme
|
||||
|
||||
package acme
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/certificate"
|
||||
boxtls "github.com/sagernet/sing-box/common/tls"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/service"
|
||||
"github.com/sagernet/sing/service/filemanager"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
"github.com/caddyserver/zerossl"
|
||||
"github.com/libdns/alidns"
|
||||
"github.com/libdns/cloudflare"
|
||||
"github.com/libdns/libdns"
|
||||
"github.com/mholt/acmez/v3/acme"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
func RegisterCertificateProvider(registry *certificate.Registry) {
|
||||
certificate.Register[option.ACMECertificateProviderOptions](registry, C.TypeACME, NewCertificateProvider)
|
||||
}
|
||||
|
||||
var (
|
||||
_ adapter.CertificateProviderService = (*Service)(nil)
|
||||
_ adapter.ACMECertificateProvider = (*Service)(nil)
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
certificate.Adapter
|
||||
ctx context.Context
|
||||
config *certmagic.Config
|
||||
cache *certmagic.Cache
|
||||
zapLogger *zap.Logger
|
||||
dataDirectory string
|
||||
domain []string
|
||||
nextProtos []string
|
||||
}
|
||||
|
||||
func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag string, options option.ACMECertificateProviderOptions) (adapter.CertificateProviderService, error) {
|
||||
if len(options.Domain) == 0 {
|
||||
return nil, E.New("missing domain")
|
||||
}
|
||||
var acmeServer string
|
||||
switch options.Provider {
|
||||
case "", "letsencrypt":
|
||||
acmeServer = certmagic.LetsEncryptProductionCA
|
||||
case "zerossl":
|
||||
acmeServer = certmagic.ZeroSSLProductionCA
|
||||
default:
|
||||
if !strings.HasPrefix(options.Provider, "https://") {
|
||||
return nil, E.New("unsupported ACME provider: ", options.Provider)
|
||||
}
|
||||
acmeServer = options.Provider
|
||||
}
|
||||
if acmeServer == certmagic.ZeroSSLProductionCA &&
|
||||
(options.ExternalAccount == nil || options.ExternalAccount.KeyID == "") &&
|
||||
strings.TrimSpace(options.Email) == "" &&
|
||||
strings.TrimSpace(options.AccountKey) == "" {
|
||||
return nil, E.New("email is required to use the ZeroSSL ACME endpoint without external_account or account_key")
|
||||
}
|
||||
|
||||
var (
|
||||
storage certmagic.Storage
|
||||
dataDirectory string
|
||||
)
|
||||
if options.DataDirectory != "" {
|
||||
dataDirectory = filemanager.BasePath(ctx, os.ExpandEnv(options.DataDirectory))
|
||||
storage = &certmagic.FileStorage{Path: dataDirectory}
|
||||
} else {
|
||||
storage = certmagic.Default.Storage
|
||||
}
|
||||
|
||||
zapLogger := zap.New(zapcore.NewCore(
|
||||
zapcore.NewConsoleEncoder(boxtls.ACMEEncoderConfig()),
|
||||
&boxtls.ACMELogWriter{Logger: logger},
|
||||
zap.DebugLevel,
|
||||
))
|
||||
|
||||
config := &certmagic.Config{
|
||||
DefaultServerName: options.DefaultServerName,
|
||||
Storage: storage,
|
||||
Logger: zapLogger,
|
||||
}
|
||||
if options.KeyType != "" {
|
||||
var keyType certmagic.KeyType
|
||||
switch options.KeyType {
|
||||
case option.ACMEKeyTypeED25519:
|
||||
keyType = certmagic.ED25519
|
||||
case option.ACMEKeyTypeP256:
|
||||
keyType = certmagic.P256
|
||||
case option.ACMEKeyTypeP384:
|
||||
keyType = certmagic.P384
|
||||
case option.ACMEKeyTypeRSA2048:
|
||||
keyType = certmagic.RSA2048
|
||||
case option.ACMEKeyTypeRSA4096:
|
||||
keyType = certmagic.RSA4096
|
||||
default:
|
||||
return nil, E.New("unsupported ACME key type: ", string(options.KeyType))
|
||||
}
|
||||
config.KeySource = certmagic.StandardKeyGenerator{KeyType: keyType}
|
||||
}
|
||||
|
||||
profile := options.Profile
|
||||
if profile == "" && acmeServer == certmagic.LetsEncryptProductionCA && slices.ContainsFunc(options.Domain, certmagic.SubjectIsIP) {
|
||||
profile = "shortlived"
|
||||
}
|
||||
|
||||
acmeIssuer := certmagic.ACMEIssuer{
|
||||
CA: acmeServer,
|
||||
Email: options.Email,
|
||||
AccountKeyPEM: options.AccountKey,
|
||||
Agreed: true,
|
||||
Profile: profile,
|
||||
DisableHTTPChallenge: options.DisableHTTPChallenge,
|
||||
DisableTLSALPNChallenge: options.DisableTLSALPNChallenge,
|
||||
AltHTTPPort: int(options.AlternativeHTTPPort),
|
||||
AltTLSALPNPort: int(options.AlternativeTLSPort),
|
||||
Logger: zapLogger,
|
||||
}
|
||||
acmeHTTPClient, err := newACMEHTTPClient(ctx, logger, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dnsSolver, err := newDNSSolver(options.DNS01Challenge, zapLogger, acmeHTTPClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dnsSolver != nil {
|
||||
acmeIssuer.DNS01Solver = dnsSolver
|
||||
}
|
||||
if options.ExternalAccount != nil && options.ExternalAccount.KeyID != "" {
|
||||
acmeIssuer.ExternalAccount = (*acme.EAB)(options.ExternalAccount)
|
||||
}
|
||||
if acmeServer == certmagic.ZeroSSLProductionCA {
|
||||
acmeIssuer.NewAccountFunc = func(ctx context.Context, acmeIssuer *certmagic.ACMEIssuer, account acme.Account) (acme.Account, error) {
|
||||
if acmeIssuer.ExternalAccount != nil {
|
||||
return account, nil
|
||||
}
|
||||
var err error
|
||||
acmeIssuer.ExternalAccount, account, err = createZeroSSLExternalAccountBinding(ctx, acmeIssuer, account, acmeHTTPClient)
|
||||
return account, err
|
||||
}
|
||||
}
|
||||
|
||||
certmagicIssuer := certmagic.NewACMEIssuer(config, acmeIssuer)
|
||||
httpClientField := reflect.ValueOf(certmagicIssuer).Elem().FieldByName("httpClient")
|
||||
if !httpClientField.IsValid() || !httpClientField.CanAddr() {
|
||||
return nil, E.New("certmagic ACME issuer HTTP client field is unavailable")
|
||||
}
|
||||
reflect.NewAt(httpClientField.Type(), unsafe.Pointer(httpClientField.UnsafeAddr())).Elem().Set(reflect.ValueOf(acmeHTTPClient))
|
||||
config.Issuers = []certmagic.Issuer{certmagicIssuer}
|
||||
|
||||
var nextProtos []string
|
||||
if !acmeIssuer.DisableTLSALPNChallenge && acmeIssuer.DNS01Solver == nil {
|
||||
nextProtos = []string{C.ACMETLS1Protocol}
|
||||
}
|
||||
return &Service{
|
||||
Adapter: certificate.NewAdapter(C.TypeACME, tag),
|
||||
ctx: ctx,
|
||||
config: config,
|
||||
zapLogger: zapLogger,
|
||||
dataDirectory: dataDirectory,
|
||||
domain: options.Domain,
|
||||
nextProtos: nextProtos,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(stage adapter.StartStage) error {
|
||||
switch stage {
|
||||
case adapter.StartStateInitialize:
|
||||
if s.dataDirectory != "" {
|
||||
err := filemanager.MkdirAll(s.ctx, s.dataDirectory, 0o700)
|
||||
if err != nil {
|
||||
return E.Cause(err, "create ACME data directory")
|
||||
}
|
||||
}
|
||||
config := s.config
|
||||
cache := certmagic.NewCache(certmagic.CacheOptions{
|
||||
GetConfigForCert: func(certificate certmagic.Certificate) (*certmagic.Config, error) {
|
||||
return config, nil
|
||||
},
|
||||
Logger: s.zapLogger,
|
||||
})
|
||||
config = certmagic.New(cache, *config)
|
||||
s.config = config
|
||||
s.cache = cache
|
||||
case adapter.StartStateStart:
|
||||
return s.config.ManageAsync(s.ctx, s.domain)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Close() error {
|
||||
if s.cache != nil {
|
||||
s.cache.Stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
return s.config.GetCertificate(hello)
|
||||
}
|
||||
|
||||
func (s *Service) GetACMENextProtos() []string {
|
||||
return s.nextProtos
|
||||
}
|
||||
|
||||
func newDNSSolver(dnsOptions *option.ACMEProviderDNS01ChallengeOptions, logger *zap.Logger, httpClient *http.Client) (*certmagic.DNS01Solver, error) {
|
||||
if dnsOptions == nil || dnsOptions.Provider == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if dnsOptions.TTL < 0 {
|
||||
return nil, E.New("invalid ACME DNS01 ttl: ", dnsOptions.TTL.Build())
|
||||
}
|
||||
if dnsOptions.PropagationDelay < 0 {
|
||||
return nil, E.New("invalid ACME DNS01 propagation_delay: ", dnsOptions.PropagationDelay.Build())
|
||||
}
|
||||
if dnsOptions.PropagationTimeout < -1 {
|
||||
return nil, E.New("invalid ACME DNS01 propagation_timeout: ", dnsOptions.PropagationTimeout.Build())
|
||||
}
|
||||
solver := &certmagic.DNS01Solver{
|
||||
DNSManager: certmagic.DNSManager{
|
||||
TTL: time.Duration(dnsOptions.TTL),
|
||||
PropagationDelay: time.Duration(dnsOptions.PropagationDelay),
|
||||
PropagationTimeout: time.Duration(dnsOptions.PropagationTimeout),
|
||||
Resolvers: dnsOptions.Resolvers,
|
||||
OverrideDomain: dnsOptions.OverrideDomain,
|
||||
Logger: logger.Named("dns_manager"),
|
||||
},
|
||||
}
|
||||
switch dnsOptions.Provider {
|
||||
case C.DNSProviderAliDNS:
|
||||
solver.DNSProvider = &alidns.Provider{
|
||||
CredentialInfo: alidns.CredentialInfo{
|
||||
AccessKeyID: dnsOptions.AliDNSOptions.AccessKeyID,
|
||||
AccessKeySecret: dnsOptions.AliDNSOptions.AccessKeySecret,
|
||||
RegionID: dnsOptions.AliDNSOptions.RegionID,
|
||||
SecurityToken: dnsOptions.AliDNSOptions.SecurityToken,
|
||||
},
|
||||
}
|
||||
case C.DNSProviderCloudflare:
|
||||
solver.DNSProvider = &cloudflare.Provider{
|
||||
APIToken: dnsOptions.CloudflareOptions.APIToken,
|
||||
ZoneToken: dnsOptions.CloudflareOptions.ZoneToken,
|
||||
HTTPClient: httpClient,
|
||||
}
|
||||
case C.DNSProviderACMEDNS:
|
||||
solver.DNSProvider = &acmeDNSProvider{
|
||||
username: dnsOptions.ACMEDNSOptions.Username,
|
||||
password: dnsOptions.ACMEDNSOptions.Password,
|
||||
subdomain: dnsOptions.ACMEDNSOptions.Subdomain,
|
||||
serverURL: dnsOptions.ACMEDNSOptions.ServerURL,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
default:
|
||||
return nil, E.New("unsupported ACME DNS01 provider type: ", dnsOptions.Provider)
|
||||
}
|
||||
return solver, nil
|
||||
}
|
||||
|
||||
func createZeroSSLExternalAccountBinding(ctx context.Context, acmeIssuer *certmagic.ACMEIssuer, account acme.Account, httpClient *http.Client) (*acme.EAB, acme.Account, error) {
|
||||
email := strings.TrimSpace(acmeIssuer.Email)
|
||||
if email == "" {
|
||||
return nil, acme.Account{}, E.New("email is required to use the ZeroSSL ACME endpoint without external_account")
|
||||
}
|
||||
if len(account.Contact) == 0 {
|
||||
account.Contact = []string{"mailto:" + email}
|
||||
}
|
||||
if acmeIssuer.CertObtainTimeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, acmeIssuer.CertObtainTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
form := url.Values{"email": []string{email}}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, zerossl.BaseURL+"/acme/eab-credentials-email", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, account, E.Cause(err, "create ZeroSSL EAB request")
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("User-Agent", certmagic.UserAgent)
|
||||
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, account, E.Cause(err, "request ZeroSSL EAB")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
EABKID string `json:"eab_kid"`
|
||||
EABHMACKey string `json:"eab_hmac_key"`
|
||||
}
|
||||
err = json.NewDecoder(response.Body).Decode(&result)
|
||||
if err != nil {
|
||||
return nil, account, E.Cause(err, "decode ZeroSSL EAB response")
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, account, E.New("failed getting ZeroSSL EAB credentials: HTTP ", response.StatusCode)
|
||||
}
|
||||
if result.Error.Code != 0 {
|
||||
return nil, account, E.New("failed getting ZeroSSL EAB credentials: ", result.Error.Type, " (code ", result.Error.Code, ")")
|
||||
}
|
||||
|
||||
acmeIssuer.Logger.Info("generated ZeroSSL EAB credentials", zap.String("key_id", result.EABKID))
|
||||
|
||||
return &acme.EAB{
|
||||
KeyID: result.EABKID,
|
||||
MACKey: result.EABHMACKey,
|
||||
}, account, nil
|
||||
}
|
||||
|
||||
func newACMEHTTPClient(ctx context.Context, logger log.ContextLogger, options option.ACMECertificateProviderOptions) (*http.Client, error) {
|
||||
httpClientOptions := common.PtrValueOrDefault(options.HTTPClient)
|
||||
httpClientManager := service.FromContext[adapter.HTTPClientManager](ctx)
|
||||
transport, err := httpClientManager.ResolveTransport(ctx, logger, httpClientOptions)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "create ACME provider http client")
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: certmagic.HTTPTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type acmeDNSProvider struct {
|
||||
username string
|
||||
password string
|
||||
subdomain string
|
||||
serverURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type acmeDNSRecord struct {
|
||||
resourceRecord libdns.RR
|
||||
}
|
||||
|
||||
func (r acmeDNSRecord) RR() libdns.RR {
|
||||
return r.resourceRecord
|
||||
}
|
||||
|
||||
func (p *acmeDNSProvider) AppendRecords(ctx context.Context, _ string, records []libdns.Record) ([]libdns.Record, error) {
|
||||
if p.username == "" {
|
||||
return nil, E.New("ACME-DNS username cannot be empty")
|
||||
}
|
||||
if p.password == "" {
|
||||
return nil, E.New("ACME-DNS password cannot be empty")
|
||||
}
|
||||
if p.subdomain == "" {
|
||||
return nil, E.New("ACME-DNS subdomain cannot be empty")
|
||||
}
|
||||
if p.serverURL == "" {
|
||||
return nil, E.New("ACME-DNS server_url cannot be empty")
|
||||
}
|
||||
appendedRecords := make([]libdns.Record, 0, len(records))
|
||||
for _, record := range records {
|
||||
resourceRecord := record.RR()
|
||||
if resourceRecord.Type != "TXT" {
|
||||
return appendedRecords, E.New("ACME-DNS only supports adding TXT records")
|
||||
}
|
||||
requestBody, err := json.Marshal(map[string]string{
|
||||
"subdomain": p.subdomain,
|
||||
"txt": resourceRecord.Data,
|
||||
})
|
||||
if err != nil {
|
||||
return appendedRecords, E.Cause(err, "marshal ACME-DNS update request")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, p.serverURL+"/update", bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return appendedRecords, E.Cause(err, "create ACME-DNS update request")
|
||||
}
|
||||
request.Header.Set("X-Api-User", p.username)
|
||||
request.Header.Set("X-Api-Key", p.password)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := p.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return appendedRecords, E.Cause(err, "update ACME-DNS record")
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return appendedRecords, E.New("update ACME-DNS record: HTTP ", response.StatusCode)
|
||||
}
|
||||
appendedRecords = append(appendedRecords, acmeDNSRecord{resourceRecord: libdns.RR{
|
||||
Type: "TXT",
|
||||
Name: resourceRecord.Name,
|
||||
Data: resourceRecord.Data,
|
||||
}})
|
||||
}
|
||||
return appendedRecords, nil
|
||||
}
|
||||
|
||||
func (p *acmeDNSProvider) DeleteRecords(context.Context, string, []libdns.Record) ([]libdns.Record, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//go:build !with_acme
|
||||
|
||||
package acme
|
||||
@@ -0,0 +1,314 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/service"
|
||||
"github.com/sagernet/sing/service/filemanager"
|
||||
)
|
||||
|
||||
const (
|
||||
dashboardRoutePrefix = "/dashboard/"
|
||||
dashboardEtagFileName = ".etag"
|
||||
defaultDashboardURL = "https://github.com/SagerNet/sing-box-dashboard/archive/refs/heads/gh-pages.zip"
|
||||
)
|
||||
|
||||
type dashboardStatus int
|
||||
|
||||
const (
|
||||
dashboardEmpty dashboardStatus = iota
|
||||
dashboardManaged
|
||||
dashboardUserProvided
|
||||
)
|
||||
|
||||
type dashboard struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
logger log.ContextLogger
|
||||
options option.APIDashboardOptions
|
||||
path string
|
||||
url string
|
||||
updateInterval time.Duration
|
||||
fileServer http.Handler
|
||||
httpClient *http.Client
|
||||
lastEtag string
|
||||
lastUpdated time.Time
|
||||
}
|
||||
|
||||
func newDashboard(ctx context.Context, logger log.ContextLogger, options option.APIDashboardOptions) *dashboard {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
path := options.Path
|
||||
if path == "" {
|
||||
path = "dashboard"
|
||||
}
|
||||
path = filemanager.BasePath(ctx, os.ExpandEnv(path))
|
||||
url := options.DownloadURL
|
||||
if url == "" {
|
||||
url = defaultDashboardURL
|
||||
}
|
||||
updateInterval := 24 * time.Hour
|
||||
if options.UpdateInterval > 0 {
|
||||
updateInterval = time.Duration(options.UpdateInterval)
|
||||
}
|
||||
return &dashboard{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
logger: logger,
|
||||
options: options,
|
||||
path: path,
|
||||
url: url,
|
||||
updateInterval: updateInterval,
|
||||
fileServer: http.StripPrefix(dashboardRoutePrefix, http.FileServer(dashboardDir(path))),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dashboard) start() error {
|
||||
_, err := filemanager.ReadDir(d.ctx, d.path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return E.Cause(err, "read dashboard directory")
|
||||
}
|
||||
transport, err := d.resolveTransport()
|
||||
if err != nil {
|
||||
return E.Cause(err, "create dashboard http client")
|
||||
}
|
||||
d.httpClient = &http.Client{Transport: transport}
|
||||
go d.loopUpdate()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dashboard) close() error {
|
||||
d.cancel()
|
||||
if d.httpClient != nil {
|
||||
d.httpClient.CloseIdleConnections()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dashboard) resolveTransport() (adapter.HTTPTransport, error) {
|
||||
httpClientManager := service.FromContext[adapter.HTTPClientManager](d.ctx)
|
||||
if httpClientManager == nil {
|
||||
return nil, E.New("missing http client manager in context")
|
||||
}
|
||||
if d.options.HTTPClient != nil && !d.options.HTTPClient.IsEmpty() {
|
||||
return httpClientManager.ResolveTransport(d.ctx, d.logger, *d.options.HTTPClient)
|
||||
}
|
||||
defaultTransport := httpClientManager.DefaultTransport()
|
||||
if defaultTransport == nil {
|
||||
return nil, E.New("default http client transport is not initialized")
|
||||
}
|
||||
return defaultTransport, nil
|
||||
}
|
||||
|
||||
func (d *dashboard) serveHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
if strings.HasPrefix(request.URL.Path, dashboardRoutePrefix) {
|
||||
d.fileServer.ServeHTTP(writer, request)
|
||||
return
|
||||
}
|
||||
http.Redirect(writer, request, dashboardRoutePrefix, http.StatusFound)
|
||||
}
|
||||
|
||||
func (d *dashboard) loopUpdate() {
|
||||
status := d.loadState()
|
||||
if status == dashboardUserProvided {
|
||||
d.logger.Info("dashboard: serving user-provided files at ", d.path, ", auto-update disabled")
|
||||
return
|
||||
}
|
||||
var nextUpdate time.Time
|
||||
if status == dashboardManaged {
|
||||
nextUpdate = d.lastUpdated.Add(d.updateInterval)
|
||||
}
|
||||
timer := time.NewTimer(0)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-d.ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
now := time.Now()
|
||||
if !now.Before(nextUpdate) {
|
||||
err := d.fetch(d.ctx)
|
||||
if err != nil {
|
||||
d.logger.Error(E.Cause(err, "update dashboard"))
|
||||
nextUpdate = now.Add(d.updateInterval)
|
||||
} else {
|
||||
nextUpdate = d.lastUpdated.Add(d.updateInterval)
|
||||
}
|
||||
}
|
||||
timer.Reset(max(time.Until(nextUpdate), 0))
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dashboard) loadState() dashboardStatus {
|
||||
entries, err := filemanager.ReadDir(d.ctx, d.path)
|
||||
if err != nil {
|
||||
return dashboardEmpty
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return dashboardEmpty
|
||||
}
|
||||
etagPath := filepath.Join(d.path, dashboardEtagFileName)
|
||||
etagBytes, err := filemanager.ReadFile(d.ctx, etagPath)
|
||||
if err != nil {
|
||||
return dashboardUserProvided
|
||||
}
|
||||
d.lastEtag = strings.TrimSpace(string(etagBytes))
|
||||
info, err := filemanager.Stat(d.ctx, etagPath)
|
||||
if err == nil {
|
||||
d.lastUpdated = info.ModTime()
|
||||
}
|
||||
return dashboardManaged
|
||||
}
|
||||
|
||||
func (d *dashboard) fetch(ctx context.Context) error {
|
||||
d.logger.Info("updating dashboard from URL: ", d.url)
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, d.url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.lastEtag != "" {
|
||||
request.Header.Set("If-None-Match", d.lastEtag)
|
||||
}
|
||||
defer d.httpClient.CloseIdleConnections()
|
||||
response, err := d.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
switch response.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusNotModified:
|
||||
d.lastUpdated = time.Now()
|
||||
err = filemanager.WriteFile(d.ctx, filepath.Join(d.path, dashboardEtagFileName), []byte(d.lastEtag), 0o644)
|
||||
if err != nil {
|
||||
d.logger.Warn(E.Cause(err, "save dashboard update time"))
|
||||
}
|
||||
d.logger.Info("dashboard: not modified")
|
||||
return nil
|
||||
default:
|
||||
return E.New("unexpected status: ", response.Status)
|
||||
}
|
||||
etag := response.Header.Get("Etag")
|
||||
err = d.extract(response.Body, etag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.lastEtag = etag
|
||||
d.lastUpdated = time.Now()
|
||||
d.logger.Info("dashboard: updated")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dashboard) extract(body io.Reader, etag string) error {
|
||||
tempFile, err := filemanager.CreateTemp(d.ctx, "sing-box-dashboard-*.zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempZipPath := tempFile.Name()
|
||||
defer filemanager.Remove(d.ctx, tempZipPath)
|
||||
_, err = io.Copy(tempFile, body)
|
||||
tempFile.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader, err := zip.OpenReader(tempZipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
tempDir := d.path + ".tmp"
|
||||
err = filemanager.RemoveAll(d.ctx, tempDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = filemanager.MkdirAll(d.ctx, tempDir, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
trimDir := zipIsInSingleDirectory(reader.File)
|
||||
for _, file := range reader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
pathElements := strings.Split(file.Name, "/")
|
||||
if trimDir {
|
||||
pathElements = pathElements[1:]
|
||||
}
|
||||
if len(pathElements) == 0 {
|
||||
continue
|
||||
}
|
||||
relativePath := filepath.Join(pathElements...)
|
||||
if !filepath.IsLocal(relativePath) {
|
||||
filemanager.RemoveAll(d.ctx, tempDir)
|
||||
return E.New("invalid dashboard archive entry: ", file.Name)
|
||||
}
|
||||
savePath := filepath.Join(tempDir, relativePath)
|
||||
err = filemanager.MkdirAll(d.ctx, filepath.Dir(savePath), 0o755)
|
||||
if err != nil {
|
||||
filemanager.RemoveAll(d.ctx, tempDir)
|
||||
return err
|
||||
}
|
||||
err = extractZipEntry(d.ctx, file, savePath)
|
||||
if err != nil {
|
||||
filemanager.RemoveAll(d.ctx, tempDir)
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = filemanager.WriteFile(d.ctx, filepath.Join(tempDir, dashboardEtagFileName), []byte(etag), 0o644)
|
||||
if err != nil {
|
||||
filemanager.RemoveAll(d.ctx, tempDir)
|
||||
return err
|
||||
}
|
||||
err = filemanager.RemoveAll(d.ctx, d.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return filemanager.Rename(d.ctx, tempDir, d.path)
|
||||
}
|
||||
|
||||
func extractZipEntry(ctx context.Context, file *zip.File, savePath string) error {
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
writer, err := filemanager.Create(ctx, savePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer writer.Close()
|
||||
_, err = io.Copy(writer, reader)
|
||||
return err
|
||||
}
|
||||
|
||||
// GitHub archives wrap every file under a single "<repo>-<branch>/" top-level directory.
|
||||
func zipIsInSingleDirectory(files []*zip.File) bool {
|
||||
var dirName string
|
||||
for _, file := range files {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
pathElements := strings.Split(file.Name, "/")
|
||||
if len(pathElements) < 2 {
|
||||
return false
|
||||
}
|
||||
if dirName == "" {
|
||||
dirName = pathElements[0]
|
||||
} else if dirName != pathElements[0] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return dirName != ""
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
type dashboardDir http.Dir
|
||||
|
||||
func (d dashboardDir) Open(name string) (http.File, error) {
|
||||
file, err := http.Dir(d).Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fileWrapper{file}, nil
|
||||
}
|
||||
|
||||
// workaround for #2345 #2596
|
||||
type fileWrapper struct {
|
||||
http.File
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
boxService "github.com/sagernet/sing-box/adapter/service"
|
||||
"github.com/sagernet/sing-box/common/listener"
|
||||
"github.com/sagernet/sing-box/common/tls"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/daemon"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
aTLS "github.com/sagernet/sing/common/tls"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c" //nolint:staticcheck
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
func RegisterService(registry *boxService.Registry) {
|
||||
boxService.Register[option.APIServiceOptions](registry, C.TypeAPI, NewService)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
boxService.Adapter
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
logger log.ContextLogger
|
||||
options option.APIServiceOptions
|
||||
listener *listener.Listener
|
||||
tlsConfig tls.ServerConfig
|
||||
startedService *daemon.StartedService
|
||||
grpcServer *grpc.Server
|
||||
httpServer *http.Server
|
||||
dashboard *dashboard
|
||||
}
|
||||
|
||||
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.APIServiceOptions) (adapter.Service, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
s := &Service{
|
||||
Adapter: boxService.NewAdapter(C.TypeAPI, tag),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
logger: logger,
|
||||
options: options,
|
||||
listener: listener.New(listener.Options{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
Network: []string{N.NetworkTCP},
|
||||
Listen: options.ListenOptions,
|
||||
}),
|
||||
}
|
||||
if options.TLS != nil {
|
||||
tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS))
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
s.tlsConfig = tlsConfig
|
||||
}
|
||||
if options.Dashboard != nil && options.Dashboard.Enabled {
|
||||
s.dashboard = newDashboard(ctx, logger, *options.Dashboard)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStarted {
|
||||
return nil
|
||||
}
|
||||
s.startedService = daemon.NewAttachedService(s.ctx)
|
||||
s.grpcServer = daemon.NewServer(s.startedService, s.options.Secret)
|
||||
if s.dashboard != nil {
|
||||
err := s.dashboard.start()
|
||||
if err != nil {
|
||||
return E.Cause(err, "start dashboard")
|
||||
}
|
||||
}
|
||||
s.httpServer = &http.Server{
|
||||
//nolint:staticcheck
|
||||
Handler: h2c.NewHandler(newHTTPHandler(s.logger, s.grpcServer, s.options, s.dashboard), new(http2.Server)),
|
||||
BaseContext: func(net.Listener) context.Context {
|
||||
return s.ctx
|
||||
},
|
||||
}
|
||||
if s.tlsConfig != nil {
|
||||
err := s.tlsConfig.Start()
|
||||
if err != nil {
|
||||
return E.Cause(err, "create TLS config")
|
||||
}
|
||||
if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) {
|
||||
s.tlsConfig.SetNextProtos(append([]string{http2.NextProtoTLS}, s.tlsConfig.NextProtos()...))
|
||||
}
|
||||
if !common.Contains(s.tlsConfig.NextProtos(), "http/1.1") {
|
||||
s.tlsConfig.SetNextProtos(append(s.tlsConfig.NextProtos(), "http/1.1"))
|
||||
}
|
||||
}
|
||||
tcpListener, err := s.listener.ListenTCP()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.tlsConfig != nil {
|
||||
tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig)
|
||||
}
|
||||
go func() {
|
||||
serveErr := s.httpServer.Serve(tcpListener)
|
||||
if serveErr != nil && s.ctx.Err() == nil {
|
||||
s.logger.Error("serve error: ", serveErr)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Close() error {
|
||||
s.cancel()
|
||||
if s.dashboard != nil {
|
||||
s.dashboard.close()
|
||||
}
|
||||
if s.httpServer != nil {
|
||||
s.httpServer.Close()
|
||||
}
|
||||
if s.grpcServer != nil {
|
||||
s.grpcServer.Stop()
|
||||
}
|
||||
if s.startedService != nil {
|
||||
s.startedService.Close()
|
||||
}
|
||||
return common.Close(
|
||||
common.PtrOrNil(s.listener),
|
||||
s.tlsConfig,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/cors"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
const (
|
||||
contentTypeGRPC = "application/grpc"
|
||||
contentTypeGRPCWeb = "application/grpc-web"
|
||||
contentTypeGRPCWebText = "application/grpc-web-text"
|
||||
)
|
||||
|
||||
// newHTTPHandler additionally accepts gRPC-Web requests
|
||||
// (https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) and gRPC-Web
|
||||
// streams over WebSocket, wire compatible with the improbable-eng/grpc-web
|
||||
// client transports.
|
||||
func newHTTPHandler(logger log.ContextLogger, grpcServer *grpc.Server, options option.APIServiceOptions, dashboard *dashboard) http.Handler {
|
||||
allowedOrigins := options.AccessControlAllowOrigin
|
||||
if len(allowedOrigins) == 0 {
|
||||
allowedOrigins = []string{"*"}
|
||||
}
|
||||
corsHandler := cors.New(cors.Options{
|
||||
AllowedOrigins: allowedOrigins,
|
||||
AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions},
|
||||
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Grpc-Web", "X-User-Agent", "Grpc-Timeout"},
|
||||
ExposedHeaders: []string{"Grpc-Status", "Grpc-Message", "Grpc-Status-Details-Bin"},
|
||||
AllowPrivateNetwork: options.AccessControlAllowPrivateNetwork,
|
||||
MaxAge: 300,
|
||||
})
|
||||
return corsHandler.Handler(&webBridge{
|
||||
logger: logger,
|
||||
grpcServer: grpcServer,
|
||||
dashboard: dashboard,
|
||||
})
|
||||
}
|
||||
|
||||
type webBridge struct {
|
||||
logger log.ContextLogger
|
||||
grpcServer *grpc.Server
|
||||
dashboard *dashboard
|
||||
}
|
||||
|
||||
func (b *webBridge) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
contentType := request.Header.Get("Content-Type")
|
||||
switch {
|
||||
case isWebSocketGRPCRequest(request):
|
||||
b.serveWebSocket(writer, request)
|
||||
case request.Method == http.MethodPost && strings.HasPrefix(contentType, contentTypeGRPCWeb):
|
||||
b.serveWeb(writer, request)
|
||||
case request.ProtoMajor == 2 && strings.HasPrefix(contentType, contentTypeGRPC):
|
||||
b.grpcServer.ServeHTTP(writer, request)
|
||||
case b.dashboard != nil:
|
||||
b.dashboard.serveHTTP(writer, request)
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *webBridge) serveWeb(writer http.ResponseWriter, request *http.Request) {
|
||||
isTextFormat := strings.HasPrefix(request.Header.Get("Content-Type"), contentTypeGRPCWebText)
|
||||
webContentType := contentTypeGRPCWeb
|
||||
grpcRequest := request.Clone(request.Context())
|
||||
if isTextFormat {
|
||||
webContentType = contentTypeGRPCWebText
|
||||
grpcRequest.Body = &bodyReadCloser{
|
||||
Reader: base64.NewDecoder(base64.StdEncoding, request.Body),
|
||||
Closer: request.Body,
|
||||
}
|
||||
}
|
||||
// The gRPC server handler transport only accepts requests it sees as
|
||||
// native gRPC over HTTP/2.
|
||||
grpcRequest.ProtoMajor = 2
|
||||
grpcRequest.ProtoMinor = 0
|
||||
grpcRequest.Header.Set("Content-Type", strings.Replace(request.Header.Get("Content-Type"), webContentType, contentTypeGRPC, 1))
|
||||
grpcRequest.Header.Del("Content-Length")
|
||||
response := newWebResponseWriter(writer, isTextFormat)
|
||||
b.grpcServer.ServeHTTP(response, grpcRequest)
|
||||
response.finish()
|
||||
}
|
||||
|
||||
type bodyReadCloser struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}
|
||||
|
||||
// webResponseWriter translates a native gRPC response into a gRPC-Web
|
||||
// response: headers set after the first write, including the gRPC status the
|
||||
// handler transport sets via http2.TrailerPrefix keys, become a trailer
|
||||
// frame at the end of the body instead of HTTP trailers.
|
||||
type webResponseWriter struct {
|
||||
writer http.ResponseWriter
|
||||
rawWriter http.ResponseWriter
|
||||
header http.Header
|
||||
contentType string
|
||||
wroteHeaders bool
|
||||
wroteBody bool
|
||||
}
|
||||
|
||||
func newWebResponseWriter(writer http.ResponseWriter, isTextFormat bool) *webResponseWriter {
|
||||
response := &webResponseWriter{
|
||||
writer: writer,
|
||||
rawWriter: writer,
|
||||
header: make(http.Header),
|
||||
contentType: contentTypeGRPCWeb,
|
||||
}
|
||||
if isTextFormat {
|
||||
response.writer = newBase64ResponseWriter(writer)
|
||||
response.contentType = contentTypeGRPCWebText
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func (w *webResponseWriter) Header() http.Header {
|
||||
return w.header
|
||||
}
|
||||
|
||||
func (w *webResponseWriter) Write(content []byte) (int, error) {
|
||||
if !w.wroteHeaders {
|
||||
w.prepareHeaders()
|
||||
w.wroteHeaders = true
|
||||
}
|
||||
w.wroteBody = true
|
||||
return w.writer.Write(content)
|
||||
}
|
||||
|
||||
func (w *webResponseWriter) WriteHeader(statusCode int) {
|
||||
if !w.wroteHeaders {
|
||||
w.prepareHeaders()
|
||||
w.wroteHeaders = true
|
||||
}
|
||||
w.writer.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (w *webResponseWriter) Flush() {
|
||||
// Flushing before anything was written would commit a 200 response
|
||||
// even for requests that end up as trailers-only responses.
|
||||
if w.wroteHeaders || w.wroteBody {
|
||||
flushWriter(w.writer)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *webResponseWriter) prepareHeaders() {
|
||||
rawHeader := w.rawWriter.Header()
|
||||
for key, values := range w.header {
|
||||
canonicalKey := http.CanonicalHeaderKey(strings.TrimPrefix(key, http2.TrailerPrefix))
|
||||
if canonicalKey == "Trailer" {
|
||||
continue
|
||||
}
|
||||
if canonicalKey == "Content-Type" {
|
||||
newValues := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
newValues = append(newValues, strings.Replace(value, contentTypeGRPC, w.contentType, 1))
|
||||
}
|
||||
values = newValues
|
||||
}
|
||||
rawHeader[canonicalKey] = values
|
||||
}
|
||||
}
|
||||
|
||||
func (w *webResponseWriter) finish() {
|
||||
if w.wroteHeaders || w.wroteBody {
|
||||
w.writeTrailerFrame()
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flushWriter(w.writer)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *webResponseWriter) writeTrailerFrame() {
|
||||
flushedKeys := make(map[string]bool)
|
||||
for key := range w.rawWriter.Header() {
|
||||
flushedKeys[strings.ToLower(key)] = true
|
||||
}
|
||||
trailerHeader := make(http.Header)
|
||||
for key, values := range w.header {
|
||||
lowerKey := strings.ToLower(strings.TrimPrefix(key, http2.TrailerPrefix))
|
||||
if lowerKey == "trailer" || flushedKeys[lowerKey] {
|
||||
continue
|
||||
}
|
||||
trailerHeader[lowerKey] = values
|
||||
}
|
||||
var trailerBuffer bytes.Buffer
|
||||
trailerHeader.Write(&trailerBuffer)
|
||||
w.writer.Write(webMetadataFrameHeader(trailerBuffer.Len()))
|
||||
w.writer.Write(trailerBuffer.Bytes())
|
||||
flushWriter(w.writer)
|
||||
}
|
||||
|
||||
func webMetadataFrameHeader(payloadLength int) []byte {
|
||||
return binary.BigEndian.AppendUint32([]byte{1 << 7}, uint32(payloadLength))
|
||||
}
|
||||
|
||||
func flushWriter(writer http.ResponseWriter) {
|
||||
flusher, isFlusher := writer.(http.Flusher)
|
||||
if isFlusher {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
type base64ResponseWriter struct {
|
||||
wrapped http.ResponseWriter
|
||||
encoder io.WriteCloser
|
||||
}
|
||||
|
||||
func newBase64ResponseWriter(wrapped http.ResponseWriter) http.ResponseWriter {
|
||||
writer := &base64ResponseWriter{wrapped: wrapped}
|
||||
writer.encoder = base64.NewEncoder(base64.StdEncoding, wrapped)
|
||||
return writer
|
||||
}
|
||||
|
||||
func (w *base64ResponseWriter) Header() http.Header {
|
||||
return w.wrapped.Header()
|
||||
}
|
||||
|
||||
func (w *base64ResponseWriter) Write(content []byte) (int, error) {
|
||||
return w.encoder.Write(content)
|
||||
}
|
||||
|
||||
func (w *base64ResponseWriter) WriteHeader(statusCode int) {
|
||||
w.wrapped.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (w *base64ResponseWriter) Flush() {
|
||||
w.encoder.Close()
|
||||
w.encoder = base64.NewEncoder(base64.StdEncoding, w.wrapped)
|
||||
flushWriter(w.wrapped)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"golang.org/x/net/http/httpguts"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
const (
|
||||
webSocketSubprotocol = "grpc-websockets"
|
||||
webSocketReadLimit = 1 << 22
|
||||
webSocketPingInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
func isWebSocketGRPCRequest(request *http.Request) bool {
|
||||
return httpguts.HeaderValuesContainsToken(request.Header.Values("Upgrade"), "websocket") &&
|
||||
httpguts.HeaderValuesContainsToken(request.Header.Values("Sec-Websocket-Protocol"), webSocketSubprotocol)
|
||||
}
|
||||
|
||||
// serveWebSocket carries a single gRPC stream over a WebSocket connection:
|
||||
// the first client message contains the request metadata, each subsequent
|
||||
// binary message is prefixed with 0 for body data or is a single 1 byte for
|
||||
// the half-close signal, and the server sends gRPC-Web frames back.
|
||||
func (b *webBridge) serveWebSocket(writer http.ResponseWriter, request *http.Request) {
|
||||
conn, err := websocket.Accept(writer, request, &websocket.AcceptOptions{
|
||||
Subprotocols: []string{webSocketSubprotocol},
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
if err != nil {
|
||||
b.logger.Error("upgrade websocket request: ", err)
|
||||
return
|
||||
}
|
||||
conn.SetReadLimit(webSocketReadLimit)
|
||||
ctx, cancel := context.WithCancel(request.Context())
|
||||
defer cancel()
|
||||
messageType, firstMessage, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
conn.CloseNow()
|
||||
return
|
||||
}
|
||||
if messageType != websocket.MessageBinary {
|
||||
conn.CloseNow()
|
||||
return
|
||||
}
|
||||
header, err := parseWebSocketHeader(firstMessage)
|
||||
if err != nil {
|
||||
b.logger.Error("parse websocket request metadata: ", err)
|
||||
conn.CloseNow()
|
||||
return
|
||||
}
|
||||
contentType := header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
header.Set("Content-Type", contentTypeGRPC)
|
||||
} else {
|
||||
header.Set("Content-Type", strings.Replace(contentType, contentTypeGRPCWeb, contentTypeGRPC, 1))
|
||||
}
|
||||
header.Del("Content-Length")
|
||||
response := newWebSocketResponseWriter(ctx, conn)
|
||||
grpcRequest := request.WithContext(ctx)
|
||||
grpcRequest.Method = http.MethodPost
|
||||
grpcRequest.ProtoMajor = 2
|
||||
grpcRequest.ProtoMinor = 0
|
||||
grpcRequest.Header = header
|
||||
grpcRequest.Body = &webSocketBodyReader{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
conn: conn,
|
||||
response: response,
|
||||
}
|
||||
go keepWebSocketAlive(ctx, conn)
|
||||
b.grpcServer.ServeHTTP(response, grpcRequest)
|
||||
response.writeTrailerFrame()
|
||||
conn.Close(websocket.StatusNormalClosure, "")
|
||||
}
|
||||
|
||||
func parseWebSocketHeader(content []byte) (http.Header, error) {
|
||||
reader := textproto.NewReader(bufio.NewReader(io.MultiReader(bytes.NewReader(content), strings.NewReader("\r\n"))))
|
||||
mimeHeader, err := reader.ReadMIMEHeader()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return http.Header(mimeHeader), nil
|
||||
}
|
||||
|
||||
func keepWebSocketAlive(ctx context.Context, conn *websocket.Conn) {
|
||||
ticker := time.NewTicker(webSocketPingInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
err := conn.Ping(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type webSocketResponseWriter struct {
|
||||
ctx context.Context
|
||||
conn *websocket.Conn
|
||||
header http.Header
|
||||
flushedHeader http.Header
|
||||
wroteHeaders bool
|
||||
wroteTrailers bool
|
||||
}
|
||||
|
||||
func newWebSocketResponseWriter(ctx context.Context, conn *websocket.Conn) *webSocketResponseWriter {
|
||||
return &webSocketResponseWriter{
|
||||
ctx: ctx,
|
||||
conn: conn,
|
||||
header: make(http.Header),
|
||||
flushedHeader: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *webSocketResponseWriter) Header() http.Header {
|
||||
return w.header
|
||||
}
|
||||
|
||||
func (w *webSocketResponseWriter) Write(content []byte) (int, error) {
|
||||
if !w.wroteHeaders {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
err := w.conn.Write(w.ctx, websocket.MessageBinary, content)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(content), nil
|
||||
}
|
||||
|
||||
func (w *webSocketResponseWriter) WriteHeader(statusCode int) {
|
||||
if w.wroteHeaders {
|
||||
return
|
||||
}
|
||||
w.wroteHeaders = true
|
||||
headerFrame := make(http.Header)
|
||||
for key, values := range w.header {
|
||||
canonicalKey := http.CanonicalHeaderKey(key)
|
||||
if canonicalKey == "Trailer" {
|
||||
continue
|
||||
}
|
||||
w.flushedHeader[canonicalKey] = values
|
||||
headerFrame[canonicalKey] = values
|
||||
}
|
||||
w.writeHeaderFrame(headerFrame)
|
||||
}
|
||||
|
||||
func (w *webSocketResponseWriter) Flush() {
|
||||
}
|
||||
|
||||
func (w *webSocketResponseWriter) writeHeaderFrame(header http.Header) {
|
||||
var headerBuffer bytes.Buffer
|
||||
header.Write(&headerBuffer)
|
||||
frame := make([]byte, 0, 5+headerBuffer.Len())
|
||||
frame = append(frame, webMetadataFrameHeader(headerBuffer.Len())...)
|
||||
frame = append(frame, headerBuffer.Bytes()...)
|
||||
w.conn.Write(w.ctx, websocket.MessageBinary, frame)
|
||||
}
|
||||
|
||||
func (w *webSocketResponseWriter) writeTrailerFrame() {
|
||||
if w.wroteTrailers {
|
||||
return
|
||||
}
|
||||
w.wroteTrailers = true
|
||||
trailerHeader := make(http.Header)
|
||||
for key, values := range w.header {
|
||||
lowerKey := strings.ToLower(strings.TrimPrefix(key, http2.TrailerPrefix))
|
||||
if lowerKey == "trailer" {
|
||||
continue
|
||||
}
|
||||
_, flushed := w.flushedHeader[http.CanonicalHeaderKey(lowerKey)]
|
||||
if flushed {
|
||||
continue
|
||||
}
|
||||
trailerHeader[lowerKey] = values
|
||||
}
|
||||
w.writeHeaderFrame(trailerHeader)
|
||||
}
|
||||
|
||||
type webSocketBodyReader struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
conn *websocket.Conn
|
||||
response *webSocketResponseWriter
|
||||
remaining []byte
|
||||
}
|
||||
|
||||
func (r *webSocketBodyReader) Read(buffer []byte) (int, error) {
|
||||
if len(r.remaining) > 0 {
|
||||
n := copy(buffer, r.remaining)
|
||||
r.remaining = r.remaining[n:]
|
||||
return n, nil
|
||||
}
|
||||
for {
|
||||
messageType, payload, err := r.conn.Read(r.ctx)
|
||||
if err != nil {
|
||||
r.cancel()
|
||||
return 0, io.EOF
|
||||
}
|
||||
if messageType != websocket.MessageBinary {
|
||||
return 0, E.New("unexpected non-binary websocket message")
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
continue
|
||||
}
|
||||
if payload[0] == 1 {
|
||||
go r.waitForClose()
|
||||
return 0, io.EOF
|
||||
}
|
||||
content := payload[1:]
|
||||
if len(content) == 0 {
|
||||
continue
|
||||
}
|
||||
n := copy(buffer, content)
|
||||
r.remaining = content[n:]
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *webSocketBodyReader) waitForClose() {
|
||||
for {
|
||||
_, _, err := r.conn.Read(r.ctx)
|
||||
if err != nil {
|
||||
r.cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close is called by the gRPC handler transport after the stream status has
|
||||
// been written; the trailer frame must be sent before the connection closes.
|
||||
func (r *webSocketBodyReader) Close() error {
|
||||
r.response.writeTrailerFrame()
|
||||
return r.conn.Close(websocket.StatusNormalClosure, "")
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package ccm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/service/filemanager"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -42,8 +44,8 @@ func getDefaultCredentialsPath() (string, error) {
|
||||
return filepath.Join(userInfo.HomeDir, ".claude", ".credentials.json"), nil
|
||||
}
|
||||
|
||||
func readCredentialsFromFile(path string) (*oauthCredentials, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
func readCredentialsFromFile(ctx context.Context, path string) (*oauthCredentials, error) {
|
||||
data, err := filemanager.ReadFile(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -60,14 +62,14 @@ func readCredentialsFromFile(path string) (*oauthCredentials, error) {
|
||||
return credentialsContainer.ClaudeAIAuth, nil
|
||||
}
|
||||
|
||||
func writeCredentialsToFile(oauthCredentials *oauthCredentials, path string) error {
|
||||
func writeCredentialsToFile(ctx context.Context, oauthCredentials *oauthCredentials, path string) error {
|
||||
data, err := json.MarshalIndent(map[string]any{
|
||||
"claudeAiOauth": oauthCredentials,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
return filemanager.WriteFile(ctx, path, data, 0o600)
|
||||
}
|
||||
|
||||
type oauthCredentials struct {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package ccm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -33,9 +34,9 @@ func getKeychainServiceName() string {
|
||||
return "Claude Code-credentials-" + hex.EncodeToString(hash[:])[:8]
|
||||
}
|
||||
|
||||
func platformReadCredentials(customPath string) (*oauthCredentials, error) {
|
||||
func platformReadCredentials(ctx context.Context, customPath string) (*oauthCredentials, error) {
|
||||
if customPath != "" {
|
||||
return readCredentialsFromFile(customPath)
|
||||
return readCredentialsFromFile(ctx, customPath)
|
||||
}
|
||||
|
||||
userInfo, err := getRealUser()
|
||||
@@ -66,12 +67,12 @@ func platformReadCredentials(customPath string) (*oauthCredentials, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return readCredentialsFromFile(defaultPath)
|
||||
return readCredentialsFromFile(ctx, defaultPath)
|
||||
}
|
||||
|
||||
func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath string) error {
|
||||
func platformWriteCredentials(ctx context.Context, oauthCredentials *oauthCredentials, customPath string) error {
|
||||
if customPath != "" {
|
||||
return writeCredentialsToFile(oauthCredentials, customPath)
|
||||
return writeCredentialsToFile(ctx, oauthCredentials, customPath)
|
||||
}
|
||||
|
||||
userInfo, err := getRealUser()
|
||||
@@ -112,5 +113,5 @@ func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath str
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCredentialsToFile(oauthCredentials, defaultPath)
|
||||
return writeCredentialsToFile(ctx, oauthCredentials, defaultPath)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
package ccm
|
||||
|
||||
func platformReadCredentials(customPath string) (*oauthCredentials, error) {
|
||||
import "context"
|
||||
|
||||
func platformReadCredentials(ctx context.Context, customPath string) (*oauthCredentials, error) {
|
||||
if customPath == "" {
|
||||
var err error
|
||||
customPath, err = getDefaultCredentialsPath()
|
||||
@@ -10,10 +12,10 @@ func platformReadCredentials(customPath string) (*oauthCredentials, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return readCredentialsFromFile(customPath)
|
||||
return readCredentialsFromFile(ctx, customPath)
|
||||
}
|
||||
|
||||
func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath string) error {
|
||||
func platformWriteCredentials(ctx context.Context, oauthCredentials *oauthCredentials, customPath string) error {
|
||||
if customPath == "" {
|
||||
var err error
|
||||
customPath, err = getDefaultCredentialsPath()
|
||||
@@ -21,5 +23,5 @@ func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath str
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writeCredentialsToFile(oauthCredentials, customPath)
|
||||
return writeCredentialsToFile(ctx, oauthCredentials, customPath)
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
|
||||
usageTracker = &AggregatedUsage{
|
||||
LastUpdated: time.Now(),
|
||||
Combinations: make([]CostCombination, 0),
|
||||
ctx: ctx,
|
||||
filePath: options.UsagesPath,
|
||||
logger: logger,
|
||||
}
|
||||
@@ -201,7 +202,7 @@ func (s *Service) Start(stage adapter.StartStage) error {
|
||||
|
||||
s.userManager.UpdateUsers(s.users)
|
||||
|
||||
credentials, err := platformReadCredentials(s.credentialPath)
|
||||
credentials, err := platformReadCredentials(s.ctx, s.credentialPath)
|
||||
if err != nil {
|
||||
return E.Cause(err, "read credentials")
|
||||
}
|
||||
@@ -271,7 +272,7 @@ func (s *Service) getAccessToken() (string, error) {
|
||||
|
||||
s.credentials = newCredentials
|
||||
|
||||
err = platformWriteCredentials(newCredentials, s.credentialPath)
|
||||
err = platformWriteCredentials(s.ctx, newCredentials, s.credentialPath)
|
||||
if err != nil {
|
||||
s.logger.Warn("persist refreshed token: ", err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ccm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"github.com/sagernet/sing-box/log"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/service/filemanager"
|
||||
)
|
||||
|
||||
type UsageStats struct {
|
||||
@@ -36,6 +38,7 @@ type AggregatedUsage struct {
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
Combinations []CostCombination `json:"combinations"`
|
||||
mutex sync.Mutex
|
||||
ctx context.Context
|
||||
filePath string
|
||||
logger log.ContextLogger
|
||||
lastSaveTime time.Time
|
||||
@@ -567,7 +570,7 @@ func (u *AggregatedUsage) Load() error {
|
||||
u.LastUpdated = time.Time{}
|
||||
u.Combinations = nil
|
||||
|
||||
data, err := os.ReadFile(u.filePath)
|
||||
data, err := filemanager.ReadFile(u.ctx, u.filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
@@ -601,12 +604,12 @@ func (u *AggregatedUsage) Save() error {
|
||||
}
|
||||
|
||||
tmpFile := u.filePath + ".tmp"
|
||||
err = os.WriteFile(tmpFile, data, 0o644)
|
||||
err = filemanager.WriteFile(u.ctx, tmpFile, data, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmpFile)
|
||||
err = os.Rename(tmpFile, u.filePath)
|
||||
defer filemanager.Remove(u.ctx, tmpFile)
|
||||
err = filemanager.Rename(u.ctx, tmpFile, u.filePath)
|
||||
if err == nil {
|
||||
u.saveMutex.Lock()
|
||||
u.lastSaveTime = time.Now()
|
||||
|
||||
+18
-33
@@ -5,7 +5,6 @@ package derp
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
stdTLS "crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -34,7 +33,6 @@ import (
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
"github.com/sagernet/sing/common/ntp"
|
||||
aTLS "github.com/sagernet/sing/common/tls"
|
||||
"github.com/sagernet/sing/service"
|
||||
"github.com/sagernet/sing/service/filemanager"
|
||||
@@ -51,7 +49,7 @@ import (
|
||||
"github.com/coder/websocket"
|
||||
"github.com/go-chi/render"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
"golang.org/x/net/http2/h2c" //nolint:staticcheck
|
||||
)
|
||||
|
||||
func Register(registry *boxService.Registry) {
|
||||
@@ -139,7 +137,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
|
||||
func (d *Service) Start(stage adapter.StartStage) error {
|
||||
switch stage {
|
||||
case adapter.StartStateStart:
|
||||
config, err := readDERPConfig(filemanager.BasePath(d.ctx, d.configPath))
|
||||
config, err := readDERPConfig(d.ctx, filemanager.BasePath(d.ctx, d.configPath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -151,29 +149,14 @@ func (d *Service) Start(stage adapter.StartStage) error {
|
||||
if len(d.verifyClientURL) > 0 {
|
||||
var httpClients []*http.Client
|
||||
var urls []string
|
||||
for index, options := range d.verifyClientURL {
|
||||
verifyDialer, createErr := dialer.NewWithOptions(dialer.Options{
|
||||
Context: d.ctx,
|
||||
Options: options.DialerOptions,
|
||||
RemoteIsDomain: options.ServerIsDomain(),
|
||||
NewDialer: true,
|
||||
})
|
||||
httpClientManager := service.FromContext[adapter.HTTPClientManager](d.ctx)
|
||||
for index, verifyOptions := range d.verifyClientURL {
|
||||
transport, createErr := httpClientManager.ResolveTransport(d.ctx, d.logger, verifyOptions.HTTPClientOptions)
|
||||
if createErr != nil {
|
||||
return E.Cause(createErr, "verify_client_url[", index, "]")
|
||||
}
|
||||
httpClients = append(httpClients, &http.Client{
|
||||
Transport: &http.Transport{
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSClientConfig: &stdTLS.Config{
|
||||
RootCAs: adapter.RootPoolFromContext(d.ctx),
|
||||
Time: ntp.TimeFuncFromContext(d.ctx),
|
||||
},
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return verifyDialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||
},
|
||||
},
|
||||
})
|
||||
urls = append(urls, options.URL)
|
||||
httpClients = append(httpClients, &http.Client{Transport: transport})
|
||||
urls = append(urls, verifyOptions.URL)
|
||||
}
|
||||
server.SetVerifyClientHTTPClient(httpClients)
|
||||
server.SetVerifyClientURL(urls)
|
||||
@@ -183,7 +166,7 @@ func (d *Service) Start(stage adapter.StartStage) error {
|
||||
server.SetMeshKey(d.meshKey)
|
||||
} else if d.meshKeyPath != "" {
|
||||
var meshKeyContent []byte
|
||||
meshKeyContent, err = os.ReadFile(d.meshKeyPath)
|
||||
meshKeyContent, err = filemanager.ReadFile(d.ctx, d.meshKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -234,6 +217,7 @@ func (d *Service) Start(stage adapter.StartStage) error {
|
||||
}
|
||||
tcpListener = aTLS.NewListener(tcpListener, d.tlsConfig)
|
||||
httpServer := &http.Server{
|
||||
//nolint:staticcheck
|
||||
Handler: h2c.NewHandler(derpMux, &http2.Server{}),
|
||||
}
|
||||
go httpServer.Serve(tcpListener)
|
||||
@@ -310,7 +294,7 @@ func (d *Service) startMeshWithHost(derpServer *derpserver.Server, server *optio
|
||||
}
|
||||
var stdConfig *tls.STDConfig
|
||||
if server.TLS != nil && server.TLS.Enabled {
|
||||
tlsConfig, err := tls.NewClient(d.ctx, d.logger, hostname, common.PtrValueOrDefault(server.TLS))
|
||||
tlsConfig, err := tls.NewClient(d.ctx, d.logger, hostname, *server.TLS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -352,10 +336,11 @@ func (d *Service) startMeshWithHost(derpServer *derpserver.Server, server *optio
|
||||
}
|
||||
|
||||
func (d *Service) Close() error {
|
||||
return common.Close(
|
||||
err := common.Close(
|
||||
common.PtrOrNil(d.listener),
|
||||
d.tlsConfig,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
var homePage = `
|
||||
@@ -463,11 +448,11 @@ type derpConfig struct {
|
||||
PrivateKey key.NodePrivate
|
||||
}
|
||||
|
||||
func readDERPConfig(path string) (*derpConfig, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
func readDERPConfig(ctx context.Context, path string) (*derpConfig, error) {
|
||||
content, err := filemanager.ReadFile(ctx, path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return writeNewDERPConfig(path)
|
||||
return writeNewDERPConfig(ctx, path)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -479,9 +464,9 @@ func readDERPConfig(path string) (*derpConfig, error) {
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func writeNewDERPConfig(path string) (*derpConfig, error) {
|
||||
func writeNewDERPConfig(ctx context.Context, path string) (*derpConfig, error) {
|
||||
newKey := key.NewNode()
|
||||
err := os.MkdirAll(filepath.Dir(path), 0o777)
|
||||
err := filemanager.MkdirAll(ctx, filepath.Dir(path), 0o777)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -492,7 +477,7 @@ func writeNewDERPConfig(path string) (*derpConfig, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = os.WriteFile(path, content, 0o644)
|
||||
err = filemanager.WriteFile(ctx, path, content, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package ocm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/service/filemanager"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -42,8 +44,8 @@ func getDefaultCredentialsPath() (string, error) {
|
||||
return filepath.Join(userInfo.HomeDir, ".codex", "auth.json"), nil
|
||||
}
|
||||
|
||||
func readCredentialsFromFile(path string) (*oauthCredentials, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
func readCredentialsFromFile(ctx context.Context, path string) (*oauthCredentials, error) {
|
||||
data, err := filemanager.ReadFile(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -55,12 +57,12 @@ func readCredentialsFromFile(path string) (*oauthCredentials, error) {
|
||||
return &credentials, nil
|
||||
}
|
||||
|
||||
func writeCredentialsToFile(credentials *oauthCredentials, path string) error {
|
||||
func writeCredentialsToFile(ctx context.Context, credentials *oauthCredentials, path string) error {
|
||||
data, err := json.MarshalIndent(credentials, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
return filemanager.WriteFile(ctx, path, data, 0o600)
|
||||
}
|
||||
|
||||
type oauthCredentials struct {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
package ocm
|
||||
|
||||
func platformReadCredentials(customPath string) (*oauthCredentials, error) {
|
||||
import "context"
|
||||
|
||||
func platformReadCredentials(ctx context.Context, customPath string) (*oauthCredentials, error) {
|
||||
if customPath == "" {
|
||||
var err error
|
||||
customPath, err = getDefaultCredentialsPath()
|
||||
@@ -10,10 +12,10 @@ func platformReadCredentials(customPath string) (*oauthCredentials, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return readCredentialsFromFile(customPath)
|
||||
return readCredentialsFromFile(ctx, customPath)
|
||||
}
|
||||
|
||||
func platformWriteCredentials(credentials *oauthCredentials, customPath string) error {
|
||||
func platformWriteCredentials(ctx context.Context, credentials *oauthCredentials, customPath string) error {
|
||||
if customPath == "" {
|
||||
var err error
|
||||
customPath, err = getDefaultCredentialsPath()
|
||||
@@ -21,5 +23,5 @@ func platformWriteCredentials(credentials *oauthCredentials, customPath string)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writeCredentialsToFile(credentials, customPath)
|
||||
return writeCredentialsToFile(ctx, credentials, customPath)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
package ocm
|
||||
|
||||
func platformReadCredentials(customPath string) (*oauthCredentials, error) {
|
||||
import "context"
|
||||
|
||||
func platformReadCredentials(ctx context.Context, customPath string) (*oauthCredentials, error) {
|
||||
if customPath == "" {
|
||||
var err error
|
||||
customPath, err = getDefaultCredentialsPath()
|
||||
@@ -10,10 +12,10 @@ func platformReadCredentials(customPath string) (*oauthCredentials, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return readCredentialsFromFile(customPath)
|
||||
return readCredentialsFromFile(ctx, customPath)
|
||||
}
|
||||
|
||||
func platformWriteCredentials(credentials *oauthCredentials, customPath string) error {
|
||||
func platformWriteCredentials(ctx context.Context, credentials *oauthCredentials, customPath string) error {
|
||||
if customPath == "" {
|
||||
var err error
|
||||
customPath, err = getDefaultCredentialsPath()
|
||||
@@ -21,5 +23,5 @@ func platformWriteCredentials(credentials *oauthCredentials, customPath string)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writeCredentialsToFile(credentials, customPath)
|
||||
return writeCredentialsToFile(ctx, credentials, customPath)
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
|
||||
usageTracker = &AggregatedUsage{
|
||||
LastUpdated: time.Now(),
|
||||
Combinations: make([]CostCombination, 0),
|
||||
ctx: ctx,
|
||||
filePath: options.UsagesPath,
|
||||
logger: logger,
|
||||
}
|
||||
@@ -222,7 +223,7 @@ func (s *Service) Start(stage adapter.StartStage) error {
|
||||
|
||||
s.userManager.UpdateUsers(s.users)
|
||||
|
||||
credentials, err := platformReadCredentials(s.credentialPath)
|
||||
credentials, err := platformReadCredentials(s.ctx, s.credentialPath)
|
||||
if err != nil {
|
||||
return E.Cause(err, "read credentials")
|
||||
}
|
||||
@@ -292,7 +293,7 @@ func (s *Service) getAccessToken() (string, error) {
|
||||
|
||||
s.credentials = newCredentials
|
||||
|
||||
err = platformWriteCredentials(newCredentials, s.credentialPath)
|
||||
err = platformWriteCredentials(s.ctx, newCredentials, s.credentialPath)
|
||||
if err != nil {
|
||||
s.logger.Warn("persist refreshed token: ", err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ocm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
|
||||
"github.com/sagernet/sing-box/log"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/service/filemanager"
|
||||
)
|
||||
|
||||
type UsageStats struct {
|
||||
@@ -56,6 +58,7 @@ type AggregatedUsage struct {
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
Combinations []CostCombination `json:"combinations"`
|
||||
mutex sync.Mutex
|
||||
ctx context.Context
|
||||
filePath string
|
||||
logger log.ContextLogger
|
||||
lastSaveTime time.Time
|
||||
@@ -1072,7 +1075,7 @@ func (u *AggregatedUsage) Load() error {
|
||||
u.LastUpdated = time.Time{}
|
||||
u.Combinations = nil
|
||||
|
||||
data, err := os.ReadFile(u.filePath)
|
||||
data, err := filemanager.ReadFile(u.ctx, u.filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
@@ -1106,12 +1109,12 @@ func (u *AggregatedUsage) Save() error {
|
||||
}
|
||||
|
||||
tmpFile := u.filePath + ".tmp"
|
||||
err = os.WriteFile(tmpFile, data, 0o644)
|
||||
err = filemanager.WriteFile(u.ctx, tmpFile, data, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmpFile)
|
||||
err = os.Rename(tmpFile, u.filePath)
|
||||
defer filemanager.Remove(u.ctx, tmpFile)
|
||||
err = filemanager.Rename(u.ctx, tmpFile, u.filePath)
|
||||
if err == nil {
|
||||
u.saveMutex.Lock()
|
||||
u.lastSaveTime = time.Now()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build badlinkname
|
||||
|
||||
package oomkiller
|
||||
|
||||
import (
|
||||
"sync"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
//go:linkname jsonFieldCache json.fieldCache
|
||||
var jsonFieldCache sync.Map
|
||||
|
||||
//go:linkname contextJSONFieldCache github.com/sagernet/sing/common/json/internal/contextjson.fieldCache
|
||||
var contextJSONFieldCache sync.Map
|
||||
|
||||
func badCleanup() {
|
||||
jsonFieldCache.Clear()
|
||||
contextJSONFieldCache.Clear()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//go:build !badlinkname
|
||||
|
||||
package oomkiller
|
||||
|
||||
func badCleanup() {
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package oomkiller
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
func buildTimerConfig(options option.OOMKillerServiceOptions, memoryLimit uint64, useAvailable bool) (timerConfig, error) {
|
||||
safetyMargin := uint64(defaultSafetyMargin)
|
||||
if options.SafetyMargin != nil && options.SafetyMargin.Value() > 0 {
|
||||
safetyMargin = options.SafetyMargin.Value()
|
||||
}
|
||||
|
||||
minInterval := defaultMinInterval
|
||||
if options.MinInterval != 0 {
|
||||
minInterval = time.Duration(options.MinInterval.Build())
|
||||
if minInterval <= 0 {
|
||||
return timerConfig{}, E.New("min_interval must be greater than 0")
|
||||
}
|
||||
}
|
||||
|
||||
maxInterval := defaultMaxInterval
|
||||
if options.MaxInterval != 0 {
|
||||
maxInterval = time.Duration(options.MaxInterval.Build())
|
||||
if maxInterval <= 0 {
|
||||
return timerConfig{}, E.New("max_interval must be greater than 0")
|
||||
}
|
||||
}
|
||||
if maxInterval < minInterval {
|
||||
return timerConfig{}, E.New("max_interval must be greater than or equal to min_interval")
|
||||
}
|
||||
|
||||
checksBeforeLimit := defaultChecksBeforeLimit
|
||||
if options.ChecksBeforeLimit != 0 {
|
||||
checksBeforeLimit = options.ChecksBeforeLimit
|
||||
if checksBeforeLimit <= 0 {
|
||||
return timerConfig{}, E.New("checks_before_limit must be greater than 0")
|
||||
}
|
||||
}
|
||||
|
||||
return timerConfig{
|
||||
memoryLimit: memoryLimit,
|
||||
safetyMargin: safetyMargin,
|
||||
minInterval: minInterval,
|
||||
maxInterval: maxInterval,
|
||||
checksBeforeLimit: checksBeforeLimit,
|
||||
useAvailable: useAvailable,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package oomkiller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/memory"
|
||||
"github.com/sagernet/sing/service"
|
||||
)
|
||||
|
||||
const DefaultAppleNetworkExtensionMemoryLimit = 50 * 1024 * 1024
|
||||
|
||||
type policyMode uint8
|
||||
|
||||
const (
|
||||
policyModeNone policyMode = iota
|
||||
policyModeMemoryLimit
|
||||
policyModeAvailable
|
||||
policyModeNetworkExtension
|
||||
)
|
||||
|
||||
func (m policyMode) hasTimerMode() bool {
|
||||
return m != policyModeNone
|
||||
}
|
||||
|
||||
func resolvePolicyMode(ctx context.Context, options option.OOMKillerServiceOptions) (uint64, policyMode) {
|
||||
platformInterface := service.FromContext[adapter.PlatformInterface](ctx)
|
||||
if C.IsIos && platformInterface != nil && platformInterface.UnderNetworkExtension() {
|
||||
return DefaultAppleNetworkExtensionMemoryLimit, policyModeNetworkExtension
|
||||
}
|
||||
if options.MemoryLimitOverride > 0 {
|
||||
return options.MemoryLimitOverride, policyModeMemoryLimit
|
||||
}
|
||||
if options.MemoryLimit != nil {
|
||||
memoryLimit := options.MemoryLimit.Value()
|
||||
if memoryLimit > 0 {
|
||||
return memoryLimit, policyModeMemoryLimit
|
||||
}
|
||||
}
|
||||
if memory.AvailableAvailable() {
|
||||
return 0, policyModeAvailable
|
||||
}
|
||||
return 0, policyModeNone
|
||||
}
|
||||
+41
-160
@@ -1,193 +1,74 @@
|
||||
//go:build darwin && cgo
|
||||
|
||||
package oomkiller
|
||||
|
||||
/*
|
||||
#include <dispatch/dispatch.h>
|
||||
|
||||
static dispatch_source_t memoryPressureSource;
|
||||
|
||||
extern void goMemoryPressureCallback(unsigned long status);
|
||||
|
||||
static void startMemoryPressureMonitor() {
|
||||
memoryPressureSource = dispatch_source_create(
|
||||
DISPATCH_SOURCE_TYPE_MEMORYPRESSURE,
|
||||
0,
|
||||
DISPATCH_MEMORYPRESSURE_WARN | DISPATCH_MEMORYPRESSURE_CRITICAL,
|
||||
dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0)
|
||||
);
|
||||
dispatch_source_set_event_handler(memoryPressureSource, ^{
|
||||
unsigned long status = dispatch_source_get_data(memoryPressureSource);
|
||||
goMemoryPressureCallback(status);
|
||||
});
|
||||
dispatch_activate(memoryPressureSource);
|
||||
}
|
||||
|
||||
static void stopMemoryPressureMonitor() {
|
||||
if (memoryPressureSource) {
|
||||
dispatch_source_cancel(memoryPressureSource);
|
||||
memoryPressureSource = NULL;
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"context"
|
||||
runtimeDebug "runtime/debug"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
boxService "github.com/sagernet/sing-box/adapter/service"
|
||||
boxConstant "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/memory"
|
||||
"github.com/sagernet/sing/service"
|
||||
)
|
||||
|
||||
type OOMReporter interface {
|
||||
WriteReport(memoryUsage uint64) error
|
||||
WriteDraft(memoryUsage uint64) error
|
||||
DiscardDraft() error
|
||||
}
|
||||
|
||||
func RegisterService(registry *boxService.Registry) {
|
||||
boxService.Register[option.OOMKillerServiceOptions](registry, boxConstant.TypeOOMKiller, NewService)
|
||||
}
|
||||
|
||||
var (
|
||||
globalAccess sync.Mutex
|
||||
globalServices []*Service
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
boxService.Adapter
|
||||
logger log.ContextLogger
|
||||
router adapter.Router
|
||||
memoryLimit uint64
|
||||
hasTimerMode bool
|
||||
useAvailable bool
|
||||
timerConfig timerConfig
|
||||
adaptiveTimer *adaptiveTimer
|
||||
ctx context.Context
|
||||
logger log.ContextLogger
|
||||
network adapter.NetworkManager
|
||||
timerConfig timerConfig
|
||||
adaptiveTimer *adaptiveTimer
|
||||
lastReportTime atomic.Int64
|
||||
//nolint:unused // touched only on darwin && cgo via writeOOMDraft/discardOOMDraft.
|
||||
lastDraftTime atomic.Int64
|
||||
//nolint:unused // touched only on darwin && cgo via writeOOMDraft/discardOOMDraft.
|
||||
draftCancelled atomic.Bool
|
||||
}
|
||||
|
||||
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.OOMKillerServiceOptions) (adapter.Service, error) {
|
||||
s := &Service{
|
||||
Adapter: boxService.NewAdapter(boxConstant.TypeOOMKiller, tag),
|
||||
logger: logger,
|
||||
router: service.FromContext[adapter.Router](ctx),
|
||||
}
|
||||
|
||||
if options.MemoryLimit != nil {
|
||||
s.memoryLimit = options.MemoryLimit.Value()
|
||||
if s.memoryLimit > 0 {
|
||||
s.hasTimerMode = true
|
||||
}
|
||||
}
|
||||
|
||||
config, err := buildTimerConfig(options, s.memoryLimit, s.useAvailable)
|
||||
memoryLimit, mode := resolvePolicyMode(ctx, options)
|
||||
config, err := buildTimerConfig(options, memoryLimit, mode, options.KillerDisabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.timerConfig = config
|
||||
|
||||
return s, nil
|
||||
return &Service{
|
||||
Adapter: boxService.NewAdapter(boxConstant.TypeOOMKiller, tag),
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
network: service.FromContext[adapter.NetworkManager](ctx),
|
||||
timerConfig: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.hasTimerMode {
|
||||
s.adaptiveTimer = newAdaptiveTimer(s.logger, s.router, s.timerConfig)
|
||||
s.adaptiveTimer.start(false)
|
||||
if s.memoryLimit > 0 {
|
||||
s.logger.Notice("started memory monitor with limit: ", s.memoryLimit/(1024*1024), " MiB")
|
||||
} else {
|
||||
s.logger.Notice("started memory monitor with available memory detection")
|
||||
}
|
||||
} else {
|
||||
s.logger.Notice("started memory pressure monitor")
|
||||
}
|
||||
|
||||
globalAccess.Lock()
|
||||
isFirst := len(globalServices) == 0
|
||||
globalServices = append(globalServices, s)
|
||||
globalAccess.Unlock()
|
||||
|
||||
if isFirst {
|
||||
C.startMemoryPressureMonitor()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Close() error {
|
||||
if s.adaptiveTimer != nil {
|
||||
s.adaptiveTimer.stop()
|
||||
}
|
||||
globalAccess.Lock()
|
||||
for i, svc := range globalServices {
|
||||
if svc == s {
|
||||
globalServices = append(globalServices[:i], globalServices[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
isLast := len(globalServices) == 0
|
||||
globalAccess.Unlock()
|
||||
if isLast {
|
||||
C.stopMemoryPressureMonitor()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
//export goMemoryPressureCallback
|
||||
func goMemoryPressureCallback(status C.ulong) {
|
||||
globalAccess.Lock()
|
||||
services := make([]*Service, len(globalServices))
|
||||
copy(services, globalServices)
|
||||
globalAccess.Unlock()
|
||||
if len(services) == 0 {
|
||||
func (s *Service) writeOOMReport(memoryUsage uint64) {
|
||||
now := time.Now().Unix()
|
||||
lastReport := s.lastReportTime.Load()
|
||||
if now-lastReport < 3600 {
|
||||
return
|
||||
}
|
||||
criticalFlag := C.ulong(C.DISPATCH_MEMORYPRESSURE_CRITICAL)
|
||||
warnFlag := C.ulong(C.DISPATCH_MEMORYPRESSURE_WARN)
|
||||
isCritical := status&criticalFlag != 0
|
||||
isWarning := status&warnFlag != 0
|
||||
var level string
|
||||
switch {
|
||||
case isCritical:
|
||||
level = "critical"
|
||||
case isWarning:
|
||||
level = "warning"
|
||||
default:
|
||||
level = "normal"
|
||||
if !s.lastReportTime.CompareAndSwap(lastReport, now) {
|
||||
return
|
||||
}
|
||||
var freeOSMemory bool
|
||||
for _, s := range services {
|
||||
usage := memory.Total()
|
||||
if s.hasTimerMode {
|
||||
if isCritical {
|
||||
s.logger.Warn("memory pressure: ", level, ", usage: ", usage/(1024*1024), " MiB")
|
||||
if s.adaptiveTimer != nil {
|
||||
s.adaptiveTimer.start(true)
|
||||
}
|
||||
} else if isWarning {
|
||||
s.logger.Warn("memory pressure: ", level, ", usage: ", usage/(1024*1024), " MiB")
|
||||
} else {
|
||||
s.logger.Debug("memory pressure: ", level, ", usage: ", usage/(1024*1024), " MiB")
|
||||
if s.adaptiveTimer != nil {
|
||||
s.adaptiveTimer.stop()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if isCritical {
|
||||
s.logger.Error("memory pressure: ", level, ", usage: ", usage/(1024*1024), " MiB, resetting network")
|
||||
s.router.ResetNetwork()
|
||||
freeOSMemory = true
|
||||
} else if isWarning {
|
||||
s.logger.Warn("memory pressure: ", level, ", usage: ", usage/(1024*1024), " MiB")
|
||||
} else {
|
||||
s.logger.Debug("memory pressure: ", level, ", usage: ", usage/(1024*1024), " MiB")
|
||||
}
|
||||
}
|
||||
reporter := service.FromContext[OOMReporter](s.ctx)
|
||||
if reporter == nil {
|
||||
return
|
||||
}
|
||||
if freeOSMemory {
|
||||
runtimeDebug.FreeOSMemory()
|
||||
err := reporter.WriteReport(memoryUsage)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to write OOM report: ", err)
|
||||
} else {
|
||||
s.logger.Info("OOM report saved")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//go:build darwin && cgo
|
||||
|
||||
package oomkiller
|
||||
|
||||
/*
|
||||
#include <dispatch/dispatch.h>
|
||||
|
||||
static dispatch_source_t memoryPressureSource;
|
||||
|
||||
extern void goMemoryPressureCallback(unsigned long status);
|
||||
|
||||
static void startMemoryPressureMonitor() {
|
||||
memoryPressureSource = dispatch_source_create(
|
||||
DISPATCH_SOURCE_TYPE_MEMORYPRESSURE,
|
||||
0,
|
||||
DISPATCH_MEMORYPRESSURE_CRITICAL,
|
||||
dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0)
|
||||
);
|
||||
dispatch_source_set_event_handler(memoryPressureSource, ^{
|
||||
unsigned long status = dispatch_source_get_data(memoryPressureSource);
|
||||
goMemoryPressureCallback(status);
|
||||
});
|
||||
dispatch_activate(memoryPressureSource);
|
||||
}
|
||||
|
||||
static void stopMemoryPressureMonitor() {
|
||||
if (memoryPressureSource) {
|
||||
dispatch_source_cancel(memoryPressureSource);
|
||||
memoryPressureSource = NULL;
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing/common/byteformats"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/service"
|
||||
)
|
||||
|
||||
const oomDraftMinInterval = time.Hour
|
||||
|
||||
var (
|
||||
globalAccess sync.Mutex
|
||||
globalServices []*Service
|
||||
)
|
||||
|
||||
func (s *Service) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
if s.timerConfig.policyMode == policyModeNetworkExtension {
|
||||
s.adaptiveTimer = newAdaptiveTimer(s.logger, s.network, s.timerConfig, nil)
|
||||
globalAccess.Lock()
|
||||
isFirst := len(globalServices) == 0
|
||||
globalServices = append(globalServices, s)
|
||||
globalAccess.Unlock()
|
||||
if isFirst {
|
||||
C.startMemoryPressureMonitor()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !s.timerConfig.policyMode.hasTimerMode() {
|
||||
return E.New("memory pressure monitoring is not available on this platform without memory_limit")
|
||||
}
|
||||
s.adaptiveTimer = newAdaptiveTimer(s.logger, s.network, s.timerConfig, s.writeOOMReport)
|
||||
s.adaptiveTimer.start()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Close() error {
|
||||
if s.adaptiveTimer != nil {
|
||||
s.adaptiveTimer.stop()
|
||||
}
|
||||
if s.timerConfig.policyMode == policyModeNetworkExtension {
|
||||
globalAccess.Lock()
|
||||
for i, svc := range globalServices {
|
||||
if svc == s {
|
||||
globalServices = append(globalServices[:i], globalServices[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
isLast := len(globalServices) == 0
|
||||
globalAccess.Unlock()
|
||||
if isLast {
|
||||
C.stopMemoryPressureMonitor()
|
||||
}
|
||||
s.discardOOMDraft()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
//export goMemoryPressureCallback
|
||||
func goMemoryPressureCallback(status C.ulong) {
|
||||
globalAccess.Lock()
|
||||
services := make([]*Service, len(globalServices))
|
||||
copy(services, globalServices)
|
||||
globalAccess.Unlock()
|
||||
if len(services) == 0 {
|
||||
return
|
||||
}
|
||||
sample := readMemorySample(policyModeNetworkExtension)
|
||||
for _, s := range services {
|
||||
s.logger.Warn("memory pressure: critical, usage: ", byteformats.FormatMemoryBytes(sample.usage))
|
||||
s.writeOOMDraft(sample.usage)
|
||||
s.adaptiveTimer.notifyPressure()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) writeOOMDraft(memoryUsage uint64) {
|
||||
if s.draftCancelled.Load() {
|
||||
return
|
||||
}
|
||||
now := time.Now().UnixNano()
|
||||
lastDraft := s.lastDraftTime.Load()
|
||||
if time.Duration(now-lastDraft) < oomDraftMinInterval {
|
||||
return
|
||||
}
|
||||
s.lastDraftTime.Store(now)
|
||||
reporter := service.FromContext[OOMReporter](s.ctx)
|
||||
if reporter == nil {
|
||||
return
|
||||
}
|
||||
err := reporter.WriteDraft(memoryUsage)
|
||||
if s.draftCancelled.Load() {
|
||||
reporter.DiscardDraft()
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("failed to write OOM draft: ", err)
|
||||
} else {
|
||||
s.logger.Warn("OOM draft saved")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) discardOOMDraft() {
|
||||
s.draftCancelled.Store(true)
|
||||
reporter := service.FromContext[OOMReporter](s.ctx)
|
||||
if reporter == nil {
|
||||
return
|
||||
}
|
||||
err := reporter.DiscardDraft()
|
||||
if err != nil {
|
||||
s.logger.Error("failed to discard OOM draft: ", err)
|
||||
}
|
||||
}
|
||||
@@ -3,73 +3,19 @@
|
||||
package oomkiller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
boxService "github.com/sagernet/sing-box/adapter/service"
|
||||
boxConstant "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/memory"
|
||||
"github.com/sagernet/sing/service"
|
||||
)
|
||||
|
||||
func RegisterService(registry *boxService.Registry) {
|
||||
boxService.Register[option.OOMKillerServiceOptions](registry, boxConstant.TypeOOMKiller, NewService)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
boxService.Adapter
|
||||
logger log.ContextLogger
|
||||
router adapter.Router
|
||||
adaptiveTimer *adaptiveTimer
|
||||
timerConfig timerConfig
|
||||
hasTimerMode bool
|
||||
useAvailable bool
|
||||
memoryLimit uint64
|
||||
}
|
||||
|
||||
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.OOMKillerServiceOptions) (adapter.Service, error) {
|
||||
s := &Service{
|
||||
Adapter: boxService.NewAdapter(boxConstant.TypeOOMKiller, tag),
|
||||
logger: logger,
|
||||
router: service.FromContext[adapter.Router](ctx),
|
||||
}
|
||||
|
||||
if options.MemoryLimit != nil {
|
||||
s.memoryLimit = options.MemoryLimit.Value()
|
||||
}
|
||||
if s.memoryLimit > 0 {
|
||||
s.hasTimerMode = true
|
||||
} else if memory.AvailableSupported() {
|
||||
s.useAvailable = true
|
||||
s.hasTimerMode = true
|
||||
}
|
||||
|
||||
config, err := buildTimerConfig(options, s.memoryLimit, s.useAvailable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.timerConfig = config
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
if !s.hasTimerMode {
|
||||
if !s.timerConfig.policyMode.hasTimerMode() {
|
||||
return E.New("memory pressure monitoring is not available on this platform without memory_limit")
|
||||
}
|
||||
s.adaptiveTimer = newAdaptiveTimer(s.logger, s.router, s.timerConfig)
|
||||
s.adaptiveTimer.start(false)
|
||||
if s.useAvailable {
|
||||
s.logger.Notice("started memory monitor with available memory detection")
|
||||
} else {
|
||||
s.logger.Notice("started memory monitor with limit: ", s.memoryLimit/(1024*1024), " MiB")
|
||||
}
|
||||
s.adaptiveTimer = newAdaptiveTimer(s.logger, s.network, s.timerConfig, s.writeOOMReport)
|
||||
s.adaptiveTimer.start()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
package oomkiller
|
||||
|
||||
import (
|
||||
runtimeDebug "runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing/common/memory"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultChecksBeforeLimit = 4
|
||||
defaultMinInterval = 500 * time.Millisecond
|
||||
defaultMaxInterval = 10 * time.Second
|
||||
defaultSafetyMargin = 5 * 1024 * 1024
|
||||
)
|
||||
|
||||
type adaptiveTimer struct {
|
||||
logger log.ContextLogger
|
||||
router adapter.Router
|
||||
memoryLimit uint64
|
||||
safetyMargin uint64
|
||||
minInterval time.Duration
|
||||
maxInterval time.Duration
|
||||
checksBeforeLimit int
|
||||
useAvailable bool
|
||||
|
||||
access sync.Mutex
|
||||
timer *time.Timer
|
||||
previousUsage uint64
|
||||
lastInterval time.Duration
|
||||
}
|
||||
|
||||
type timerConfig struct {
|
||||
memoryLimit uint64
|
||||
safetyMargin uint64
|
||||
minInterval time.Duration
|
||||
maxInterval time.Duration
|
||||
checksBeforeLimit int
|
||||
useAvailable bool
|
||||
}
|
||||
|
||||
func newAdaptiveTimer(logger log.ContextLogger, router adapter.Router, config timerConfig) *adaptiveTimer {
|
||||
return &adaptiveTimer{
|
||||
logger: logger,
|
||||
router: router,
|
||||
memoryLimit: config.memoryLimit,
|
||||
safetyMargin: config.safetyMargin,
|
||||
minInterval: config.minInterval,
|
||||
maxInterval: config.maxInterval,
|
||||
checksBeforeLimit: config.checksBeforeLimit,
|
||||
useAvailable: config.useAvailable,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *adaptiveTimer) start(immediate bool) {
|
||||
t.access.Lock()
|
||||
t.startLocked()
|
||||
t.access.Unlock()
|
||||
if immediate {
|
||||
t.poll()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *adaptiveTimer) startLocked() {
|
||||
if t.timer != nil {
|
||||
return
|
||||
}
|
||||
t.previousUsage = memory.Total()
|
||||
t.lastInterval = t.minInterval
|
||||
t.timer = time.AfterFunc(t.minInterval, t.poll)
|
||||
}
|
||||
|
||||
func (t *adaptiveTimer) stop() {
|
||||
t.access.Lock()
|
||||
defer t.access.Unlock()
|
||||
t.stopLocked()
|
||||
}
|
||||
|
||||
func (t *adaptiveTimer) stopLocked() {
|
||||
if t.timer != nil {
|
||||
t.timer.Stop()
|
||||
t.timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (t *adaptiveTimer) poll() {
|
||||
t.access.Lock()
|
||||
defer t.access.Unlock()
|
||||
if t.timer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
usage := memory.Total()
|
||||
delta := int64(usage) - int64(t.previousUsage)
|
||||
t.previousUsage = usage
|
||||
|
||||
var remaining uint64
|
||||
var triggered bool
|
||||
|
||||
if t.memoryLimit > 0 {
|
||||
if usage >= t.memoryLimit {
|
||||
remaining = 0
|
||||
triggered = true
|
||||
} else {
|
||||
remaining = t.memoryLimit - usage
|
||||
}
|
||||
} else if t.useAvailable {
|
||||
available := memory.Available()
|
||||
if available <= t.safetyMargin {
|
||||
remaining = 0
|
||||
triggered = true
|
||||
} else {
|
||||
remaining = available - t.safetyMargin
|
||||
}
|
||||
} else {
|
||||
remaining = 0
|
||||
}
|
||||
|
||||
if triggered {
|
||||
t.logger.Error("memory threshold reached, usage: ", usage/(1024*1024), " MiB, resetting network")
|
||||
t.router.ResetNetwork()
|
||||
runtimeDebug.FreeOSMemory()
|
||||
}
|
||||
|
||||
var interval time.Duration
|
||||
if triggered {
|
||||
interval = t.maxInterval
|
||||
} else if delta <= 0 {
|
||||
interval = t.maxInterval
|
||||
} else if t.checksBeforeLimit <= 0 {
|
||||
interval = t.maxInterval
|
||||
} else {
|
||||
timeToLimit := time.Duration(float64(remaining) / float64(delta) * float64(t.lastInterval))
|
||||
interval = max(timeToLimit/time.Duration(t.checksBeforeLimit), t.minInterval)
|
||||
interval = min(interval, t.maxInterval)
|
||||
}
|
||||
|
||||
t.lastInterval = interval
|
||||
t.timer.Reset(interval)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package oomkiller
|
||||
|
||||
import (
|
||||
"context"
|
||||
runtimeDebug "runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/byteformats"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/memory"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMinInterval = 100 * time.Millisecond
|
||||
defaultArmedInterval = time.Second
|
||||
defaultMaxInterval = 10 * time.Second
|
||||
defaultSafetyMargin = 5 * 1024 * 1024
|
||||
defaultAvailableTriggerMarginMin = 32 * 1024 * 1024
|
||||
defaultAvailableTriggerMarginMax = 128 * 1024 * 1024
|
||||
)
|
||||
|
||||
type pressureState uint8
|
||||
|
||||
const (
|
||||
pressureStateNormal pressureState = iota
|
||||
pressureStateArmed
|
||||
pressureStateTriggered
|
||||
)
|
||||
|
||||
type memorySample struct {
|
||||
usage uint64
|
||||
available uint64
|
||||
availableKnown bool
|
||||
}
|
||||
|
||||
type pressureThresholds struct {
|
||||
trigger uint64
|
||||
armed uint64
|
||||
resume uint64
|
||||
}
|
||||
|
||||
type timerConfig struct {
|
||||
memoryLimit uint64
|
||||
safetyMargin uint64
|
||||
hasSafetyMargin bool
|
||||
minInterval time.Duration
|
||||
armedInterval time.Duration
|
||||
maxInterval time.Duration
|
||||
policyMode policyMode
|
||||
killerDisabled bool
|
||||
}
|
||||
|
||||
func buildTimerConfig(options option.OOMKillerServiceOptions, memoryLimit uint64, policyMode policyMode, killerDisabled bool) (timerConfig, error) {
|
||||
minInterval := defaultMinInterval
|
||||
if options.MinInterval != 0 {
|
||||
minInterval = time.Duration(options.MinInterval.Build())
|
||||
if minInterval <= 0 {
|
||||
return timerConfig{}, E.New("min_interval must be greater than 0")
|
||||
}
|
||||
}
|
||||
|
||||
maxInterval := defaultMaxInterval
|
||||
if options.MaxInterval != 0 {
|
||||
maxInterval = time.Duration(options.MaxInterval.Build())
|
||||
if maxInterval <= 0 {
|
||||
return timerConfig{}, E.New("max_interval must be greater than 0")
|
||||
}
|
||||
}
|
||||
if maxInterval < minInterval {
|
||||
return timerConfig{}, E.New("max_interval must be greater than or equal to min_interval")
|
||||
}
|
||||
|
||||
var (
|
||||
safetyMargin uint64
|
||||
hasSafetyMargin bool
|
||||
)
|
||||
if options.SafetyMargin != nil && options.SafetyMargin.Value() > 0 {
|
||||
safetyMargin = options.SafetyMargin.Value()
|
||||
hasSafetyMargin = true
|
||||
} else if memoryLimit > 0 {
|
||||
safetyMargin = defaultSafetyMargin
|
||||
hasSafetyMargin = true
|
||||
}
|
||||
|
||||
return timerConfig{
|
||||
memoryLimit: memoryLimit,
|
||||
safetyMargin: safetyMargin,
|
||||
hasSafetyMargin: hasSafetyMargin,
|
||||
minInterval: minInterval,
|
||||
armedInterval: max(min(defaultArmedInterval, maxInterval), minInterval),
|
||||
maxInterval: maxInterval,
|
||||
policyMode: policyMode,
|
||||
killerDisabled: killerDisabled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type adaptiveTimer struct {
|
||||
timerConfig
|
||||
logger log.ContextLogger
|
||||
network adapter.NetworkManager
|
||||
onTriggered func(uint64)
|
||||
limitThresholds pressureThresholds
|
||||
|
||||
access sync.Mutex
|
||||
timer *time.Timer
|
||||
state pressureState
|
||||
currentInterval time.Duration
|
||||
forceMinInterval bool
|
||||
pendingPressureBaseline bool
|
||||
pressureBaseline memorySample
|
||||
pressureBaselineTime time.Time
|
||||
}
|
||||
|
||||
func newAdaptiveTimer(logger log.ContextLogger, network adapter.NetworkManager, config timerConfig, onTriggered func(uint64)) *adaptiveTimer {
|
||||
t := &adaptiveTimer{
|
||||
timerConfig: config,
|
||||
logger: logger,
|
||||
network: network,
|
||||
onTriggered: onTriggered,
|
||||
}
|
||||
if config.policyMode == policyModeMemoryLimit || config.policyMode == policyModeNetworkExtension {
|
||||
t.limitThresholds = computeLimitThresholds(config.memoryLimit, config.safetyMargin)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *adaptiveTimer) start() {
|
||||
t.access.Lock()
|
||||
defer t.access.Unlock()
|
||||
t.startLocked()
|
||||
}
|
||||
|
||||
func (t *adaptiveTimer) startLocked() {
|
||||
if t.timer != nil {
|
||||
return
|
||||
}
|
||||
t.state = pressureStateNormal
|
||||
t.forceMinInterval = false
|
||||
t.timer = time.AfterFunc(t.minInterval, t.poll)
|
||||
}
|
||||
|
||||
func (t *adaptiveTimer) stop() {
|
||||
t.access.Lock()
|
||||
defer t.access.Unlock()
|
||||
if t.timer != nil {
|
||||
t.timer.Stop()
|
||||
t.timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (t *adaptiveTimer) poll() {
|
||||
var triggered bool
|
||||
var rateTriggered bool
|
||||
sample := readMemorySample(t.policyMode)
|
||||
|
||||
t.access.Lock()
|
||||
if t.timer == nil {
|
||||
t.access.Unlock()
|
||||
return
|
||||
}
|
||||
if t.pendingPressureBaseline {
|
||||
t.pressureBaseline = sample
|
||||
t.pressureBaselineTime = time.Now()
|
||||
t.pendingPressureBaseline = false
|
||||
}
|
||||
previousState := t.state
|
||||
t.state = t.nextState(sample)
|
||||
if t.state == pressureStateNormal {
|
||||
t.forceMinInterval = false
|
||||
if !t.pressureBaselineTime.IsZero() && time.Since(t.pressureBaselineTime) > t.maxInterval {
|
||||
t.pressureBaselineTime = time.Time{}
|
||||
}
|
||||
}
|
||||
t.timer.Reset(t.intervalForState())
|
||||
triggered = previousState != pressureStateTriggered && t.state == pressureStateTriggered
|
||||
if !triggered && !t.pressureBaselineTime.IsZero() && t.memoryLimit > 0 &&
|
||||
sample.usage > t.pressureBaseline.usage && sample.usage < t.memoryLimit {
|
||||
elapsed := time.Since(t.pressureBaselineTime)
|
||||
if elapsed >= t.minInterval/2 {
|
||||
growth := sample.usage - t.pressureBaseline.usage
|
||||
ratePerSecond := float64(growth) / elapsed.Seconds()
|
||||
headroom := t.memoryLimit - sample.usage
|
||||
secondsUntilLimit := float64(headroom) / ratePerSecond
|
||||
if secondsUntilLimit < t.minInterval.Seconds() {
|
||||
triggered = true
|
||||
rateTriggered = true
|
||||
t.state = pressureStateTriggered
|
||||
}
|
||||
}
|
||||
}
|
||||
t.access.Unlock()
|
||||
if !triggered {
|
||||
return
|
||||
}
|
||||
if t.onTriggered != nil {
|
||||
t.onTriggered(sample.usage)
|
||||
}
|
||||
if rateTriggered {
|
||||
if t.killerDisabled {
|
||||
t.logger.Warn("memory growth rate critical (report only), usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample))
|
||||
} else {
|
||||
t.logger.Error("memory growth rate critical, usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample), ", resetting network")
|
||||
t.network.ResetNetwork(context.Background())
|
||||
}
|
||||
} else {
|
||||
if t.killerDisabled {
|
||||
t.logger.Warn("memory threshold reached (report only), usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample))
|
||||
} else {
|
||||
t.logger.Error("memory threshold reached, usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample), ", resetting network")
|
||||
t.network.ResetNetwork(context.Background())
|
||||
}
|
||||
}
|
||||
badCleanup()
|
||||
runtimeDebug.FreeOSMemory()
|
||||
}
|
||||
|
||||
func (t *adaptiveTimer) nextState(sample memorySample) pressureState {
|
||||
switch t.policyMode {
|
||||
case policyModeMemoryLimit, policyModeNetworkExtension:
|
||||
return nextPressureState(t.state,
|
||||
sample.usage >= t.limitThresholds.trigger,
|
||||
sample.usage >= t.limitThresholds.armed,
|
||||
sample.usage >= t.limitThresholds.resume,
|
||||
)
|
||||
case policyModeAvailable:
|
||||
if !sample.availableKnown {
|
||||
return pressureStateNormal
|
||||
}
|
||||
thresholds := t.availableThresholds(sample)
|
||||
return nextPressureState(t.state,
|
||||
sample.available <= thresholds.trigger,
|
||||
sample.available <= thresholds.armed,
|
||||
sample.available <= thresholds.resume,
|
||||
)
|
||||
default:
|
||||
return pressureStateNormal
|
||||
}
|
||||
}
|
||||
|
||||
func computeLimitThresholds(memoryLimit uint64, safetyMargin uint64) pressureThresholds {
|
||||
triggerMargin := min(safetyMargin, memoryLimit)
|
||||
armedMargin := min(triggerMargin*2, memoryLimit)
|
||||
resumeMargin := min(triggerMargin*4, memoryLimit)
|
||||
return pressureThresholds{
|
||||
trigger: memoryLimit - triggerMargin,
|
||||
armed: memoryLimit - armedMargin,
|
||||
resume: memoryLimit - resumeMargin,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *adaptiveTimer) availableThresholds(sample memorySample) pressureThresholds {
|
||||
var triggerMargin uint64
|
||||
if t.hasSafetyMargin {
|
||||
triggerMargin = t.safetyMargin
|
||||
} else if sample.usage == 0 {
|
||||
triggerMargin = defaultAvailableTriggerMarginMin
|
||||
} else {
|
||||
triggerMargin = max(defaultAvailableTriggerMarginMin, min(sample.usage/4, defaultAvailableTriggerMarginMax))
|
||||
}
|
||||
return pressureThresholds{
|
||||
trigger: triggerMargin,
|
||||
armed: triggerMargin * 2,
|
||||
resume: triggerMargin * 4,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *adaptiveTimer) intervalForState() time.Duration {
|
||||
switch {
|
||||
case t.forceMinInterval || t.state == pressureStateTriggered:
|
||||
t.currentInterval = t.minInterval
|
||||
case t.state == pressureStateArmed:
|
||||
t.currentInterval = t.armedInterval
|
||||
default:
|
||||
if t.currentInterval == 0 {
|
||||
t.currentInterval = t.maxInterval
|
||||
} else {
|
||||
t.currentInterval = min(t.currentInterval*2, t.maxInterval)
|
||||
}
|
||||
}
|
||||
return t.currentInterval
|
||||
}
|
||||
|
||||
func (t *adaptiveTimer) logDetails(sample memorySample) string {
|
||||
switch t.policyMode {
|
||||
case policyModeMemoryLimit, policyModeNetworkExtension:
|
||||
headroom := uint64(0)
|
||||
if sample.usage < t.memoryLimit {
|
||||
headroom = t.memoryLimit - sample.usage
|
||||
}
|
||||
return ", limit: " + byteformats.FormatMemoryBytes(t.memoryLimit) + ", headroom: " + byteformats.FormatMemoryBytes(headroom)
|
||||
case policyModeAvailable:
|
||||
if sample.availableKnown {
|
||||
return ", available: " + byteformats.FormatMemoryBytes(sample.available)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nextPressureState(current pressureState, shouldTrigger, shouldArm, shouldStayTriggered bool) pressureState {
|
||||
if current == pressureStateTriggered {
|
||||
if shouldStayTriggered {
|
||||
return pressureStateTriggered
|
||||
}
|
||||
return pressureStateNormal
|
||||
}
|
||||
if shouldTrigger {
|
||||
return pressureStateTriggered
|
||||
}
|
||||
if shouldArm {
|
||||
return pressureStateArmed
|
||||
}
|
||||
return pressureStateNormal
|
||||
}
|
||||
|
||||
func readMemorySample(mode policyMode) memorySample {
|
||||
sample := memorySample{
|
||||
usage: memory.Total(),
|
||||
}
|
||||
if mode == policyModeAvailable {
|
||||
sample.availableKnown = true
|
||||
sample.available = memory.Available()
|
||||
}
|
||||
return sample
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build darwin && cgo
|
||||
|
||||
package oomkiller
|
||||
|
||||
import runtimeDebug "runtime/debug"
|
||||
|
||||
func (t *adaptiveTimer) notifyPressure() {
|
||||
runtimeDebug.FreeOSMemory()
|
||||
t.access.Lock()
|
||||
t.startLocked()
|
||||
t.forceMinInterval = true
|
||||
t.pendingPressureBaseline = true
|
||||
t.access.Unlock()
|
||||
t.poll()
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
package originca
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/certificate"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/ntp"
|
||||
"github.com/sagernet/sing/service"
|
||||
"github.com/sagernet/sing/service/filemanager"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
)
|
||||
|
||||
const (
|
||||
cloudflareOriginCAEndpoint = "https://api.cloudflare.com/client/v4/certificates"
|
||||
defaultRequestedValidity = option.CloudflareOriginCARequestValidity5475
|
||||
// min of 30 days and certmagic's 1/3 lifetime ratio (maintain.go)
|
||||
defaultRenewBefore = 30 * 24 * time.Hour
|
||||
// from certmagic retry backoff range (async.go)
|
||||
minimumRenewRetryDelay = time.Minute
|
||||
maximumRenewRetryDelay = time.Hour
|
||||
storageLockPrefix = "cloudflare-origin-ca"
|
||||
)
|
||||
|
||||
func RegisterCertificateProvider(registry *certificate.Registry) {
|
||||
certificate.Register[option.CloudflareOriginCACertificateProviderOptions](registry, C.TypeCloudflareOriginCA, NewCertificateProvider)
|
||||
}
|
||||
|
||||
var _ adapter.CertificateProviderService = (*Service)(nil)
|
||||
|
||||
type Service struct {
|
||||
certificate.Adapter
|
||||
logger log.ContextLogger
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
timeFunc func() time.Time
|
||||
httpClient *http.Client
|
||||
storage certmagic.Storage
|
||||
dataDirectory string
|
||||
storageIssuerKey string
|
||||
storageNamesKey string
|
||||
storageLockKey string
|
||||
apiToken string
|
||||
originCAKey string
|
||||
domain []string
|
||||
requestType option.CloudflareOriginCARequestType
|
||||
requestedValidity option.CloudflareOriginCARequestValidity
|
||||
|
||||
access sync.RWMutex
|
||||
currentCertificate *tls.Certificate
|
||||
currentLeaf *x509.Certificate
|
||||
}
|
||||
|
||||
func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag string, options option.CloudflareOriginCACertificateProviderOptions) (adapter.CertificateProviderService, error) {
|
||||
domain, err := normalizeHostnames(options.Domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(domain) == 0 {
|
||||
return nil, E.New("missing domain")
|
||||
}
|
||||
apiToken := strings.TrimSpace(options.APIToken)
|
||||
originCAKey := strings.TrimSpace(options.OriginCAKey)
|
||||
switch {
|
||||
case apiToken == "" && originCAKey == "":
|
||||
return nil, E.New("api_token or origin_ca_key is required")
|
||||
case apiToken != "" && originCAKey != "":
|
||||
return nil, E.New("api_token and origin_ca_key are mutually exclusive")
|
||||
}
|
||||
requestType := options.RequestType
|
||||
if requestType == "" {
|
||||
requestType = option.CloudflareOriginCARequestTypeOriginRSA
|
||||
}
|
||||
requestedValidity := options.RequestedValidity
|
||||
if requestedValidity == 0 {
|
||||
requestedValidity = defaultRequestedValidity
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
httpClient, err := originCAHTTPClient(ctx, logger, options)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
var (
|
||||
storage certmagic.Storage
|
||||
dataDirectory string
|
||||
)
|
||||
if options.DataDirectory != "" {
|
||||
dataDirectory = filemanager.BasePath(ctx, os.ExpandEnv(options.DataDirectory))
|
||||
storage = &certmagic.FileStorage{Path: dataDirectory}
|
||||
} else {
|
||||
storage = certmagic.Default.Storage
|
||||
}
|
||||
timeFunc := ntp.TimeFuncFromContext(ctx)
|
||||
if timeFunc == nil {
|
||||
timeFunc = time.Now
|
||||
}
|
||||
storageIssuerKey := C.TypeCloudflareOriginCA + "-" + string(requestType)
|
||||
storageNamesKey := (&certmagic.CertificateResource{SANs: slices.Clone(domain)}).NamesKey()
|
||||
storageLockKey := strings.Join([]string{
|
||||
storageLockPrefix,
|
||||
certmagic.StorageKeys.Safe(storageIssuerKey),
|
||||
certmagic.StorageKeys.Safe(storageNamesKey),
|
||||
}, "/")
|
||||
return &Service{
|
||||
Adapter: certificate.NewAdapter(C.TypeCloudflareOriginCA, tag),
|
||||
logger: logger,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
timeFunc: timeFunc,
|
||||
httpClient: httpClient,
|
||||
storage: storage,
|
||||
dataDirectory: dataDirectory,
|
||||
storageIssuerKey: storageIssuerKey,
|
||||
storageNamesKey: storageNamesKey,
|
||||
storageLockKey: storageLockKey,
|
||||
apiToken: apiToken,
|
||||
originCAKey: originCAKey,
|
||||
domain: domain,
|
||||
requestType: requestType,
|
||||
requestedValidity: requestedValidity,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func originCAHTTPClient(ctx context.Context, logger log.ContextLogger, options option.CloudflareOriginCACertificateProviderOptions) (*http.Client, error) {
|
||||
httpClientOptions := common.PtrValueOrDefault(options.HTTPClient)
|
||||
httpClientManager := service.FromContext[adapter.HTTPClientManager](ctx)
|
||||
transport, err := httpClientManager.ResolveTransport(ctx, logger, httpClientOptions)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "create Cloudflare Origin CA http client")
|
||||
}
|
||||
return &http.Client{Transport: transport}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(stage adapter.StartStage) error {
|
||||
if stage == adapter.StartStateInitialize {
|
||||
if s.dataDirectory == "" {
|
||||
return nil
|
||||
}
|
||||
err := filemanager.MkdirAll(s.ctx, s.dataDirectory, 0o700)
|
||||
if err != nil {
|
||||
return E.Cause(err, "create data directory")
|
||||
}
|
||||
return nil
|
||||
} else if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
cachedCertificate, cachedLeaf, err := s.loadCachedCertificate()
|
||||
if err != nil {
|
||||
s.logger.Warn(E.Cause(err, "load cached Cloudflare Origin CA certificate"))
|
||||
} else if cachedCertificate != nil {
|
||||
s.setCurrentCertificate(cachedCertificate, cachedLeaf)
|
||||
}
|
||||
if cachedCertificate == nil {
|
||||
err = s.issueAndStoreCertificate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if s.shouldRenew(cachedLeaf, s.timeFunc()) {
|
||||
err = s.issueAndStoreCertificate()
|
||||
if err != nil {
|
||||
s.logger.Warn(E.Cause(err, "renew cached Cloudflare Origin CA certificate"))
|
||||
}
|
||||
}
|
||||
s.done = make(chan struct{})
|
||||
go s.refreshLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Close() error {
|
||||
s.cancel()
|
||||
if done := s.done; done != nil {
|
||||
<-done
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
s.access.RLock()
|
||||
certificate := s.currentCertificate
|
||||
s.access.RUnlock()
|
||||
if certificate == nil {
|
||||
return nil, E.New("Cloudflare Origin CA certificate is unavailable")
|
||||
}
|
||||
return certificate, nil
|
||||
}
|
||||
|
||||
func (s *Service) refreshLoop() {
|
||||
defer close(s.done)
|
||||
var retryDelay time.Duration
|
||||
for {
|
||||
waitDuration := retryDelay
|
||||
if waitDuration == 0 {
|
||||
s.access.RLock()
|
||||
leaf := s.currentLeaf
|
||||
s.access.RUnlock()
|
||||
if leaf == nil {
|
||||
waitDuration = minimumRenewRetryDelay
|
||||
} else {
|
||||
refreshAt := leaf.NotAfter.Add(-s.effectiveRenewBefore(leaf))
|
||||
waitDuration = max(refreshAt.Sub(s.timeFunc()), minimumRenewRetryDelay)
|
||||
}
|
||||
}
|
||||
timer := time.NewTimer(waitDuration)
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
err := s.issueAndStoreCertificate()
|
||||
if err != nil {
|
||||
s.logger.Error(E.Cause(err, "renew Cloudflare Origin CA certificate"))
|
||||
s.access.RLock()
|
||||
leaf := s.currentLeaf
|
||||
s.access.RUnlock()
|
||||
if leaf == nil {
|
||||
retryDelay = minimumRenewRetryDelay
|
||||
} else {
|
||||
remaining := leaf.NotAfter.Sub(s.timeFunc())
|
||||
switch {
|
||||
case remaining <= minimumRenewRetryDelay:
|
||||
retryDelay = minimumRenewRetryDelay
|
||||
case remaining < maximumRenewRetryDelay:
|
||||
retryDelay = max(remaining/2, minimumRenewRetryDelay)
|
||||
default:
|
||||
retryDelay = maximumRenewRetryDelay
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
retryDelay = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) shouldRenew(leaf *x509.Certificate, now time.Time) bool {
|
||||
return !now.Before(leaf.NotAfter.Add(-s.effectiveRenewBefore(leaf)))
|
||||
}
|
||||
|
||||
func (s *Service) effectiveRenewBefore(leaf *x509.Certificate) time.Duration {
|
||||
lifetime := leaf.NotAfter.Sub(leaf.NotBefore)
|
||||
if lifetime <= 0 {
|
||||
return 0
|
||||
}
|
||||
return min(lifetime/3, defaultRenewBefore)
|
||||
}
|
||||
|
||||
func (s *Service) issueAndStoreCertificate() error {
|
||||
err := s.storage.Lock(s.ctx, s.storageLockKey)
|
||||
if err != nil {
|
||||
return E.Cause(err, "lock Cloudflare Origin CA certificate storage")
|
||||
}
|
||||
defer func() {
|
||||
err = s.storage.Unlock(context.WithoutCancel(s.ctx), s.storageLockKey)
|
||||
if err != nil {
|
||||
s.logger.Warn(E.Cause(err, "unlock Cloudflare Origin CA certificate storage"))
|
||||
}
|
||||
}()
|
||||
cachedCertificate, cachedLeaf, err := s.loadCachedCertificate()
|
||||
if err != nil {
|
||||
s.logger.Warn(E.Cause(err, "load cached Cloudflare Origin CA certificate"))
|
||||
} else if cachedCertificate != nil && !s.shouldRenew(cachedLeaf, s.timeFunc()) {
|
||||
s.setCurrentCertificate(cachedCertificate, cachedLeaf)
|
||||
return nil
|
||||
}
|
||||
certificatePEM, privateKeyPEM, tlsCertificate, leaf, err := s.requestCertificate(s.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
issuerData, err := json.Marshal(originCAIssuerData{
|
||||
RequestType: s.requestType,
|
||||
RequestedValidity: s.requestedValidity,
|
||||
})
|
||||
if err != nil {
|
||||
return E.Cause(err, "encode Cloudflare Origin CA certificate metadata")
|
||||
}
|
||||
err = storeCertificateResource(s.ctx, s.storage, s.storageIssuerKey, certmagic.CertificateResource{
|
||||
SANs: slices.Clone(s.domain),
|
||||
CertificatePEM: certificatePEM,
|
||||
PrivateKeyPEM: privateKeyPEM,
|
||||
IssuerData: issuerData,
|
||||
})
|
||||
if err != nil {
|
||||
return E.Cause(err, "store Cloudflare Origin CA certificate")
|
||||
}
|
||||
s.setCurrentCertificate(tlsCertificate, leaf)
|
||||
s.logger.Info("updated Cloudflare Origin CA certificate, expires at ", leaf.NotAfter.Format(time.RFC3339))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) requestCertificate(ctx context.Context) ([]byte, []byte, *tls.Certificate, *x509.Certificate, error) {
|
||||
var privateKey crypto.Signer
|
||||
switch s.requestType {
|
||||
case option.CloudflareOriginCARequestTypeOriginRSA:
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
privateKey = rsaKey
|
||||
case option.CloudflareOriginCARequestTypeOriginECC:
|
||||
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
privateKey = ecKey
|
||||
default:
|
||||
return nil, nil, nil, nil, E.New("unsupported Cloudflare Origin CA request type: ", string(s.requestType))
|
||||
}
|
||||
privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, E.Cause(err, "encode private key")
|
||||
}
|
||||
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: privateKeyDER,
|
||||
})
|
||||
certificateRequestDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: s.domain[0]},
|
||||
DNSNames: s.domain,
|
||||
}, privateKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, E.Cause(err, "create certificate request")
|
||||
}
|
||||
certificateRequestPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: certificateRequestDER,
|
||||
})
|
||||
requestBody, err := json.Marshal(originCARequest{
|
||||
CSR: string(certificateRequestPEM),
|
||||
Hostnames: s.domain,
|
||||
RequestType: string(s.requestType),
|
||||
RequestedValidity: uint16(s.requestedValidity),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, E.Cause(err, "marshal request")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, cloudflareOriginCAEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, E.Cause(err, "create request")
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "sing-box/"+C.Version)
|
||||
if s.apiToken != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+s.apiToken)
|
||||
} else {
|
||||
request.Header.Set("X-Auth-User-Service-Key", s.originCAKey)
|
||||
}
|
||||
defer s.httpClient.CloseIdleConnections()
|
||||
response, err := s.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, E.Cause(err, "request certificate from Cloudflare")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, E.Cause(err, "read Cloudflare response")
|
||||
}
|
||||
var responseEnvelope originCAResponse
|
||||
err = json.Unmarshal(responseBody, &responseEnvelope)
|
||||
if err != nil && response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
|
||||
return nil, nil, nil, nil, E.Cause(err, "decode Cloudflare response")
|
||||
}
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, nil, nil, nil, buildOriginCAError(response.StatusCode, responseEnvelope.Errors, responseBody)
|
||||
}
|
||||
if !responseEnvelope.Success {
|
||||
return nil, nil, nil, nil, buildOriginCAError(response.StatusCode, responseEnvelope.Errors, responseBody)
|
||||
}
|
||||
if responseEnvelope.Result.Certificate == "" {
|
||||
return nil, nil, nil, nil, E.New("Cloudflare Origin CA response is missing certificate data")
|
||||
}
|
||||
certificatePEM := []byte(responseEnvelope.Result.Certificate)
|
||||
tlsCertificate, leaf, err := parseKeyPair(certificatePEM, privateKeyPEM)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, E.Cause(err, "parse issued certificate")
|
||||
}
|
||||
if !s.matchesCertificate(leaf) {
|
||||
return nil, nil, nil, nil, E.New("issued Cloudflare Origin CA certificate does not match requested hostnames or key type")
|
||||
}
|
||||
return certificatePEM, privateKeyPEM, tlsCertificate, leaf, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadCachedCertificate() (*tls.Certificate, *x509.Certificate, error) {
|
||||
certificateResource, err := loadCertificateResource(s.ctx, s.storage, s.storageIssuerKey, s.storageNamesKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
tlsCertificate, leaf, err := parseKeyPair(certificateResource.CertificatePEM, certificateResource.PrivateKeyPEM)
|
||||
if err != nil {
|
||||
return nil, nil, E.Cause(err, "parse cached key pair")
|
||||
}
|
||||
if s.timeFunc().After(leaf.NotAfter) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if !s.matchesCertificate(leaf) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return tlsCertificate, leaf, nil
|
||||
}
|
||||
|
||||
func (s *Service) matchesCertificate(leaf *x509.Certificate) bool {
|
||||
if leaf == nil {
|
||||
return false
|
||||
}
|
||||
leafHostnames := leaf.DNSNames
|
||||
if len(leafHostnames) == 0 && leaf.Subject.CommonName != "" {
|
||||
leafHostnames = []string{leaf.Subject.CommonName}
|
||||
}
|
||||
normalizedLeafHostnames, err := normalizeHostnames(leafHostnames)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !slices.Equal(normalizedLeafHostnames, s.domain) {
|
||||
return false
|
||||
}
|
||||
switch s.requestType {
|
||||
case option.CloudflareOriginCARequestTypeOriginRSA:
|
||||
return leaf.PublicKeyAlgorithm == x509.RSA
|
||||
case option.CloudflareOriginCARequestTypeOriginECC:
|
||||
return leaf.PublicKeyAlgorithm == x509.ECDSA
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) setCurrentCertificate(certificate *tls.Certificate, leaf *x509.Certificate) {
|
||||
s.access.Lock()
|
||||
s.currentCertificate = certificate
|
||||
s.currentLeaf = leaf
|
||||
s.access.Unlock()
|
||||
}
|
||||
|
||||
func normalizeHostnames(hostnames []string) ([]string, error) {
|
||||
normalizedHostnames := make([]string, 0, len(hostnames))
|
||||
seen := make(map[string]struct{}, len(hostnames))
|
||||
for _, hostname := range hostnames {
|
||||
normalizedHostname := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(hostname, ".")))
|
||||
if normalizedHostname == "" {
|
||||
return nil, E.New("hostname is empty")
|
||||
}
|
||||
if net.ParseIP(normalizedHostname) != nil {
|
||||
return nil, E.New("hostname cannot be an IP address: ", normalizedHostname)
|
||||
}
|
||||
if strings.Contains(normalizedHostname, "*") {
|
||||
if !strings.HasPrefix(normalizedHostname, "*.") || strings.Count(normalizedHostname, "*") != 1 {
|
||||
return nil, E.New("invalid wildcard hostname: ", normalizedHostname)
|
||||
}
|
||||
suffix := strings.TrimPrefix(normalizedHostname, "*.")
|
||||
if strings.Count(suffix, ".") == 0 {
|
||||
return nil, E.New("wildcard hostname must cover a multi-label domain: ", normalizedHostname)
|
||||
}
|
||||
normalizedHostname = "*." + suffix
|
||||
}
|
||||
if _, loaded := seen[normalizedHostname]; loaded {
|
||||
continue
|
||||
}
|
||||
seen[normalizedHostname] = struct{}{}
|
||||
normalizedHostnames = append(normalizedHostnames, normalizedHostname)
|
||||
}
|
||||
slices.Sort(normalizedHostnames)
|
||||
return normalizedHostnames, nil
|
||||
}
|
||||
|
||||
func parseKeyPair(certificatePEM []byte, privateKeyPEM []byte) (*tls.Certificate, *x509.Certificate, error) {
|
||||
keyPair, err := tls.X509KeyPair(certificatePEM, privateKeyPEM)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(keyPair.Certificate) == 0 {
|
||||
return nil, nil, E.New("certificate chain is empty")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(keyPair.Certificate[0])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
keyPair.Leaf = leaf
|
||||
return &keyPair, leaf, nil
|
||||
}
|
||||
|
||||
func storeCertificateResource(ctx context.Context, storage certmagic.Storage, issuerKey string, certificateResource certmagic.CertificateResource) error {
|
||||
metaBytes, err := json.MarshalIndent(certificateResource, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
namesKey := certificateResource.NamesKey()
|
||||
keyValueList := []struct {
|
||||
key string
|
||||
value []byte
|
||||
}{
|
||||
{
|
||||
key: certmagic.StorageKeys.SitePrivateKey(issuerKey, namesKey),
|
||||
value: certificateResource.PrivateKeyPEM,
|
||||
},
|
||||
{
|
||||
key: certmagic.StorageKeys.SiteCert(issuerKey, namesKey),
|
||||
value: certificateResource.CertificatePEM,
|
||||
},
|
||||
{
|
||||
key: certmagic.StorageKeys.SiteMeta(issuerKey, namesKey),
|
||||
value: metaBytes,
|
||||
},
|
||||
}
|
||||
for i, item := range keyValueList {
|
||||
err = storage.Store(ctx, item.key, item.value)
|
||||
if err != nil {
|
||||
for j := i - 1; j >= 0; j-- {
|
||||
storage.Delete(ctx, keyValueList[j].key)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadCertificateResource(ctx context.Context, storage certmagic.Storage, issuerKey string, namesKey string) (certmagic.CertificateResource, error) {
|
||||
privateKeyPEM, err := storage.Load(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, namesKey))
|
||||
if err != nil {
|
||||
return certmagic.CertificateResource{}, err
|
||||
}
|
||||
certificatePEM, err := storage.Load(ctx, certmagic.StorageKeys.SiteCert(issuerKey, namesKey))
|
||||
if err != nil {
|
||||
return certmagic.CertificateResource{}, err
|
||||
}
|
||||
metaBytes, err := storage.Load(ctx, certmagic.StorageKeys.SiteMeta(issuerKey, namesKey))
|
||||
if err != nil {
|
||||
return certmagic.CertificateResource{}, err
|
||||
}
|
||||
var certificateResource certmagic.CertificateResource
|
||||
err = json.Unmarshal(metaBytes, &certificateResource)
|
||||
if err != nil {
|
||||
return certmagic.CertificateResource{}, E.Cause(err, "decode Cloudflare Origin CA certificate metadata")
|
||||
}
|
||||
certificateResource.PrivateKeyPEM = privateKeyPEM
|
||||
certificateResource.CertificatePEM = certificatePEM
|
||||
return certificateResource, nil
|
||||
}
|
||||
|
||||
func buildOriginCAError(statusCode int, responseErrors []originCAResponseError, responseBody []byte) error {
|
||||
if len(responseErrors) > 0 {
|
||||
messageList := make([]string, 0, len(responseErrors))
|
||||
for _, responseError := range responseErrors {
|
||||
if responseError.Message == "" {
|
||||
continue
|
||||
}
|
||||
if responseError.Code != 0 {
|
||||
messageList = append(messageList, responseError.Message+" (code "+strconv.Itoa(responseError.Code)+")")
|
||||
} else {
|
||||
messageList = append(messageList, responseError.Message)
|
||||
}
|
||||
}
|
||||
if len(messageList) > 0 {
|
||||
return E.New("Cloudflare Origin CA request failed: HTTP ", statusCode, " ", strings.Join(messageList, ", "))
|
||||
}
|
||||
}
|
||||
responseText := strings.TrimSpace(string(responseBody))
|
||||
if responseText == "" {
|
||||
return E.New("Cloudflare Origin CA request failed: HTTP ", statusCode)
|
||||
}
|
||||
return E.New("Cloudflare Origin CA request failed: HTTP ", statusCode, " ", responseText)
|
||||
}
|
||||
|
||||
type originCARequest struct {
|
||||
CSR string `json:"csr"`
|
||||
Hostnames []string `json:"hostnames"`
|
||||
RequestType string `json:"request_type"`
|
||||
RequestedValidity uint16 `json:"requested_validity"`
|
||||
}
|
||||
|
||||
type originCAResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Errors []originCAResponseError `json:"errors"`
|
||||
Result originCAResponseResult `json:"result"`
|
||||
}
|
||||
|
||||
type originCAResponseError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type originCAResponseResult struct {
|
||||
Certificate string `json:"certificate"`
|
||||
}
|
||||
|
||||
type originCAIssuerData struct {
|
||||
RequestType option.CloudflareOriginCARequestType `json:"request_type,omitempty"`
|
||||
RequestedValidity option.CloudflareOriginCARequestValidity `json:"requested_validity,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package powerreport
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
access sync.Mutex
|
||||
recorder atomic.Pointer[Recorder]
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{}
|
||||
}
|
||||
|
||||
func (m *Manager) Start(options Options) error {
|
||||
m.access.Lock()
|
||||
defer m.access.Unlock()
|
||||
if m.recorder.Load() != nil {
|
||||
return nil
|
||||
}
|
||||
recorder := NewRecorder(options)
|
||||
err := recorder.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.recorder.Store(recorder)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Close() error {
|
||||
m.access.Lock()
|
||||
defer m.access.Unlock()
|
||||
recorder := m.recorder.Swap(nil)
|
||||
if recorder == nil {
|
||||
return nil
|
||||
}
|
||||
return recorder.Close()
|
||||
}
|
||||
|
||||
func (m *Manager) Recorder() *Recorder {
|
||||
return m.recorder.Load()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package powerreport
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func PromoteDraft(basePath string) {
|
||||
promoteDirectory(filepath.Join(basePath, DraftDirectoryName), filepath.Join(basePath, ReportsDirectoryName))
|
||||
}
|
||||
|
||||
func finalizeDraft(draftPath string) {
|
||||
promoteDirectory(draftPath, filepath.Join(filepath.Dir(draftPath), ReportsDirectoryName))
|
||||
}
|
||||
|
||||
func promoteDirectory(draftPath string, reportsPath string) {
|
||||
info, err := os.Stat(draftPath)
|
||||
if err != nil || !info.IsDir() {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(draftPath)
|
||||
if err != nil || len(entries) == 0 {
|
||||
os.RemoveAll(draftPath)
|
||||
return
|
||||
}
|
||||
err = os.MkdirAll(reportsPath, 0o777)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
destName := info.ModTime().UTC().Format("2006-01-02T15-04-05")
|
||||
destPath := filepath.Join(reportsPath, destName)
|
||||
for i := 1; ; i++ {
|
||||
_, err = os.Stat(destPath)
|
||||
if os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
if i > 1000 {
|
||||
os.RemoveAll(draftPath)
|
||||
return
|
||||
}
|
||||
destPath = filepath.Join(reportsPath, destName+"-"+strconv.Itoa(i))
|
||||
}
|
||||
err = os.Rename(draftPath, destPath)
|
||||
if err != nil {
|
||||
os.RemoveAll(draftPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package powerreport
|
||||
|
||||
type Direction uint8
|
||||
|
||||
const (
|
||||
DirectionOutbound Direction = iota
|
||||
DirectionInbound
|
||||
)
|
||||
|
||||
func (d Direction) String() string {
|
||||
if d == DirectionInbound {
|
||||
return "in"
|
||||
}
|
||||
return "out"
|
||||
}
|
||||
|
||||
type Attribution struct {
|
||||
Inbound string `json:"inbound,omitempty"`
|
||||
InboundType string `json:"inboundType,omitempty"`
|
||||
Network string `json:"network,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Destination string `json:"destination,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
Process *ProcessAttribution `json:"process,omitempty"`
|
||||
Rule string `json:"rule,omitempty"`
|
||||
Chain []string `json:"chain,omitempty"`
|
||||
Outbound string `json:"outbound,omitempty"`
|
||||
OutboundType string `json:"outboundType,omitempty"`
|
||||
Server string `json:"server,omitempty"`
|
||||
DNS string `json:"dns,omitempty"`
|
||||
DNSType string `json:"dnsType,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
}
|
||||
|
||||
type ProcessAttribution struct {
|
||||
ProcessID uint32 `json:"processId,omitempty"`
|
||||
UserID int32 `json:"userId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ProcessPath string `json:"processPath,omitempty"`
|
||||
PackageNames []string `json:"packageNames,omitempty"`
|
||||
}
|
||||
|
||||
type timelineRow struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
CPUUserMS int64 `json:"cpuUserMS,omitempty"`
|
||||
CPUSystemMS int64 `json:"cpuSystemMS,omitempty"`
|
||||
CPUPerformanceMS int64 `json:"cpuPerformanceMS,omitempty"`
|
||||
CPUGCMS int64 `json:"cpuGCMS,omitempty"`
|
||||
QoSMS *qosBreakdown `json:"qosMS,omitempty"`
|
||||
PackageIdleWakeups uint64 `json:"packageIdleWakeups,omitempty"`
|
||||
InterruptWakeups uint64 `json:"interruptWakeups,omitempty"`
|
||||
EnergyNanojoules uint64 `json:"energyNJ,omitempty"`
|
||||
PerformanceEnergyNanojoules uint64 `json:"performanceEnergyNJ,omitempty"`
|
||||
DiskBytesWritten uint64 `json:"diskWriteBytes,omitempty"`
|
||||
SleptMS int64 `json:"sleptMS,omitempty"`
|
||||
Goroutines uint64 `json:"goroutines,omitempty"`
|
||||
GCCycles uint64 `json:"gcCycles,omitempty"`
|
||||
GoMemoryBytes uint64 `json:"goMemoryBytes,omitempty"`
|
||||
GoHeapLiveBytes uint64 `json:"goHeapLiveBytes,omitempty"`
|
||||
MemoryBytes uint64 `json:"memoryBytes,omitempty"`
|
||||
DNSQueries uint64 `json:"dnsQueries,omitempty"`
|
||||
ConnectionsOpened uint64 `json:"connectionsOpened,omitempty"`
|
||||
InterfacePackets map[string]uint64 `json:"interfacePackets,omitempty"`
|
||||
NetworkType string `json:"network,omitempty"`
|
||||
}
|
||||
|
||||
type qosBreakdown struct {
|
||||
DefaultMS int64 `json:"default,omitempty"`
|
||||
MaintenanceMS int64 `json:"maintenance,omitempty"`
|
||||
BackgroundMS int64 `json:"background,omitempty"`
|
||||
UtilityMS int64 `json:"utility,omitempty"`
|
||||
LegacyMS int64 `json:"legacy,omitempty"`
|
||||
UserInitiatedMS int64 `json:"userInitiated,omitempty"`
|
||||
UserInteractiveMS int64 `json:"userInteractive,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
eventTypeBreak = "break"
|
||||
eventTypeNetwork = "network"
|
||||
)
|
||||
|
||||
type eventRecord struct {
|
||||
Type string `json:"t"`
|
||||
At string `json:"at"`
|
||||
IdleMS int64 `json:"idleMS,omitempty"`
|
||||
Direction string `json:"direction,omitempty"`
|
||||
Size int `json:"size,omitempty"`
|
||||
NetworkType string `json:"network,omitempty"`
|
||||
By *Attribution `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
type systemUsage struct {
|
||||
valid bool
|
||||
userTime int64
|
||||
systemTime int64
|
||||
performanceUserTime int64
|
||||
performanceSystemTime int64
|
||||
qosDefaultTime int64
|
||||
qosMaintenanceTime int64
|
||||
qosBackgroundTime int64
|
||||
qosUtilityTime int64
|
||||
qosLegacyTime int64
|
||||
qosUserInitiatedTime int64
|
||||
qosUserInteractiveTime int64
|
||||
packageIdleWakeups uint64
|
||||
interruptWakeups uint64
|
||||
diskBytesWritten uint64
|
||||
energyNanojoules uint64
|
||||
performanceEnergyNanojoules uint64
|
||||
}
|
||||
|
||||
type interfaceCounters struct {
|
||||
inPackets uint32
|
||||
outPackets uint32
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
package powerreport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/metrics"
|
||||
"runtime/pprof"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
"github.com/sagernet/sing/common/memory"
|
||||
)
|
||||
|
||||
const (
|
||||
DraftDirectoryName = "power_draft"
|
||||
ReportsDirectoryName = "power_reports"
|
||||
|
||||
timelineFileName = "timeline.jsonl"
|
||||
eventsFileName = "events.jsonl"
|
||||
metadataFileName = "metadata.json"
|
||||
logFileName = "go.log"
|
||||
goroutineProfileFileName = "goroutine.pb.gz"
|
||||
|
||||
defaultGateInterval = 5 * time.Second
|
||||
defaultSampleInterval = time.Minute
|
||||
defaultFlushInterval = 15 * time.Minute
|
||||
defaultFallbackInterval = 10 * time.Minute
|
||||
|
||||
activityRefreshNano = int64(time.Second)
|
||||
|
||||
rowCapacity = 4096
|
||||
eventCapacity = 8192
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
BasePath string
|
||||
Logger logger.Logger
|
||||
Metadata any
|
||||
OwnerCallback func(path string)
|
||||
LogCallback func() []byte
|
||||
ProfileCallback func(path string)
|
||||
GateInterval time.Duration
|
||||
SampleInterval time.Duration
|
||||
FlushInterval time.Duration
|
||||
FallbackInterval time.Duration
|
||||
}
|
||||
|
||||
type Recorder struct {
|
||||
draftPath string
|
||||
logger logger.Logger
|
||||
metadata any
|
||||
ownerCallback func(path string)
|
||||
logCallback func() []byte
|
||||
profileCallback func(path string)
|
||||
gateNano int64
|
||||
sampleNano int64
|
||||
flushInterval time.Duration
|
||||
fallbackInterval time.Duration
|
||||
baseTime time.Time
|
||||
|
||||
_ [64]byte
|
||||
lastActivity atomic.Int64
|
||||
_ [64]byte
|
||||
|
||||
lastSampleAt atomic.Int64
|
||||
pendingBreak atomic.Pointer[breakRecord]
|
||||
notify chan struct{}
|
||||
|
||||
dnsQueries atomic.Uint64
|
||||
connectionsOpened atomic.Uint64
|
||||
|
||||
access sync.Mutex
|
||||
networkType string
|
||||
rows []timelineRow
|
||||
events []eventRecord
|
||||
previous previousSample
|
||||
lastFlushAt time.Time
|
||||
started bool
|
||||
closed bool
|
||||
metricsSamples []metrics.Sample
|
||||
|
||||
done chan struct{}
|
||||
workerDone chan struct{}
|
||||
}
|
||||
|
||||
type breakRecord struct {
|
||||
at time.Time
|
||||
idleMS int64
|
||||
direction Direction
|
||||
size int
|
||||
by *Attribution
|
||||
}
|
||||
|
||||
type previousSample struct {
|
||||
at time.Time
|
||||
usage systemUsage
|
||||
gcSeconds float64
|
||||
gcCycles uint64
|
||||
absoluteTime int64
|
||||
continuousTime int64
|
||||
interfaces map[string]interfaceCounters
|
||||
dnsQueries uint64
|
||||
connectionsOpened uint64
|
||||
}
|
||||
|
||||
func NewRecorder(options Options) *Recorder {
|
||||
recorderLogger := options.Logger
|
||||
if recorderLogger == nil {
|
||||
recorderLogger = logger.NOP()
|
||||
}
|
||||
gateInterval := options.GateInterval
|
||||
if gateInterval == 0 {
|
||||
gateInterval = defaultGateInterval
|
||||
}
|
||||
sampleInterval := options.SampleInterval
|
||||
if sampleInterval == 0 {
|
||||
sampleInterval = defaultSampleInterval
|
||||
}
|
||||
flushInterval := options.FlushInterval
|
||||
if flushInterval == 0 {
|
||||
flushInterval = defaultFlushInterval
|
||||
}
|
||||
fallbackInterval := options.FallbackInterval
|
||||
if fallbackInterval == 0 {
|
||||
fallbackInterval = defaultFallbackInterval
|
||||
}
|
||||
return &Recorder{
|
||||
draftPath: filepath.Join(options.BasePath, DraftDirectoryName),
|
||||
logger: recorderLogger,
|
||||
metadata: options.Metadata,
|
||||
ownerCallback: options.OwnerCallback,
|
||||
logCallback: options.LogCallback,
|
||||
profileCallback: options.ProfileCallback,
|
||||
gateNano: int64(gateInterval),
|
||||
sampleNano: int64(sampleInterval),
|
||||
flushInterval: flushInterval,
|
||||
fallbackInterval: fallbackInterval,
|
||||
baseTime: time.Now(),
|
||||
notify: make(chan struct{}, 1),
|
||||
metricsSamples: []metrics.Sample{
|
||||
{Name: "/cpu/classes/gc/total:cpu-seconds"},
|
||||
{Name: "/sched/goroutines:goroutines"},
|
||||
{Name: "/gc/cycles/total:gc-cycles"},
|
||||
{Name: "/memory/classes/total:bytes"},
|
||||
{Name: "/gc/heap/live:bytes"},
|
||||
},
|
||||
done: make(chan struct{}),
|
||||
workerDone: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Recorder) Start() error {
|
||||
r.access.Lock()
|
||||
defer r.access.Unlock()
|
||||
if r.started {
|
||||
return nil
|
||||
}
|
||||
PromoteDraft(filepath.Dir(r.draftPath))
|
||||
err := os.MkdirAll(r.draftPath, 0o777)
|
||||
if err != nil {
|
||||
return E.Cause(err, "create power report draft directory")
|
||||
}
|
||||
r.chown(r.draftPath)
|
||||
if r.metadata != nil {
|
||||
metadataContent, marshalErr := json.Marshal(r.metadata)
|
||||
if marshalErr == nil {
|
||||
metadataPath := filepath.Join(r.draftPath, metadataFileName)
|
||||
os.WriteFile(metadataPath, metadataContent, 0o666)
|
||||
r.chown(metadataPath)
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
r.resetPreviousLocked(now)
|
||||
r.lastSampleAt.Store(int64(now.Sub(r.baseTime)))
|
||||
r.lastFlushAt = now
|
||||
r.started = true
|
||||
go r.worker()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Recorder) Close() error {
|
||||
r.access.Lock()
|
||||
if !r.started || r.closed {
|
||||
r.access.Unlock()
|
||||
return nil
|
||||
}
|
||||
r.closed = true
|
||||
r.access.Unlock()
|
||||
close(r.done)
|
||||
<-r.workerDone
|
||||
now := time.Now()
|
||||
r.access.Lock()
|
||||
r.consumeBreakLocked()
|
||||
r.sampleLocked(now)
|
||||
r.flushLocked(now)
|
||||
r.access.Unlock()
|
||||
r.writeProfiles()
|
||||
r.writeLog()
|
||||
finalizeDraft(r.draftPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Recorder) writeLog() {
|
||||
if r.logCallback == nil {
|
||||
return
|
||||
}
|
||||
content := r.logCallback()
|
||||
if len(content) == 0 {
|
||||
return
|
||||
}
|
||||
logPath := filepath.Join(r.draftPath, logFileName)
|
||||
err := os.WriteFile(logPath, content, 0o666)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r.chown(logPath)
|
||||
}
|
||||
|
||||
// Touch reports one I/O activity: one read or write call, or one batched receive or send
|
||||
// syscall on paths that batch packets. size is the size of the first packet of the activity
|
||||
// and characterizes what ended an idle period; it is not accumulated. Volume totals come from
|
||||
// the sampled interface counters instead.
|
||||
func (r *Recorder) Touch(direction Direction, size int, by *Attribution) {
|
||||
nowNano := int64(time.Since(r.baseTime))
|
||||
lastNano := r.lastActivity.Load()
|
||||
if nowNano-lastNano < activityRefreshNano {
|
||||
return
|
||||
}
|
||||
previousNano := r.lastActivity.Swap(nowNano)
|
||||
if previousNano != 0 && nowNano-previousNano >= r.gateNano {
|
||||
r.pendingBreak.Store(&breakRecord{
|
||||
at: time.Now(),
|
||||
idleMS: (nowNano - previousNano) / int64(time.Millisecond),
|
||||
direction: direction,
|
||||
size: size,
|
||||
by: by,
|
||||
})
|
||||
r.notifyWorker()
|
||||
} else if nowNano-r.lastSampleAt.Load() >= r.sampleNano {
|
||||
r.notifyWorker()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Recorder) CountDNSQuery() {
|
||||
r.dnsQueries.Add(1)
|
||||
}
|
||||
|
||||
func (r *Recorder) CountConnectionOpened() {
|
||||
r.connectionsOpened.Add(1)
|
||||
}
|
||||
|
||||
func (r *Recorder) RecordPlatformEvent(eventType string) {
|
||||
now := time.Now()
|
||||
r.access.Lock()
|
||||
if !r.started || r.closed {
|
||||
r.access.Unlock()
|
||||
return
|
||||
}
|
||||
r.events = append(r.events, eventRecord{
|
||||
Type: eventType,
|
||||
At: now.UTC().Format(time.RFC3339),
|
||||
})
|
||||
r.access.Unlock()
|
||||
r.notifyWorker()
|
||||
}
|
||||
|
||||
func (r *Recorder) UpdateNetworkType(networkType string) {
|
||||
now := time.Now()
|
||||
r.access.Lock()
|
||||
if r.closed || r.networkType == networkType {
|
||||
r.access.Unlock()
|
||||
return
|
||||
}
|
||||
r.networkType = networkType
|
||||
r.events = append(r.events, eventRecord{
|
||||
Type: eventTypeNetwork,
|
||||
At: now.UTC().Format(time.RFC3339),
|
||||
NetworkType: networkType,
|
||||
})
|
||||
r.access.Unlock()
|
||||
r.notifyWorker()
|
||||
}
|
||||
|
||||
func (r *Recorder) notifyWorker() {
|
||||
select {
|
||||
case r.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Recorder) worker() {
|
||||
defer close(r.workerDone)
|
||||
timer := time.NewTimer(r.fallbackInterval)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.done:
|
||||
return
|
||||
case <-r.notify:
|
||||
case <-timer.C:
|
||||
timer.Reset(r.fallbackInterval)
|
||||
}
|
||||
r.process()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Recorder) process() {
|
||||
now := time.Now()
|
||||
r.access.Lock()
|
||||
defer r.access.Unlock()
|
||||
if !r.started || r.closed {
|
||||
return
|
||||
}
|
||||
r.consumeBreakLocked()
|
||||
nowNano := int64(now.Sub(r.baseTime))
|
||||
if nowNano-r.lastSampleAt.Load() >= r.sampleNano {
|
||||
r.lastSampleAt.Store(nowNano)
|
||||
r.sampleLocked(now)
|
||||
}
|
||||
if now.Sub(r.lastFlushAt) >= r.flushInterval || len(r.rows) >= rowCapacity || len(r.events) >= eventCapacity {
|
||||
r.flushLocked(now)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Recorder) consumeBreakLocked() {
|
||||
record := r.pendingBreak.Swap(nil)
|
||||
if record == nil {
|
||||
return
|
||||
}
|
||||
r.events = append(r.events, eventRecord{
|
||||
Type: eventTypeBreak,
|
||||
At: record.at.UTC().Format(time.RFC3339),
|
||||
IdleMS: record.idleMS,
|
||||
Direction: record.direction.String(),
|
||||
Size: record.size,
|
||||
NetworkType: r.networkType,
|
||||
By: record.by,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Recorder) resetPreviousLocked(now time.Time) {
|
||||
metrics.Read(r.metricsSamples)
|
||||
r.previous = previousSample{
|
||||
at: now,
|
||||
usage: readSystemUsage(),
|
||||
gcSeconds: r.metricsSamples[0].Value.Float64(),
|
||||
gcCycles: r.metricsSamples[2].Value.Uint64(),
|
||||
interfaces: readInterfaceCounters(),
|
||||
dnsQueries: r.dnsQueries.Load(),
|
||||
connectionsOpened: r.connectionsOpened.Load(),
|
||||
}
|
||||
r.previous.absoluteTime, r.previous.continuousTime = readClocks()
|
||||
}
|
||||
|
||||
func (r *Recorder) sampleLocked(now time.Time) {
|
||||
previous := r.previous
|
||||
r.resetPreviousLocked(now)
|
||||
current := &r.previous
|
||||
row := timelineRow{
|
||||
From: previous.at.UTC().Format(time.RFC3339),
|
||||
To: now.UTC().Format(time.RFC3339),
|
||||
CPUGCMS: int64((current.gcSeconds - previous.gcSeconds) * 1000),
|
||||
Goroutines: r.metricsSamples[1].Value.Uint64(),
|
||||
GCCycles: current.gcCycles - previous.gcCycles,
|
||||
GoMemoryBytes: r.metricsSamples[3].Value.Uint64(),
|
||||
GoHeapLiveBytes: r.metricsSamples[4].Value.Uint64(),
|
||||
DNSQueries: current.dnsQueries - previous.dnsQueries,
|
||||
ConnectionsOpened: current.connectionsOpened - previous.connectionsOpened,
|
||||
NetworkType: r.networkType,
|
||||
}
|
||||
if memory.TotalAvailable() {
|
||||
row.MemoryBytes = memory.Total()
|
||||
}
|
||||
if current.usage.valid && previous.usage.valid {
|
||||
row.CPUUserMS = (current.usage.userTime - previous.usage.userTime) / int64(time.Millisecond)
|
||||
row.CPUSystemMS = (current.usage.systemTime - previous.usage.systemTime) / int64(time.Millisecond)
|
||||
row.CPUPerformanceMS = (current.usage.performanceUserTime - previous.usage.performanceUserTime +
|
||||
current.usage.performanceSystemTime - previous.usage.performanceSystemTime) / int64(time.Millisecond)
|
||||
row.PackageIdleWakeups = current.usage.packageIdleWakeups - previous.usage.packageIdleWakeups
|
||||
row.InterruptWakeups = current.usage.interruptWakeups - previous.usage.interruptWakeups
|
||||
row.EnergyNanojoules = current.usage.energyNanojoules - previous.usage.energyNanojoules
|
||||
row.PerformanceEnergyNanojoules = current.usage.performanceEnergyNanojoules - previous.usage.performanceEnergyNanojoules
|
||||
row.DiskBytesWritten = current.usage.diskBytesWritten - previous.usage.diskBytesWritten
|
||||
qos := qosBreakdown{
|
||||
DefaultMS: (current.usage.qosDefaultTime - previous.usage.qosDefaultTime) / int64(time.Millisecond),
|
||||
MaintenanceMS: (current.usage.qosMaintenanceTime - previous.usage.qosMaintenanceTime) / int64(time.Millisecond),
|
||||
BackgroundMS: (current.usage.qosBackgroundTime - previous.usage.qosBackgroundTime) / int64(time.Millisecond),
|
||||
UtilityMS: (current.usage.qosUtilityTime - previous.usage.qosUtilityTime) / int64(time.Millisecond),
|
||||
LegacyMS: (current.usage.qosLegacyTime - previous.usage.qosLegacyTime) / int64(time.Millisecond),
|
||||
UserInitiatedMS: (current.usage.qosUserInitiatedTime - previous.usage.qosUserInitiatedTime) / int64(time.Millisecond),
|
||||
UserInteractiveMS: (current.usage.qosUserInteractiveTime - previous.usage.qosUserInteractiveTime) / int64(time.Millisecond),
|
||||
}
|
||||
if qos != (qosBreakdown{}) {
|
||||
row.QoSMS = &qos
|
||||
}
|
||||
}
|
||||
if current.absoluteTime != 0 && previous.absoluteTime != 0 && current.absoluteTime >= previous.absoluteTime {
|
||||
sleptNano := (current.continuousTime - previous.continuousTime) - (current.absoluteTime - previous.absoluteTime)
|
||||
wallNano := now.Sub(previous.at).Nanoseconds()
|
||||
if sleptNano > wallNano {
|
||||
sleptNano = wallNano
|
||||
}
|
||||
if sleptNano > 0 {
|
||||
row.SleptMS = sleptNano / int64(time.Millisecond)
|
||||
}
|
||||
}
|
||||
if len(current.interfaces) > 0 && len(previous.interfaces) > 0 {
|
||||
interfacePackets := make(map[string]uint64)
|
||||
for name, counters := range current.interfaces {
|
||||
previousCounters, found := previous.interfaces[name]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
delta := uint64(counters.inPackets-previousCounters.inPackets) + uint64(counters.outPackets-previousCounters.outPackets)
|
||||
if delta > 0 {
|
||||
interfacePackets[name] = delta
|
||||
}
|
||||
}
|
||||
if len(interfacePackets) > 0 {
|
||||
row.InterfacePackets = interfacePackets
|
||||
}
|
||||
}
|
||||
r.rows = append(r.rows, row)
|
||||
}
|
||||
|
||||
func (r *Recorder) chown(path string) {
|
||||
if r.ownerCallback != nil {
|
||||
r.ownerCallback(path)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Recorder) flushLocked(now time.Time) {
|
||||
err := appendRecords(r, filepath.Join(r.draftPath, timelineFileName), r.rows)
|
||||
if err == nil {
|
||||
r.rows = r.rows[:0]
|
||||
} else {
|
||||
r.logger.Error(E.Cause(err, "power report: write timeline"))
|
||||
if len(r.rows) >= rowCapacity {
|
||||
r.rows = r.rows[len(r.rows)-rowCapacity/2:]
|
||||
}
|
||||
}
|
||||
err = appendRecords(r, filepath.Join(r.draftPath, eventsFileName), r.events)
|
||||
if err == nil {
|
||||
r.events = r.events[:0]
|
||||
} else {
|
||||
r.logger.Error(E.Cause(err, "power report: write events"))
|
||||
if len(r.events) >= eventCapacity {
|
||||
r.events = r.events[len(r.events)-eventCapacity/2:]
|
||||
}
|
||||
}
|
||||
r.lastFlushAt = now
|
||||
}
|
||||
|
||||
func appendRecords[T any](r *Recorder, path string, records []T) error {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
r.chown(path)
|
||||
encoder := json.NewEncoder(file)
|
||||
for _, record := range records {
|
||||
err = encoder.Encode(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Recorder) writeProfiles() {
|
||||
if r.profileCallback != nil {
|
||||
r.profileCallback(r.draftPath)
|
||||
return
|
||||
}
|
||||
profilePath := filepath.Join(r.draftPath, goroutineProfileFileName)
|
||||
file, err := os.OpenFile(profilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o666)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
r.chown(profilePath)
|
||||
pprof.Lookup("goroutine").WriteTo(file, 0)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package powerreport
|
||||
|
||||
//go:generate go run golang.org/x/sys/windows/mkwinsyscall -output zsyscall_windows.go syscall_windows.go
|
||||
|
||||
type processIOCounters struct {
|
||||
readOperationCount uint64
|
||||
writeOperationCount uint64
|
||||
otherOperationCount uint64
|
||||
readTransferCount uint64
|
||||
writeTransferCount uint64
|
||||
otherTransferCount uint64
|
||||
}
|
||||
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getprocessiocounters
|
||||
//sys getProcessIoCounters(process windows.Handle, ioCounters *processIOCounters) (err error) = kernel32.GetProcessIoCounters
|
||||
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/realtimeapiset/nf-realtimeapiset-queryunbiasedinterrupttime
|
||||
//sys queryUnbiasedInterruptTime(unbiasedTime *uint64) (err error) = kernel32.QueryUnbiasedInterruptTime
|
||||
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/realtimeapiset/nf-realtimeapiset-queryinterrupttime
|
||||
//sys queryInterruptTime(interruptTime *uint64) = api-ms-win-core-realtime-l1-1-1.QueryInterruptTime
|
||||
@@ -0,0 +1,123 @@
|
||||
package powerreport
|
||||
|
||||
/*
|
||||
#include <ifaddrs.h>
|
||||
#include <mach/mach_time.h>
|
||||
#include <net/if.h>
|
||||
#include <net/if_var.h>
|
||||
#include <sys/resource.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// The iOS SDK does not ship libproc.h; the symbol is exported by libSystem
|
||||
// on all darwin platforms.
|
||||
int proc_pid_rusage(int pid, int flavor, rusage_info_t *buffer);
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var (
|
||||
timebaseOnce sync.Once
|
||||
timebaseNumer int64
|
||||
timebaseDenom int64
|
||||
)
|
||||
|
||||
func machTimebase() (int64, int64) {
|
||||
timebaseOnce.Do(func() {
|
||||
var timebase C.struct_mach_timebase_info
|
||||
C.mach_timebase_info(&timebase)
|
||||
timebaseNumer = int64(timebase.numer)
|
||||
timebaseDenom = int64(timebase.denom)
|
||||
})
|
||||
return timebaseNumer, timebaseDenom
|
||||
}
|
||||
|
||||
func machToNano(value uint64) int64 {
|
||||
numer, denom := machTimebase()
|
||||
if denom == 0 {
|
||||
return int64(value)
|
||||
}
|
||||
return int64(value) * numer / denom
|
||||
}
|
||||
|
||||
// The time fields of rusage_info are in mach_absolute_time units on arm64,
|
||||
// not nanoseconds; the header does not document this.
|
||||
func readSystemUsage() systemUsage {
|
||||
var info C.struct_rusage_info_v6
|
||||
result := C.proc_pid_rusage(C.int(os.Getpid()), C.RUSAGE_INFO_V6, (*C.rusage_info_t)(unsafe.Pointer(&info)))
|
||||
if result == 0 {
|
||||
return systemUsage{
|
||||
valid: true,
|
||||
userTime: machToNano(uint64(info.ri_user_time)),
|
||||
systemTime: machToNano(uint64(info.ri_system_time)),
|
||||
performanceUserTime: machToNano(uint64(info.ri_user_ptime)),
|
||||
performanceSystemTime: machToNano(uint64(info.ri_system_ptime)),
|
||||
qosDefaultTime: machToNano(uint64(info.ri_cpu_time_qos_default)),
|
||||
qosMaintenanceTime: machToNano(uint64(info.ri_cpu_time_qos_maintenance)),
|
||||
qosBackgroundTime: machToNano(uint64(info.ri_cpu_time_qos_background)),
|
||||
qosUtilityTime: machToNano(uint64(info.ri_cpu_time_qos_utility)),
|
||||
qosLegacyTime: machToNano(uint64(info.ri_cpu_time_qos_legacy)),
|
||||
qosUserInitiatedTime: machToNano(uint64(info.ri_cpu_time_qos_user_initiated)),
|
||||
qosUserInteractiveTime: machToNano(uint64(info.ri_cpu_time_qos_user_interactive)),
|
||||
packageIdleWakeups: uint64(info.ri_pkg_idle_wkups),
|
||||
interruptWakeups: uint64(info.ri_interrupt_wkups),
|
||||
diskBytesWritten: uint64(info.ri_diskio_byteswritten),
|
||||
energyNanojoules: uint64(info.ri_energy_nj),
|
||||
performanceEnergyNanojoules: uint64(info.ri_penergy_nj),
|
||||
}
|
||||
}
|
||||
var infoV4 C.struct_rusage_info_v4
|
||||
result = C.proc_pid_rusage(C.int(os.Getpid()), C.RUSAGE_INFO_V4, (*C.rusage_info_t)(unsafe.Pointer(&infoV4)))
|
||||
if result != 0 {
|
||||
return systemUsage{}
|
||||
}
|
||||
return systemUsage{
|
||||
valid: true,
|
||||
userTime: machToNano(uint64(infoV4.ri_user_time)),
|
||||
systemTime: machToNano(uint64(infoV4.ri_system_time)),
|
||||
qosDefaultTime: machToNano(uint64(infoV4.ri_cpu_time_qos_default)),
|
||||
qosMaintenanceTime: machToNano(uint64(infoV4.ri_cpu_time_qos_maintenance)),
|
||||
qosBackgroundTime: machToNano(uint64(infoV4.ri_cpu_time_qos_background)),
|
||||
qosUtilityTime: machToNano(uint64(infoV4.ri_cpu_time_qos_utility)),
|
||||
qosLegacyTime: machToNano(uint64(infoV4.ri_cpu_time_qos_legacy)),
|
||||
qosUserInitiatedTime: machToNano(uint64(infoV4.ri_cpu_time_qos_user_initiated)),
|
||||
qosUserInteractiveTime: machToNano(uint64(infoV4.ri_cpu_time_qos_user_interactive)),
|
||||
packageIdleWakeups: uint64(infoV4.ri_pkg_idle_wkups),
|
||||
interruptWakeups: uint64(infoV4.ri_interrupt_wkups),
|
||||
diskBytesWritten: uint64(infoV4.ri_diskio_byteswritten),
|
||||
}
|
||||
}
|
||||
|
||||
func readClocks() (absoluteTime int64, continuousTime int64) {
|
||||
return machToNano(uint64(C.mach_absolute_time())), machToNano(uint64(C.mach_continuous_time()))
|
||||
}
|
||||
|
||||
func readInterfaceCounters() map[string]interfaceCounters {
|
||||
var list *C.struct_ifaddrs
|
||||
if C.getifaddrs(&list) != 0 {
|
||||
return nil
|
||||
}
|
||||
defer C.freeifaddrs(list)
|
||||
result := make(map[string]interfaceCounters)
|
||||
for entry := list; entry != nil; entry = entry.ifa_next {
|
||||
if entry.ifa_addr == nil || entry.ifa_addr.sa_family != C.AF_LINK || entry.ifa_data == nil {
|
||||
continue
|
||||
}
|
||||
name := C.GoString(entry.ifa_name)
|
||||
if !strings.HasPrefix(name, "en") && !strings.HasPrefix(name, "pdp_ip") {
|
||||
continue
|
||||
}
|
||||
data := (*C.struct_if_data)(entry.ifa_data)
|
||||
result[name] = interfaceCounters{
|
||||
inPackets: uint32(data.ifi_ipackets),
|
||||
outPackets: uint32(data.ifi_opackets),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build darwin && !cgo
|
||||
|
||||
package powerreport
|
||||
|
||||
func readSystemUsage() systemUsage {
|
||||
panic("power report requires CGO on darwin, rebuild with CGO_ENABLED=1")
|
||||
}
|
||||
|
||||
func readClocks() (absoluteTime int64, continuousTime int64) {
|
||||
panic("power report requires CGO on darwin, rebuild with CGO_ENABLED=1")
|
||||
}
|
||||
|
||||
func readInterfaceCounters() map[string]interfaceCounters {
|
||||
panic("power report requires CGO on darwin, rebuild with CGO_ENABLED=1")
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package powerreport
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func readSystemUsage() systemUsage {
|
||||
var rusage unix.Rusage
|
||||
err := unix.Getrusage(unix.RUSAGE_SELF, &rusage)
|
||||
if err != nil {
|
||||
return systemUsage{}
|
||||
}
|
||||
return systemUsage{
|
||||
valid: true,
|
||||
userTime: rusage.Utime.Nano(),
|
||||
systemTime: rusage.Stime.Nano(),
|
||||
diskBytesWritten: readWriteBytes(),
|
||||
}
|
||||
}
|
||||
|
||||
func readWriteBytes() uint64 {
|
||||
content, err := os.ReadFile("/proc/self/io")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for line := range strings.SplitSeq(string(content), "\n") {
|
||||
value, found := strings.CutPrefix(line, "write_bytes: ")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
parsed, parseErr := strconv.ParseUint(value, 10, 64)
|
||||
if parseErr != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func readClocks() (absoluteTime int64, continuousTime int64) {
|
||||
var monotonicTime unix.Timespec
|
||||
err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &monotonicTime)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
var bootTime unix.Timespec
|
||||
err = unix.ClockGettime(unix.CLOCK_BOOTTIME, &bootTime)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
return monotonicTime.Nano(), bootTime.Nano()
|
||||
}
|
||||
|
||||
func readInterfaceCounters() map[string]interfaceCounters {
|
||||
content, err := os.ReadFile("/proc/net/dev")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(string(content), "\n")
|
||||
if len(lines) <= 2 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]interfaceCounters)
|
||||
for _, line := range lines[2:] {
|
||||
name, counters, found := strings.Cut(line, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "lo" || strings.HasPrefix(name, "tun") || strings.HasPrefix(name, "utun") || strings.HasPrefix(name, "dummy") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(counters)
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
inPackets, inErr := strconv.ParseUint(fields[1], 10, 64)
|
||||
outPackets, outErr := strconv.ParseUint(fields[9], 10, 64)
|
||||
if inErr != nil || outErr != nil {
|
||||
continue
|
||||
}
|
||||
result[name] = interfaceCounters{
|
||||
inPackets: uint32(inPackets),
|
||||
outPackets: uint32(outPackets),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !darwin && !linux && !windows
|
||||
|
||||
package powerreport
|
||||
|
||||
func readSystemUsage() systemUsage {
|
||||
return systemUsage{}
|
||||
}
|
||||
|
||||
func readClocks() (absoluteTime int64, continuousTime int64) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func readInterfaceCounters() map[string]interfaceCounters {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package powerreport
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func filetimeDuration(value windows.Filetime) int64 {
|
||||
return (int64(value.HighDateTime)<<32 | int64(value.LowDateTime)) * 100
|
||||
}
|
||||
|
||||
func readSystemUsage() systemUsage {
|
||||
var creationTime, exitTime, kernelTime, userTime windows.Filetime
|
||||
err := windows.GetProcessTimes(windows.CurrentProcess(), &creationTime, &exitTime, &kernelTime, &userTime)
|
||||
if err != nil {
|
||||
return systemUsage{}
|
||||
}
|
||||
usage := systemUsage{
|
||||
valid: true,
|
||||
userTime: filetimeDuration(userTime),
|
||||
systemTime: filetimeDuration(kernelTime),
|
||||
}
|
||||
var ioCounters processIOCounters
|
||||
err = getProcessIoCounters(windows.CurrentProcess(), &ioCounters)
|
||||
if err == nil {
|
||||
usage.diskBytesWritten = ioCounters.writeTransferCount
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func readClocks() (absoluteTime int64, continuousTime int64) {
|
||||
if procQueryInterruptTime.Find() != nil {
|
||||
return 0, 0
|
||||
}
|
||||
var unbiasedTime uint64
|
||||
err := queryUnbiasedInterruptTime(&unbiasedTime)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
var interruptTime uint64
|
||||
queryInterruptTime(&interruptTime)
|
||||
return int64(unbiasedTime) * 100, int64(interruptTime) * 100
|
||||
}
|
||||
|
||||
func readInterfaceCounters() map[string]interfaceCounters {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Code generated by 'go generate'; DO NOT EDIT.
|
||||
|
||||
package powerreport
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var _ unsafe.Pointer
|
||||
|
||||
// Do the interface allocations only once for common
|
||||
// Errno values.
|
||||
const (
|
||||
errnoERROR_IO_PENDING = 997
|
||||
)
|
||||
|
||||
var (
|
||||
errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING)
|
||||
errERROR_EINVAL error = syscall.EINVAL
|
||||
)
|
||||
|
||||
// errnoErr returns common boxed Errno values, to prevent
|
||||
// allocations at runtime.
|
||||
func errnoErr(e syscall.Errno) error {
|
||||
switch e {
|
||||
case 0:
|
||||
return errERROR_EINVAL
|
||||
case errnoERROR_IO_PENDING:
|
||||
return errERROR_IO_PENDING
|
||||
}
|
||||
// TODO: add more here, after collecting data on the common
|
||||
// error values see on Windows. (perhaps when running
|
||||
// all.bat?)
|
||||
return e
|
||||
}
|
||||
|
||||
var (
|
||||
modapi_ms_win_core_realtime_l1_1_1 = windows.NewLazySystemDLL("api-ms-win-core-realtime-l1-1-1.dll")
|
||||
modkernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
|
||||
procQueryInterruptTime = modapi_ms_win_core_realtime_l1_1_1.NewProc("QueryInterruptTime")
|
||||
procGetProcessIoCounters = modkernel32.NewProc("GetProcessIoCounters")
|
||||
procQueryUnbiasedInterruptTime = modkernel32.NewProc("QueryUnbiasedInterruptTime")
|
||||
)
|
||||
|
||||
func queryInterruptTime(interruptTime *uint64) {
|
||||
syscall.SyscallN(procQueryInterruptTime.Addr(), uintptr(unsafe.Pointer(interruptTime)))
|
||||
return
|
||||
}
|
||||
|
||||
func getProcessIoCounters(process windows.Handle, ioCounters *processIOCounters) (err error) {
|
||||
r1, _, e1 := syscall.SyscallN(procGetProcessIoCounters.Addr(), uintptr(process), uintptr(unsafe.Pointer(ioCounters)))
|
||||
if r1 == 0 {
|
||||
err = errnoErr(e1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func queryUnbiasedInterruptTime(unbiasedTime *uint64) (err error) {
|
||||
r1, _, e1 := syscall.SyscallN(procQueryUnbiasedInterruptTime.Addr(), uintptr(unsafe.Pointer(unbiasedTime)))
|
||||
if r1 == 0 {
|
||||
err = errnoErr(e1)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (i *Service) Close() error {
|
||||
return i.listener.Close()
|
||||
}
|
||||
|
||||
func (i *Service) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (i *Service) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
metadata.Inbound = i.Tag()
|
||||
metadata.InboundType = i.Type()
|
||||
metadata.Destination = M.Socksaddr{}
|
||||
@@ -146,7 +146,7 @@ func (i *Service) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Service) NewPacketEx(buffer *buf.Buffer, oob []byte, source M.Socksaddr) {
|
||||
func (i *Service) NewPacket(buffer *buf.Buffer, oob []byte, source M.Socksaddr) {
|
||||
go i.exchangePacket(buffer, oob, source)
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func (i *Service) exchangePacket0(ctx context.Context, buffer *buf.Buffer, oob [
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
responseBuffer, err := dns.TruncateDNSMessage(&message, response, 0)
|
||||
responseBuffer, err := dns.TruncateDNSMessage(&message, response, 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+119
-79
@@ -6,6 +6,8 @@ import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -31,7 +33,11 @@ func RegisterTransport(registry *dns.TransportRegistry) {
|
||||
dns.RegisterTransport[option.ResolvedDNSServerOptions](registry, C.TypeResolved, NewTransport)
|
||||
}
|
||||
|
||||
var _ adapter.DNSTransport = (*Transport)(nil)
|
||||
var (
|
||||
_ adapter.DNSTransport = (*Transport)(nil)
|
||||
_ adapter.DNSTransportWithPreferredDomain = (*Transport)(nil)
|
||||
_ adapter.DNSTransportWithEnvironment = (*Transport)(nil)
|
||||
)
|
||||
|
||||
type Transport struct {
|
||||
dns.TransportAdapter
|
||||
@@ -119,6 +125,48 @@ func (t *Transport) Reset() {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Transport) Environment() []string {
|
||||
if t.service == nil {
|
||||
return nil
|
||||
}
|
||||
t.service.linkAccess.RLock()
|
||||
defer t.service.linkAccess.RUnlock()
|
||||
linkIndexes := make([]int32, 0, len(t.service.links))
|
||||
for linkIndex := range t.service.links {
|
||||
linkIndexes = append(linkIndexes, linkIndex)
|
||||
}
|
||||
slices.Sort(linkIndexes)
|
||||
var environment []string
|
||||
for _, linkIndex := range linkIndexes {
|
||||
link := t.service.links[linkIndex]
|
||||
linkEntry := "link:" + strconv.Itoa(int(linkIndex))
|
||||
if link.dnsOverTLS {
|
||||
linkEntry += ":tls"
|
||||
}
|
||||
environment = append(environment, linkEntry)
|
||||
for _, address := range link.address {
|
||||
serverAddr, ok := netip.AddrFromSlice(address.Address)
|
||||
if ok {
|
||||
environment = append(environment, serverAddr.String())
|
||||
}
|
||||
}
|
||||
for _, address := range link.addressEx {
|
||||
serverAddr, ok := netip.AddrFromSlice(address.Address)
|
||||
if ok {
|
||||
environment = append(environment, M.SocksaddrFrom(serverAddr, address.Port).String()+"/"+address.Name)
|
||||
}
|
||||
}
|
||||
for _, domain := range link.domain {
|
||||
if domain.RoutingOnly {
|
||||
environment = append(environment, "routing-only:"+domain.Domain)
|
||||
} else {
|
||||
environment = append(environment, domain.Domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
return environment
|
||||
}
|
||||
|
||||
func (t *Transport) updateTransports(link *TransportLink) error {
|
||||
t.linkAccess.Lock()
|
||||
defer t.linkAccess.Unlock()
|
||||
@@ -128,8 +176,10 @@ func (t *Transport) updateTransports(link *TransportLink) error {
|
||||
}
|
||||
}
|
||||
serverDialer := common.Must1(dialer.NewDefault(t.ctx, option.DialerOptions{
|
||||
BindInterface: link.iif.Name,
|
||||
UDPFragmentDefault: true,
|
||||
AbstractDialerOptions: option.AbstractDialerOptions{
|
||||
BindInterface: link.iif.Name,
|
||||
UDPFragmentDefault: true,
|
||||
},
|
||||
}))
|
||||
var transports []adapter.DNSTransport
|
||||
for _, address := range link.address {
|
||||
@@ -190,7 +240,38 @@ func (t *Transport) deleteTransport(link *TransportLink) {
|
||||
delete(t.linkServers, link)
|
||||
}
|
||||
|
||||
func (t *Transport) PreferredDomain(domain string) bool {
|
||||
t.service.linkAccess.RLock()
|
||||
defer t.service.linkAccess.RUnlock()
|
||||
for _, link := range t.service.links {
|
||||
for _, linkDomain := range link.domain {
|
||||
if linkDomain.Domain == "." {
|
||||
continue
|
||||
}
|
||||
if mDNS.IsSubDomain(linkDomain.Domain, domain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) {
|
||||
done := make(chan struct{})
|
||||
var (
|
||||
response *mDNS.Msg
|
||||
err error
|
||||
)
|
||||
t.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) {
|
||||
response = callbackResponse
|
||||
err = callbackErr
|
||||
close(done)
|
||||
})
|
||||
<-done
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) {
|
||||
question := message.Question[0]
|
||||
var selectedLink *TransportLink
|
||||
t.service.linkAccess.RLock()
|
||||
@@ -214,93 +295,52 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg,
|
||||
}
|
||||
t.service.linkAccess.RUnlock()
|
||||
if selectedLink == nil {
|
||||
return dns.FixedResponseStatus(message, mDNS.RcodeNameError), nil
|
||||
callback(dns.FixedResponseStatus(message, mDNS.RcodeNameError), nil)
|
||||
return
|
||||
}
|
||||
t.linkAccess.RLock()
|
||||
servers := t.linkServers[selectedLink]
|
||||
t.linkAccess.RUnlock()
|
||||
if len(servers.Servers) == 0 {
|
||||
return dns.FixedResponseStatus(message, mDNS.RcodeNameError), nil
|
||||
if servers == nil || len(servers.Servers) == 0 {
|
||||
callback(dns.FixedResponseStatus(message, mDNS.RcodeNameError), nil)
|
||||
return
|
||||
}
|
||||
if question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA {
|
||||
return t.exchangeParallel(ctx, servers, message)
|
||||
} else {
|
||||
return t.exchangeSingleRequest(ctx, servers, message)
|
||||
names := servers.Link.nameList(t.ndots, question.Name)
|
||||
if len(names) == 0 {
|
||||
callback(nil, E.New("invalid domain: ", question.Name))
|
||||
return
|
||||
}
|
||||
transport.ExchangeNames(ctx, names, question, func(fqdn string) transport.AsyncExchanger {
|
||||
return t.newNameExchanger(servers, message, fqdn)
|
||||
}, callback)
|
||||
}
|
||||
|
||||
func (t *Transport) exchangeSingleRequest(ctx context.Context, servers *LinkServers, message *mDNS.Msg) (*mDNS.Msg, error) {
|
||||
var lastErr error
|
||||
for _, fqdn := range servers.Link.nameList(t.ndots, message.Question[0].Name) {
|
||||
response, err := t.tryOneName(ctx, servers, message, fqdn)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (t *Transport) tryOneName(ctx context.Context, servers *LinkServers, message *mDNS.Msg, fqdn string) (*mDNS.Msg, error) {
|
||||
func (t *Transport) newNameExchanger(servers *LinkServers, message *mDNS.Msg, fqdn string) transport.AsyncExchanger {
|
||||
serverOffset := servers.ServerOffset(t.rotate)
|
||||
sLen := uint32(len(servers.Servers))
|
||||
var lastErr error
|
||||
serverCount := uint32(len(servers.Servers))
|
||||
attemptExchangers := make([]transport.AsyncExchanger, 0, t.attempts*int(serverCount))
|
||||
for i := 0; i < t.attempts; i++ {
|
||||
for j := range sLen {
|
||||
server := servers.Servers[(serverOffset+j)%sLen]
|
||||
question := message.Question[0]
|
||||
question.Name = fqdn
|
||||
exchangeMessage := *message
|
||||
exchangeMessage.Question = []mDNS.Question{question}
|
||||
exchangeCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
||||
response, err := server.Exchange(exchangeCtx, &exchangeMessage)
|
||||
cancel()
|
||||
for j := range serverCount {
|
||||
server := servers.Servers[(serverOffset+j)%serverCount]
|
||||
attemptExchangers = append(attemptExchangers, func(ctx context.Context, callback func(response *mDNS.Msg, err error)) {
|
||||
question := message.Question[0]
|
||||
question.Name = fqdn
|
||||
exchangeMessage := *message
|
||||
exchangeMessage.Question = []mDNS.Question{question}
|
||||
exchangeCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
||||
server.ExchangeAsync(exchangeCtx, &exchangeMessage, func(response *mDNS.Msg, err error) {
|
||||
cancel()
|
||||
callback(response, err)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) {
|
||||
transport.ExchangeSequential(ctx, attemptExchangers, nil, func(response *mDNS.Msg, err error) {
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
err = E.Cause(err, fqdn)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
}
|
||||
return nil, E.Cause(lastErr, fqdn)
|
||||
}
|
||||
|
||||
func (t *Transport) exchangeParallel(ctx context.Context, servers *LinkServers, message *mDNS.Msg) (*mDNS.Msg, error) {
|
||||
returned := make(chan struct{})
|
||||
defer close(returned)
|
||||
type queryResult struct {
|
||||
response *mDNS.Msg
|
||||
err error
|
||||
}
|
||||
results := make(chan queryResult)
|
||||
startRacer := func(ctx context.Context, fqdn string) {
|
||||
response, err := t.tryOneName(ctx, servers, message, fqdn)
|
||||
select {
|
||||
case results <- queryResult{response, err}:
|
||||
case <-returned:
|
||||
}
|
||||
}
|
||||
queryCtx, queryCancel := context.WithCancel(ctx)
|
||||
defer queryCancel()
|
||||
var nameCount int
|
||||
for _, fqdn := range servers.Link.nameList(t.ndots, message.Question[0].Name) {
|
||||
nameCount++
|
||||
go startRacer(queryCtx, fqdn)
|
||||
}
|
||||
var errors []error
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case result := <-results:
|
||||
if result.err == nil {
|
||||
return result.response, nil
|
||||
}
|
||||
errors = append(errors, result.err)
|
||||
if len(errors) == nameCount {
|
||||
return nil, E.Errors(errors...)
|
||||
}
|
||||
}
|
||||
callback(response, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func (s *Service) loadCache() error {
|
||||
return nil
|
||||
}
|
||||
basePath := filemanager.BasePath(s.ctx, s.cachePath)
|
||||
cacheBinary, err := os.ReadFile(basePath)
|
||||
cacheBinary, err := filemanager.ReadFile(s.ctx, basePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
@@ -46,7 +46,7 @@ func (s *Service) loadCache() error {
|
||||
}
|
||||
err = s.decodeCache(cacheBinary)
|
||||
if err != nil {
|
||||
os.RemoveAll(basePath)
|
||||
filemanager.RemoveAll(s.ctx, basePath)
|
||||
return err
|
||||
}
|
||||
s.cacheMutex.Lock()
|
||||
@@ -73,11 +73,11 @@ func (s *Service) saveCache() error {
|
||||
|
||||
func (s *Service) writeCache(cacheBinary []byte) error {
|
||||
basePath := filemanager.BasePath(s.ctx, s.cachePath)
|
||||
err := os.MkdirAll(filepath.Dir(basePath), 0o777)
|
||||
err := filemanager.MkdirAll(s.ctx, filepath.Dir(basePath), 0o777)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.WriteFile(basePath, cacheBinary, 0o644)
|
||||
err = filemanager.WriteFile(s.ctx, basePath, cacheBinary, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//go:build with_usbip && (linux || (darwin && cgo) || windows)
|
||||
|
||||
package usbip
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
boxService "github.com/sagernet/sing-box/adapter/service"
|
||||
"github.com/sagernet/sing-box/common/dialer"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-usbip"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
type ClientService struct {
|
||||
boxService.Adapter
|
||||
ctx context.Context
|
||||
logger log.ContextLogger
|
||||
inner *usbip.ClientService
|
||||
}
|
||||
|
||||
func NewClientService(ctx context.Context, logger log.ContextLogger, tag string, options option.USBIPClientServiceOptions) (adapter.Service, error) {
|
||||
serviceDialer, err := dialer.NewWithOptions(dialer.Options{
|
||||
Context: ctx,
|
||||
Options: option.DialerOptions{
|
||||
Detour: options.Detour,
|
||||
},
|
||||
RemoteIsDomain: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "create dialer")
|
||||
}
|
||||
inner, err := usbip.NewClientService(ctx, usbip.ClientOptions{
|
||||
Logger: logger,
|
||||
Dialer: serviceDialer,
|
||||
ServerAddress: options.ServerOptions.Build(),
|
||||
Devices: toDeviceMatches(options.Devices),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ClientService{
|
||||
Adapter: boxService.NewAdapter(C.TypeUSBIPClient, tag),
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
inner: inner,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
return s.inner.Start()
|
||||
}
|
||||
|
||||
func (s *ClientService) Close() error {
|
||||
return s.inner.Close()
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//go:build with_usbip && (linux || (darwin && cgo) || windows)
|
||||
|
||||
package usbip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
boxService "github.com/sagernet/sing-box/adapter/service"
|
||||
"github.com/sagernet/sing-box/common/listener"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-usbip"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
type ServerService struct {
|
||||
boxService.Adapter
|
||||
ctx context.Context
|
||||
logger log.ContextLogger
|
||||
inner *usbip.ServerService
|
||||
}
|
||||
|
||||
type dynamicServerService struct {
|
||||
ServerService
|
||||
host *usbip.DynamicHost
|
||||
}
|
||||
|
||||
var _ adapter.USBIPDynamicServer = (*dynamicServerService)(nil)
|
||||
|
||||
func NewServerService(ctx context.Context, logger log.ContextLogger, tag string, options option.USBIPServerServiceOptions) (adapter.Service, error) {
|
||||
listenOptions := options.ListenOptions
|
||||
if listenOptions.ListenPort == 0 {
|
||||
listenOptions.ListenPort = usbip.DefaultPort
|
||||
}
|
||||
boxListener := listener.New(listener.Options{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
Network: []string{N.NetworkTCP},
|
||||
Listen: listenOptions,
|
||||
})
|
||||
serverOptions := usbip.ServerOptions{
|
||||
Logger: logger,
|
||||
Listen: func(context.Context) (net.Listener, error) {
|
||||
return boxListener.ListenTCP()
|
||||
},
|
||||
}
|
||||
base := ServerService{
|
||||
Adapter: boxService.NewAdapter(C.TypeUSBIPServer, tag),
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
providerType := options.Provider
|
||||
if providerType == "" {
|
||||
providerType = option.USBIPProviderDefault
|
||||
}
|
||||
switch providerType {
|
||||
case option.USBIPProviderDefault:
|
||||
defaultOptions, isDefault := options.Options.(*option.USBIPDefaultProviderOptions)
|
||||
if isDefault {
|
||||
serverOptions.Devices = toDeviceMatches(defaultOptions.Devices)
|
||||
}
|
||||
inner, err := usbip.NewServerService(ctx, serverOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base.inner = inner
|
||||
return &base, nil
|
||||
case option.USBIPProviderDynamic:
|
||||
host := usbip.NewDynamicHost(logger)
|
||||
inner, err := usbip.NewDynamicServerService(ctx, serverOptions, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base.inner = inner
|
||||
return &dynamicServerService{ServerService: base, host: host}, nil
|
||||
default:
|
||||
return nil, E.New("unknown usbip provider type: ", providerType)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ServerService) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
return s.inner.Start()
|
||||
}
|
||||
|
||||
func (s *ServerService) Close() error {
|
||||
return s.inner.Close()
|
||||
}
|
||||
|
||||
func (s *dynamicServerService) AddDevice(info usbip.ProvidedDeviceInfo, transport usbip.DeviceTransport) (string, error) {
|
||||
return s.host.AddDevice(info, transport)
|
||||
}
|
||||
|
||||
func (s *dynamicServerService) RemoveDevice(busID string) {
|
||||
s.host.RemoveDevice(busID)
|
||||
}
|
||||
|
||||
func (s *dynamicServerService) SubscribeDevices(ctx context.Context, listener func([]usbip.ControlDeviceInfo)) {
|
||||
s.inner.SubscribeDevices(ctx, listener)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build with_usbip && (linux || (darwin && cgo) || windows)
|
||||
|
||||
package usbip
|
||||
|
||||
import (
|
||||
boxService "github.com/sagernet/sing-box/adapter/service"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-usbip"
|
||||
)
|
||||
|
||||
func RegisterService(registry *boxService.Registry) {
|
||||
boxService.Register[option.USBIPServerServiceOptions](registry, C.TypeUSBIPServer, NewServerService)
|
||||
boxService.Register[option.USBIPClientServiceOptions](registry, C.TypeUSBIPClient, NewClientService)
|
||||
}
|
||||
|
||||
func toDeviceMatches(matches []option.USBIPDeviceMatch) []usbip.DeviceMatch {
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
deviceMatches := make([]usbip.DeviceMatch, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
deviceMatches = append(deviceMatches, usbip.DeviceMatch{
|
||||
BusID: match.BusID,
|
||||
VendorID: match.VendorID,
|
||||
ProductID: match.ProductID,
|
||||
Serial: match.Serial,
|
||||
})
|
||||
}
|
||||
return deviceMatches
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//go:build !with_usbip || !(linux || (darwin && cgo) || windows)
|
||||
|
||||
package usbip
|
||||
Reference in New Issue
Block a user