Files
proxy/internal/gnome/gnome.go
T
2026-08-30 11:17:41 +08:00

968 lines
34 KiB
Go

// Package gnome implements the GNOME desktop proxy Target through gsettings.
package gnome
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"unicode/utf8"
"proxyctl/internal/pathutil"
"proxyctl/internal/profile"
"proxyctl/internal/securefs"
"proxyctl/internal/target"
)
const (
gnomeRecoveryVersion = 1
proxySchema = "org.gnome.system.proxy"
httpSchema = proxySchema + ".http"
httpsSchema = proxySchema + ".https"
socksSchema = proxySchema + ".socks"
)
var requiredSchemas = []string{proxySchema, httpSchema, httpsSchema, socksSchema}
var managedSettings = []struct{ schema, key string }{
{proxySchema, "mode"}, {proxySchema, "autoconfig-url"}, {proxySchema, "ignore-hosts"},
{httpSchema, "host"}, {httpSchema, "port"}, {httpSchema, "use-authentication"}, {httpSchema, "authentication-user"}, {httpSchema, "authentication-password"},
{httpsSchema, "host"}, {httpsSchema, "port"}, {socksSchema, "host"}, {socksSchema, "port"},
}
// Command is the complete command contract used by the adapter. UID, GID, and
// Env make user-session identity explicit when proxyctl itself runs under sudo.
type Command struct {
Name string
Args []string
UID int
GID int
Env map[string]string
}
type CommandRunner interface {
Run(context.Context, Command) (string, error)
}
type execRunner struct{}
func (execRunner) Run(ctx context.Context, command Command) (string, error) {
cmd := exec.CommandContext(ctx, command.Name, command.Args...)
environment := envWithOverrides(os.Environ(), command.Env)
cmd.Env = environment
if os.Geteuid() != command.UID {
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uint32(command.UID), Gid: uint32(command.GID)}}
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
output, err := cmd.Output()
if err != nil {
detail := strings.TrimSpace(stderr.String())
if detail == "" {
return "", fmt.Errorf("run %s: %w", command.Name, err)
}
if strings.Contains(strings.ToLower(detail), "permission denied") {
return "", fmt.Errorf("run %s: %s: %w", command.Name, detail, os.ErrPermission)
}
return "", fmt.Errorf("run %s: %s: %w", command.Name, detail, err)
}
return string(output), nil
}
func envWithOverrides(base []string, overrides map[string]string) []string {
values := make(map[string]string, len(base)+len(overrides))
order := make([]string, 0, len(base)+len(overrides))
for _, item := range base {
name, value, ok := strings.Cut(item, "=")
if !ok {
continue
}
if _, exists := values[name]; !exists {
order = append(order, name)
}
values[name] = value
}
for name, value := range overrides {
if _, exists := values[name]; !exists {
order = append(order, name)
}
values[name] = value
}
result := make([]string, 0, len(order))
for _, name := range order {
result = append(result, name+"="+values[name])
}
return result
}
// StateStore holds the original GNOME values and last managed fingerprint.
type StateStore interface {
Load() ([]byte, error)
Save([]byte) error
Delete() error
Lock() (io.Closer, error)
}
type fileState struct {
path string
store securefs.Store
}
func NewFileState(path string, uid, gid int, trustedRoot string) StateStore {
var owner *securefs.Owner
if uid >= 0 && gid >= 0 {
owner = &securefs.Owner{UID: uid, GID: gid}
}
return &fileState{path: path, store: securefs.Store{TrustedRoot: trustedRoot, Owner: owner}}
}
func (s *fileState) Load() ([]byte, error) { return s.store.ReadFile(s.path) }
func (s *fileState) Save(data []byte) error { return s.store.AtomicWrite(s.path, data, 0o600) }
func (s *fileState) Delete() error { return s.store.Remove(s.path) }
func (s *fileState) Lock() (io.Closer, error) {
return s.store.LockFile(s.path + ".lock")
}
type Configuration struct {
CommandName string
UID int
GID int
Username string
HomeDir string
ConfigDir string
DataDir string
CacheDir string
RuntimeDir string
SessionBus string
StateFile string
TrustedRoot string
}
// ResolveConfiguration keeps both recovery state and gsettings commands with
// the invoking user under sudo, never root's unrelated desktop session.
func ResolveConfiguration(environment profile.IdentityEnvironment) (Configuration, error) {
profileConfiguration, err := profile.ResolveConfiguration(environment)
if err != nil {
return Configuration{}, err
}
owner := profileConfiguration.Owner
current, err := environment.CurrentUser()
if err != nil {
return Configuration{}, fmt.Errorf("identify effective user: %w", err)
}
invoking := current.Username != owner.Username
stateDir := ""
if value, ok := environment.LookupEnv("XDG_STATE_HOME"); ok && filepath.IsAbs(value) && (!invoking || pathWithin(value, owner.HomeDir)) {
stateDir = value
}
if stateDir == "" {
stateDir = filepath.Join(owner.HomeDir, ".local", "state")
}
// Profile storage lives below <XDG_CONFIG_HOME>/proxyctl, while gsettings
// must receive the XDG base itself or dconf opens a proxyctl-private database.
configDir := filepath.Dir(filepath.Dir(profileConfiguration.Path))
dataDir := filepath.Join(owner.HomeDir, ".local", "share")
if value, ok := environment.LookupEnv("XDG_DATA_HOME"); ok && filepath.IsAbs(value) && (!invoking || pathWithin(value, owner.HomeDir)) {
dataDir = value
}
cacheDir := filepath.Join(owner.HomeDir, ".cache")
if value, ok := environment.LookupEnv("XDG_CACHE_HOME"); ok && filepath.IsAbs(value) && (!invoking || pathWithin(value, owner.HomeDir)) {
cacheDir = value
}
runtimeDir := filepath.Join("/run/user", strconv.Itoa(owner.UID))
if value, ok := environment.LookupEnv("XDG_RUNTIME_DIR"); ok && filepath.IsAbs(value) && !invoking {
runtimeDir = value
}
sessionBus := "unix:path=" + filepath.Join(runtimeDir, "bus")
if value, ok := environment.LookupEnv("DBUS_SESSION_BUS_ADDRESS"); ok && value != "" && !invoking {
sessionBus = value
}
trustedRoot := owner.HomeDir
if !pathWithin(stateDir, owner.HomeDir) {
trustedRoot = stateDir
}
return Configuration{CommandName: "gsettings", UID: owner.UID, GID: owner.GID, Username: owner.Username, HomeDir: owner.HomeDir, ConfigDir: configDir, DataDir: dataDir, CacheDir: cacheDir, RuntimeDir: runtimeDir, SessionBus: sessionBus, StateFile: filepath.Join(stateDir, "proxyctl", "gnome.json"), TrustedRoot: trustedRoot}, nil
}
func pathWithin(path, parent string) bool { return pathutil.Within(path, parent) }
type settings struct {
Mode string `json:"mode"`
AutoConfigURL string `json:"autoConfigUrl"`
IgnoreHosts []string `json:"ignoreHosts"`
HTTPHost string `json:"httpHost"`
HTTPPort int `json:"httpPort"`
HTTPUseAuth bool `json:"httpUseAuthentication"`
HTTPAuthUser string `json:"httpAuthenticationUser"`
HTTPAuthPassword string `json:"httpAuthenticationPassword"`
HTTPSHost string `json:"httpsHost"`
HTTPSPort int `json:"httpsPort"`
SocksHost string `json:"socksHost"`
SocksPort int `json:"socksPort"`
}
type recovery struct {
Version int `json:"version"`
Original settings `json:"original"`
LastFingerprint string `json:"lastFingerprint"`
}
type Adapter struct {
configuration Configuration
runner CommandRunner
state StateStore
}
var (
_ target.Target = (*Adapter)(nil)
_ target.Diagnosable = (*Adapter)(nil)
)
func New(configuration Configuration, runner CommandRunner, state StateStore) *Adapter {
if configuration.CommandName == "" {
configuration.CommandName = "gsettings"
}
if configuration.ConfigDir == "" {
configuration.ConfigDir = filepath.Join(configuration.HomeDir, ".config")
}
if configuration.DataDir == "" {
configuration.DataDir = filepath.Join(configuration.HomeDir, ".local", "share")
}
if configuration.CacheDir == "" {
configuration.CacheDir = filepath.Join(configuration.HomeDir, ".cache")
}
if configuration.RuntimeDir == "" {
configuration.RuntimeDir = filepath.Join("/run/user", strconv.Itoa(configuration.UID))
}
if configuration.SessionBus == "" {
configuration.SessionBus = "unix:path=" + filepath.Join(configuration.RuntimeDir, "bus")
}
if runner == nil {
runner = execRunner{}
}
if state == nil {
state = NewFileState(configuration.StateFile, configuration.UID, configuration.GID, configuration.TrustedRoot)
}
return &Adapter{configuration: configuration, runner: runner, state: state}
}
func (a *Adapter) ID() string { return "gnome" }
func (a *Adapter) Description() target.Description {
return target.Description{
Name: "GNOME Desktop",
Summary: "Configures the GNOME desktop system proxy.",
Scope: "applications that consume GNOME system network settings; not Shell commands or system services",
Permissions: "the desktop user's usable session bus and schema permissions",
Ownership: "complete original desktop values are recorded and restored when management is disabled",
RestartRisk: "none; changes take effect immediately",
Details: "Affects applications that consume GNOME system network settings; not every graphical application consumes these settings, and it does not affect Shell commands or system services. proxyctl records the complete original desktop proxy values before temporarily owning the managed settings and restores them on disable. The settings belong to the desktop user and require that user's usable session bus and schema permissions; changes take effect immediately without a service restart.",
}
}
func (a *Adapter) Confirmation(bool) string { return "" }
// DiagnosticChecks uses only gsettings list/get operations and filesystem
// metadata probes. In particular, it never invokes gsettings set or locks or
// writes the recovery StateStore.
func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check {
commandCheck := target.Check{Name: "required command", OK: true, Evidence: a.configuration.CommandName + " is usable for the owning user"}
schemaCheck := target.Check{Name: "required schemas", OK: true, Evidence: "all GNOME proxy schemas are present"}
sessionCheck := target.Check{Name: "desktop session", OK: true, Evidence: "GNOME proxy settings are readable through the configured session bus"}
output, err := a.run(ctx, "list-schemas")
if err != nil {
commandCheck.OK = false
commandCheck.Evidence = profile.RedactText(a.configuration.CommandName + " failed: " + err.Error())
schemaCheck.OK = false
schemaCheck.Evidence = "GNOME schemas could not be listed"
sessionCheck.OK = false
sessionCheck.Evidence = "desktop session could not be inspected"
} else {
present := make(map[string]bool)
for _, schema := range strings.Fields(output) {
present[schema] = true
}
var missing []string
for _, schema := range requiredSchemas {
if !present[schema] {
missing = append(missing, schema)
}
}
if len(missing) != 0 {
schemaCheck.OK = false
schemaCheck.Evidence = "missing GNOME proxy schemas: " + strings.Join(missing, ", ")
sessionCheck.OK = false
sessionCheck.Evidence = "desktop session settings cannot be checked without required schemas"
} else if _, err := a.readSettings(ctx); err != nil {
sessionCheck.OK = false
sessionCheck.Evidence = profile.RedactText("GNOME proxy settings are unreadable: " + err.Error())
}
}
authorityCheck := target.ReadOnlyWriteAuthority(a.configuration.StateFile)
if !commandCheck.OK || !schemaCheck.OK || !sessionCheck.OK {
authorityCheck.OK = false
authorityCheck.Evidence += "; GNOME setting authority could not be inspected"
} else {
for _, setting := range managedSettings {
output, err := a.run(ctx, "writable", setting.schema, setting.key)
if err != nil || strings.TrimSpace(output) != "true" {
authorityCheck.OK = false
detail := strings.TrimSpace(output)
if err != nil {
detail = err.Error()
}
authorityCheck.Evidence += profile.RedactText(fmt.Sprintf("; %s %s is not writable: %s", setting.schema, setting.key, detail))
}
}
if authorityCheck.OK {
authorityCheck.Evidence += "; all managed GNOME settings report writable"
}
}
return []target.Check{commandCheck, schemaCheck, sessionCheck, authorityCheck}
}
func (a *Adapter) Availability(ctx context.Context) target.Availability {
availability, _, _ := a.detectAvailability(ctx)
return availability
}
// detectAvailability returns the settings observation that established
// availability so Detect can inspect that exact observation without issuing a
// second twelve-command gsettings probe.
func (a *Adapter) detectAvailability(ctx context.Context) (target.Availability, settings, error) {
output, err := a.run(ctx, "list-schemas")
if err != nil {
safeErr := redact(err)
return target.Availability{Evidence: fmt.Sprintf("GNOME Target requires a usable %s command: %v", a.configuration.CommandName, safeErr)}, settings{}, safeErr
}
present := make(map[string]bool)
for _, schema := range strings.Fields(output) {
present[schema] = true
}
for _, schema := range requiredSchemas {
if !present[schema] {
return target.Availability{Evidence: "required GNOME settings schema is unavailable: " + schema}, settings{}, nil
}
}
current, err := a.readSettings(ctx)
if err != nil {
safeErr := redact(err)
return target.Availability{Evidence: fmt.Sprintf("GNOME proxy schema is present but unusable for the desktop session: %v", safeErr)}, settings{}, safeErr
}
return target.Availability{Available: true, Evidence: "gsettings command and GNOME proxy schemas are usable for the owning user"}, current, nil
}
func (a *Adapter) Detect(ctx context.Context, active *profile.Profile) target.Detection {
availability, current, availabilityErr := a.detectAvailability(ctx)
if !availability.Available && availabilityErr == nil {
return target.Detection{Availability: availability}
}
inspection, inspectionErr := a.inspectSettings(active, current, availabilityErr)
return target.Detection{Availability: availability, Inspection: inspection, Err: errors.Join(availabilityErr, inspectionErr)}
}
func (a *Adapter) Inspect(ctx context.Context, active *profile.Profile) target.Inspection {
current, settingsErr := a.readSettings(ctx)
inspection, _ := a.inspectSettings(active, current, settingsErr)
return inspection
}
func (a *Adapter) inspectSettings(active *profile.Profile, current settings, settingsErr error) (target.Inspection, error) {
metadata, managed, metadataErr := a.readRecovery()
if managed || (metadataErr != nil && !errors.Is(metadataErr, fs.ErrNotExist)) {
if metadataErr != nil {
return target.Inspection{State: target.Drifted, Evidence: "managed GNOME recovery metadata cannot be read"}, redact(metadataErr)
}
if settingsErr != nil {
return target.Inspection{State: target.Drifted, Evidence: "managed GNOME settings cannot be read: " + redact(settingsErr).Error()}, redact(settingsErr)
}
if fingerprint(current) == metadata.LastFingerprint && active != nil {
desired, err := translate(*active)
if err == nil && fingerprint(desired) == metadata.LastFingerprint {
return target.Inspection{State: target.Enabled, Evidence: "managed GNOME settings match the Active Profile"}, nil
}
}
return target.Inspection{State: target.Drifted, Evidence: "managed GNOME settings do not match the Active Profile"}, nil
}
if settingsErr != nil {
safeErr := redact(settingsErr)
return target.Inspection{State: target.Disabled, Evidence: "GNOME settings could not be inspected: " + safeErr.Error()}, safeErr
}
switch current.Mode {
case "manual":
return target.Inspection{State: target.External, Evidence: "recognized external GNOME manual proxy mode is configured"}, nil
case "auto":
return target.Inspection{State: target.External, Evidence: "recognized external GNOME automatic proxy mode is configured"}, nil
default:
return target.Inspection{State: target.Disabled, Evidence: "GNOME proxy mode is none; no recognized desktop proxy configuration detected"}, nil
}
}
func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.Profile) target.OperationResult {
availability := a.Availability(ctx)
if !availability.Available {
return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("GNOME Target is unavailable: " + availability.Evidence)}
}
if enabled && active == nil {
return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("no Active Profile; open Profile management to create and activate one")}
}
lock, err := a.state.Lock()
if err != nil {
return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: redactForProfile(fmt.Errorf("lock GNOME mutation: %w", err), active)}
}
defer func() { _ = lock.Close() }() // best effort: release the process-shared advisory lock
if enabled {
err = a.enable(ctx, *active)
} else {
err = a.disable(ctx)
}
return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: redactForProfile(err, active)}
}
func (a *Adapter) enable(ctx context.Context, active profile.Profile) error {
desired, err := translate(active)
if err != nil {
return err
}
metadata, managed, err := a.readRecovery()
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("read GNOME recovery metadata: %w", err)
}
if !managed {
metadata.Version = gnomeRecoveryVersion
original, err := a.readSettings(ctx)
if err != nil {
return fmt.Errorf("snapshot original GNOME settings: %w", err)
}
metadata.Original = original
}
metadata.LastFingerprint = fingerprint(desired)
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return err
}
if err := a.state.Save(append(data, '\n')); err != nil {
return fmt.Errorf("persist GNOME recovery metadata before changing settings: %w", err)
}
if err := a.writeSettings(ctx, desired); err != nil {
return fmt.Errorf("apply GNOME proxy settings after recovery metadata persisted: %w", err)
}
return nil
}
func (a *Adapter) disable(ctx context.Context) error {
metadata, managed, err := a.readRecovery()
if errors.Is(err, fs.ErrNotExist) || !managed {
return nil
}
if err != nil {
return fmt.Errorf("read GNOME recovery metadata: %w", err)
}
current, err := a.readSettings(ctx)
if err != nil {
return fmt.Errorf("verify managed GNOME settings before restore: %w", err)
}
currentFingerprint := fingerprint(current)
if currentFingerprint == fingerprint(metadata.Original) {
// The restore completed previously, but metadata cleanup did not. It is
// safe to retry only the cleanup while the restored snapshot still
// matches exactly.
if err := a.state.Delete(); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("remove GNOME recovery metadata after restoring settings: %w", err)
}
return nil
}
if currentFingerprint != metadata.LastFingerprint {
return errors.New("GNOME settings no longer match proxyctl's last applied values; refusing restore because ownership evidence was lost")
}
if err := a.writeSettings(ctx, metadata.Original); err != nil {
return fmt.Errorf("restore original GNOME settings: %w", err)
}
if err := a.state.Delete(); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("remove GNOME recovery metadata after restoring settings: %w", err)
}
return nil
}
func (a *Adapter) readRecovery() (recovery, bool, error) {
data, err := a.state.Load()
if err != nil {
return recovery{}, false, err
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return recovery{}, true, err
}
if fields == nil {
return recovery{}, true, errors.New("GNOME recovery metadata must be a JSON object")
}
for _, name := range []string{"version", "original", "lastFingerprint"} {
if _, present := fields[name]; !present {
return recovery{}, true, fmt.Errorf("GNOME recovery metadata is missing required field %q", name)
}
}
var originalFields map[string]json.RawMessage
if err := json.Unmarshal(fields["original"], &originalFields); err != nil || originalFields == nil {
return recovery{}, true, errors.New("GNOME recovery metadata original settings must be a JSON object")
}
for _, name := range []string{"mode", "autoConfigUrl", "ignoreHosts", "httpHost", "httpPort", "httpUseAuthentication", "httpAuthenticationUser", "httpAuthenticationPassword", "httpsHost", "httpsPort", "socksHost", "socksPort"} {
if _, present := originalFields[name]; !present {
return recovery{}, true, fmt.Errorf("GNOME recovery metadata original settings are missing required field %q", name)
}
}
var value recovery
if err := json.Unmarshal(data, &value); err != nil {
return recovery{}, true, err
}
if err := validateRecovery(value); err != nil {
return recovery{}, true, err
}
return value, true, nil
}
func validateRecovery(value recovery) error {
if value.Version != gnomeRecoveryVersion {
return fmt.Errorf("unsupported GNOME recovery metadata version %d", value.Version)
}
decoded, err := hex.DecodeString(value.LastFingerprint)
if err != nil || len(decoded) != sha256.Size {
return errors.New("GNOME recovery metadata has an invalid fingerprint")
}
if value.Original.Mode != "none" && value.Original.Mode != "manual" && value.Original.Mode != "auto" {
return errors.New("GNOME recovery metadata has an invalid original proxy mode")
}
if value.Original.IgnoreHosts == nil {
return errors.New("GNOME recovery metadata has an incomplete original ignore-hosts snapshot")
}
for _, port := range []int{value.Original.HTTPPort, value.Original.HTTPSPort, value.Original.SocksPort} {
if port < 0 || port > 65535 {
return errors.New("GNOME recovery metadata has an invalid original proxy port")
}
}
return nil
}
func (a *Adapter) command(args ...string) Command {
return Command{Name: a.configuration.CommandName, Args: append([]string(nil), args...), UID: a.configuration.UID, GID: a.configuration.GID, Env: map[string]string{
"HOME": a.configuration.HomeDir, "USER": a.configuration.Username, "LOGNAME": a.configuration.Username,
"XDG_CONFIG_HOME": a.configuration.ConfigDir, "XDG_DATA_HOME": a.configuration.DataDir, "XDG_CACHE_HOME": a.configuration.CacheDir,
"XDG_RUNTIME_DIR": a.configuration.RuntimeDir, "DBUS_SESSION_BUS_ADDRESS": a.configuration.SessionBus,
}}
}
func (a *Adapter) run(ctx context.Context, args ...string) (string, error) {
return a.runner.Run(ctx, a.command(args...))
}
func (a *Adapter) get(ctx context.Context, schema, key string) (string, error) {
value, err := a.run(ctx, "get", schema, key)
if err != nil {
return "", fmt.Errorf("get %s %s: %w", schema, key, err)
}
return strings.TrimSpace(value), nil
}
func (a *Adapter) set(ctx context.Context, schema, key, value string) error {
if _, err := a.run(ctx, "set", schema, key, value); err != nil {
return fmt.Errorf("set %s %s: %w", schema, key, err)
}
return nil
}
func (a *Adapter) setSensitiveString(ctx context.Context, schema, key, value string) error {
if _, err := a.run(ctx, "set", schema, key, value); err != nil {
return safeRedaction("set GNOME authentication password failed; sensitive argument and runner error suppressed", err)
}
return nil
}
func (a *Adapter) readSettings(ctx context.Context) (settings, error) {
var result settings
var err error
if result.Mode, err = a.getString(ctx, proxySchema, "mode"); err != nil {
return settings{}, err
}
if result.AutoConfigURL, err = a.getString(ctx, proxySchema, "autoconfig-url"); err != nil {
return settings{}, err
}
if result.IgnoreHosts, err = a.getStrings(ctx, proxySchema, "ignore-hosts"); err != nil {
return settings{}, err
}
if result.HTTPHost, err = a.getString(ctx, httpSchema, "host"); err != nil {
return settings{}, err
}
if result.HTTPPort, err = a.getInt(ctx, httpSchema, "port"); err != nil {
return settings{}, err
}
if result.HTTPUseAuth, err = a.getBool(ctx, httpSchema, "use-authentication"); err != nil {
return settings{}, err
}
if result.HTTPAuthUser, err = a.getString(ctx, httpSchema, "authentication-user"); err != nil {
return settings{}, err
}
if result.HTTPAuthPassword, err = a.getSensitiveString(ctx, httpSchema, "authentication-password"); err != nil {
return settings{}, err
}
if result.HTTPSHost, err = a.getString(ctx, httpsSchema, "host"); err != nil {
return settings{}, err
}
if result.HTTPSPort, err = a.getInt(ctx, httpsSchema, "port"); err != nil {
return settings{}, err
}
if result.SocksHost, err = a.getString(ctx, socksSchema, "host"); err != nil {
return settings{}, err
}
if result.SocksPort, err = a.getInt(ctx, socksSchema, "port"); err != nil {
return settings{}, err
}
return result, nil
}
func (a *Adapter) getString(ctx context.Context, schema, key string) (string, error) {
raw, err := a.get(ctx, schema, key)
if err != nil {
return "", err
}
return parseString(raw)
}
// getSensitiveString deliberately suppresses both command error detail and raw
// output. A password value is secret even when malformed, and command runners
// are not trusted to keep that value out of their errors.
func (a *Adapter) getSensitiveString(ctx context.Context, schema, key string) (string, error) {
raw, err := a.run(ctx, "get", schema, key)
if err != nil {
return "", safeRedaction(fmt.Sprintf("get %s %s failed; sensitive output suppressed", schema, key), err)
}
value, err := parseString(strings.TrimSpace(raw))
if err != nil {
return "", errors.New("parse " + schema + " " + key + " failed; sensitive output suppressed")
}
return value, nil
}
func (a *Adapter) getInt(ctx context.Context, schema, key string) (int, error) {
raw, err := a.get(ctx, schema, key)
if err != nil {
return 0, err
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("parse %s %s integer %q: %w", schema, key, raw, err)
}
return value, nil
}
func (a *Adapter) getBool(ctx context.Context, schema, key string) (bool, error) {
raw, err := a.get(ctx, schema, key)
if err != nil {
return false, err
}
value, err := strconv.ParseBool(raw)
if err != nil {
return false, fmt.Errorf("parse %s %s boolean %q: %w", schema, key, raw, err)
}
return value, nil
}
func (a *Adapter) getStrings(ctx context.Context, schema, key string) ([]string, error) {
raw, err := a.get(ctx, schema, key)
if err != nil {
return nil, err
}
return parseStrings(raw)
}
func (a *Adapter) writeSettings(ctx context.Context, value settings) error {
// Keep manual mode from becoming active until all component values exist.
if err := a.set(ctx, proxySchema, "mode", quote("none")); err != nil {
return err
}
operations := []struct{ schema, key, value string }{
{proxySchema, "autoconfig-url", quote(value.AutoConfigURL)},
{proxySchema, "ignore-hosts", quoteStrings(value.IgnoreHosts)},
{httpSchema, "host", quote(value.HTTPHost)}, {httpSchema, "port", strconv.Itoa(value.HTTPPort)},
{httpSchema, "use-authentication", strconv.FormatBool(value.HTTPUseAuth)}, {httpSchema, "authentication-user", quote(value.HTTPAuthUser)},
{httpsSchema, "host", quote(value.HTTPSHost)}, {httpsSchema, "port", strconv.Itoa(value.HTTPSPort)},
{socksSchema, "host", quote(value.SocksHost)}, {socksSchema, "port", strconv.Itoa(value.SocksPort)},
}
for _, operation := range operations {
if err := a.set(ctx, operation.schema, operation.key, operation.value); err != nil {
return err
}
}
if err := a.setSensitiveString(ctx, httpSchema, "authentication-password", quote(value.HTTPAuthPassword)); err != nil {
return err
}
return a.set(ctx, proxySchema, "mode", quote(value.Mode))
}
func translate(input profile.Profile) (settings, error) {
p, err := profile.ValidateAndNormalize(input)
if err != nil {
return settings{}, err
}
result := settings{Mode: "manual", IgnoreHosts: []string{}}
if p.Mode == profile.SingleMode {
if result.SocksHost, result.SocksPort, _, _, err = endpoint(p.SingleURL, "Single proxy URL", "socks5", false); err != nil {
return settings{}, err
}
for _, bypass := range p.Bypass {
if strings.HasPrefix(bypass, ".") {
result.IgnoreHosts = append(result.IgnoreHosts, "*"+bypass)
} else {
result.IgnoreHosts = append(result.IgnoreHosts, bypass)
}
}
return result, nil
}
if result.HTTPHost, result.HTTPPort, result.HTTPAuthUser, result.HTTPAuthPassword, err = endpoint(p.HTTPURL, "HTTP proxy URL", "http", true); err != nil {
return settings{}, err
}
result.HTTPUseAuth = result.HTTPAuthUser != "" || result.HTTPAuthPassword != ""
if result.HTTPSHost, result.HTTPSPort, _, _, err = endpoint(p.HTTPSURL, "HTTPS proxy URL", "http", false); err != nil {
return settings{}, err
}
if result.SocksHost, result.SocksPort, _, _, err = endpoint(p.AllURL, "ALL/SOCKS proxy URL", "socks5", false); err != nil {
return settings{}, err
}
for _, bypass := range p.Bypass {
if strings.HasPrefix(bypass, ".") {
result.IgnoreHosts = append(result.IgnoreHosts, "*"+bypass)
continue
}
result.IgnoreHosts = append(result.IgnoreHosts, bypass)
}
return result, nil
}
func endpoint(raw, field, allowedScheme string, allowCredentials bool) (string, int, string, string, error) {
if raw == "" {
return "", 0, "", "", nil
}
u, err := url.Parse(raw)
if err != nil {
return "", 0, "", "", fmt.Errorf("%s is malformed", field)
}
if u.Scheme != allowedScheme {
if u.Scheme == "socks5h" {
return "", 0, "", "", fmt.Errorf("%s uses socks5h remote-DNS semantics that GNOME settings cannot preserve", field)
}
return "", 0, "", "", fmt.Errorf("%s scheme %q cannot be represented without changing semantics; GNOME requires %s", field, u.Scheme, allowedScheme)
}
username, password := "", ""
if u.User != nil {
if !allowCredentials {
return "", 0, "", "", fmt.Errorf("%s credentials cannot be represented safely by the GNOME Target", field)
}
username = u.User.Username()
password, _ = u.User.Password()
}
port, _ := strconv.Atoi(u.Port())
return u.Hostname(), port, username, password, nil
}
func fingerprint(value settings) string {
data, _ := json.Marshal(value)
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func quote(value string) string {
delimiter := byte('\'')
if strings.ContainsRune(value, '\'') {
// This matches GVariant's canonical printer: apostrophes select a
// double-quoted literal rather than an escaped single-quoted one.
delimiter = '"'
}
var b strings.Builder
b.WriteByte(delimiter)
for _, r := range value {
switch r {
case '\a':
b.WriteString(`\a`)
case '\b':
b.WriteString(`\b`)
case '\f':
b.WriteString(`\f`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
case '\v':
b.WriteString(`\v`)
case '\\':
b.WriteString(`\\`)
default:
if r == rune(delimiter) {
b.WriteByte('\\')
}
b.WriteRune(r)
}
}
b.WriteByte(delimiter)
return b.String()
}
func quoteStrings(values []string) string {
quoted := make([]string, len(values))
for i, v := range values {
quoted[i] = quote(v)
}
return "[" + strings.Join(quoted, ", ") + "]"
}
func parseString(raw string) (string, error) {
raw = strings.TrimSpace(raw)
value, consumed, err := parseQuoted(raw)
if err != nil || consumed != len(raw) {
return "", fmt.Errorf("invalid gsettings string %q", raw)
}
return value, nil
}
func parseQuoted(raw string) (string, int, error) {
if len(raw) < 2 || (raw[0] != '\'' && raw[0] != '"') {
return "", 0, errors.New("missing GVariant string delimiter")
}
delimiter := raw[0]
var b strings.Builder
for i := 1; i < len(raw); {
if raw[i] == delimiter {
return b.String(), i + 1, nil
}
if raw[i] != '\\' {
r, size := utf8.DecodeRuneInString(raw[i:])
if r == utf8.RuneError && size == 1 {
return "", 0, errors.New("invalid UTF-8 in GVariant string")
}
b.WriteRune(r)
i += size
continue
}
i++
if i >= len(raw) {
return "", 0, errors.New("trailing GVariant escape")
}
switch raw[i] {
case 'a':
b.WriteByte('\a')
case 'b':
b.WriteByte('\b')
case 'f':
b.WriteByte('\f')
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 't':
b.WriteByte('\t')
case 'v':
b.WriteByte('\v')
case '\\', '\'', '"':
b.WriteByte(raw[i])
case 'u', 'U':
digits := 4
if raw[i] == 'U' {
digits = 8
}
if i+digits >= len(raw) {
return "", 0, errors.New("short GVariant Unicode escape")
}
code, err := strconv.ParseUint(raw[i+1:i+1+digits], 16, 32)
if err != nil || !utf8.ValidRune(rune(code)) {
return "", 0, errors.New("invalid GVariant Unicode escape")
}
b.WriteRune(rune(code))
i += digits
default:
return "", 0, errors.New("unsupported GVariant escape")
}
i++
}
return "", 0, errors.New("unterminated GVariant string")
}
func parseStrings(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
// GVariant prints an empty string array with its otherwise-inferable type
// annotation ("@as []") on some gsettings versions.
raw = strings.TrimSpace(strings.TrimPrefix(raw, "@as"))
if len(raw) < 2 || raw[0] != '[' || raw[len(raw)-1] != ']' {
return nil, fmt.Errorf("invalid gsettings string array %q", raw)
}
inner := strings.TrimSpace(raw[1 : len(raw)-1])
if inner == "" {
return []string{}, nil
}
var result []string
for {
value, consumed, err := parseQuoted(inner)
if err != nil {
return nil, fmt.Errorf("invalid gsettings string array %q", raw)
}
result = append(result, value)
inner = strings.TrimSpace(inner[consumed:])
if inner == "" {
return result, nil
}
if inner[0] != ',' {
return nil, fmt.Errorf("invalid gsettings string array %q", raw)
}
inner = strings.TrimSpace(inner[1:])
if inner == "" {
return nil, fmt.Errorf("invalid gsettings string array %q", raw)
}
}
}
func redactForProfile(err error, active *profile.Profile) error {
if err == nil {
return nil
}
message := err.Error()
if active != nil {
for _, raw := range []string{active.SingleURL, active.HTTPURL, active.HTTPSURL, active.AllURL} {
parsed, parseErr := url.Parse(raw)
if parseErr != nil || parsed.User == nil {
continue
}
if password, present := parsed.User.Password(); present && password != "" {
message = strings.ReplaceAll(message, password, "REDACTED")
}
}
}
return safeRedaction(profile.RedactText(message), err)
}
func redact(err error) error {
if err == nil {
return nil
}
return safeRedaction(profile.RedactText(err.Error()), err)
}
func safeRedaction(message string, original error) error {
var class error
for _, candidate := range []error{os.ErrPermission, fs.ErrNotExist, context.Canceled, context.DeadlineExceeded} {
if errors.Is(original, candidate) {
class = candidate
break
}
}
return &safeError{message: message, class: class}
}
type safeError struct {
message string
class error
}
func (e *safeError) Error() string { return e.message }
func (e *safeError) Is(target error) bool { return e.class != nil && errors.Is(e.class, target) }