984 lines
35 KiB
Go
984 lines
35 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/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 ResolveConfigurationForCurrentProcess() (Configuration, error) {
|
|
return ResolveConfiguration(systemEnvironment{})
|
|
}
|
|
|
|
type systemEnvironment struct{}
|
|
|
|
func (systemEnvironment) CurrentUser() (profile.UserIdentity, error) {
|
|
return profile.SystemCurrentUser()
|
|
}
|
|
func (systemEnvironment) LookupUser(name string) (profile.UserIdentity, error) {
|
|
return profile.SystemLookupUser(name)
|
|
}
|
|
func (systemEnvironment) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) }
|
|
|
|
func pathWithin(path, parent string) bool {
|
|
relative, err := filepath.Rel(parent, path)
|
|
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
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) }
|