Merge tag 'v1.14.0'

This commit is contained in:
Shtorm
2026-09-04 01:08:30 +03:00
987 changed files with 141481 additions and 11302 deletions
+419
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
//go:build !with_acme
package acme
+314
View File
@@ -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 != ""
}
+18
View File
@@ -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
}
+138
View 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,
)
}
+239
View File
@@ -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)
}
+249
View File
@@ -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, "")
}
+6 -4
View File
@@ -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 {
+7 -6
View File
@@ -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)
}
+6 -4
View File
@@ -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)
}
+3 -2
View File
@@ -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)
}
+7 -4
View File
@@ -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
View File
@@ -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
}
+6 -4
View File
@@ -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 {
+6 -4
View File
@@ -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)
}
+6 -4
View File
@@ -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)
}
+3 -2
View File
@@ -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)
}
+7 -4
View File
@@ -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()
+19
View File
@@ -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()
}
+6
View File
@@ -0,0 +1,6 @@
//go:build !badlinkname
package oomkiller
func badCleanup() {
}
-51
View File
@@ -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
}
+46
View File
@@ -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
View File
@@ -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")
}
}
+150
View File
@@ -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 -57
View File
@@ -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
}
-143
View File
@@ -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)
}
+328
View File
@@ -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
}
+15
View File
@@ -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()
}
+625
View File
@@ -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"`
}
+44
View File
@@ -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()
}
+48
View File
@@ -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)
}
}
+118
View File
@@ -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
}
+491
View File
@@ -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)
}
+21
View File
@@ -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
+123
View File
@@ -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")
}
+92
View File
@@ -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
}
+15
View File
@@ -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
}
+46
View File
@@ -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
}
+68
View File
@@ -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
}
+3 -3
View File
@@ -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
View File
@@ -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)
})
}
}
+4 -4
View File
@@ -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
}
+62
View File
@@ -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()
}
+107
View File
@@ -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)
}
+31
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
//go:build !with_usbip || !(linux || (darwin && cgo) || windows)
package usbip