// Package shell implements the Bash and Zsh Shell Target. package shell import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io/fs" "net/url" "os" "path/filepath" "strings" "proxyctl/internal/profile" "proxyctl/internal/securefs" "proxyctl/internal/target" ) const ( integrationStart = "# >>> proxyctl shell integration >>>" integrationEnd = "# <<< proxyctl shell integration <<<" shellRecoveryVersion = 1 ) var proxyVariables = []string{ "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy", } type Configuration struct { Shell string StartupFile string EnvironmentFile string RecoveryFile string OwnFiles bool UID int GID int TrustedRoots []string } // ResolveConfiguration keeps user Shell effects with the invoking user under sudo. 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) } invokingUser := current.Username != owner.Username configDirectory := filepath.Dir(profileConfiguration.Path) stateDirectory := "" if value, ok := environment.LookupEnv("XDG_STATE_HOME"); ok && filepath.IsAbs(value) { if !invokingUser || pathWithin(value, owner.HomeDir) { stateDirectory = value } } if stateDirectory == "" { stateDirectory = filepath.Join(owner.HomeDir, ".local", "state") } shellPath, _ := environment.LookupEnv("SHELL") startupFile := "" switch filepath.Base(shellPath) { case "bash": startupFile = filepath.Join(owner.HomeDir, ".bashrc") case "zsh": startupFile = filepath.Join(owner.HomeDir, ".zshrc") } stateRoot := owner.HomeDir if !pathWithin(stateDirectory, owner.HomeDir) { stateRoot = stateDirectory } return Configuration{ Shell: shellPath, StartupFile: startupFile, EnvironmentFile: filepath.Join(configDirectory, "shell.env"), RecoveryFile: filepath.Join(stateDirectory, "proxyctl", "shell.json"), OwnFiles: true, UID: owner.UID, GID: owner.GID, TrustedRoots: []string{owner.HomeDir, profileConfiguration.TrustedRoot, stateRoot}, }, 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 Environment interface{ LookupEnv(string) (string, bool) } type MapEnvironment map[string]string func (m MapEnvironment) LookupEnv(name string) (string, bool) { value, ok := m[name]; return value, ok } type Ownership struct{ UID, GID int } type FileSystem interface { ReadFile(string) ([]byte, error) FileMode(string) (os.FileMode, error) AtomicWrite(string, []byte, os.FileMode, *Ownership) error Remove(string) error } type OSFileSystem struct{ TrustedRoots []string } func NewOSFileSystem(configuration Configuration) OSFileSystem { return OSFileSystem{TrustedRoots: append([]string(nil), configuration.TrustedRoots...)} } func (f OSFileSystem) store(path string, owner *Ownership) securefs.Store { root := "" if len(f.TrustedRoots) == 0 { root = string(filepath.Separator) } for _, candidate := range f.TrustedRoots { if pathWithin(path, candidate) && (root == "" || len(filepath.Clean(candidate)) > len(filepath.Clean(root))) { root = candidate } } var secureOwner *securefs.Owner if owner != nil { secureOwner = &securefs.Owner{UID: owner.UID, GID: owner.GID} } return securefs.Store{TrustedRoot: root, Owner: secureOwner} } func (f OSFileSystem) ReadFile(path string) ([]byte, error) { return f.store(path, nil).ReadFile(path) } func (f OSFileSystem) Remove(path string) error { return f.store(path, nil).Remove(path) } func (f OSFileSystem) FileMode(path string) (os.FileMode, error) { return f.store(path, nil).FileMode(path) } func (f OSFileSystem) AtomicWrite(path string, data []byte, mode os.FileMode, owner *Ownership) error { return f.store(path, owner).AtomicWrite(path, data, mode) } type recovery struct { Version int `json:"version"` Original map[string]string `json:"original"` OriginalScript *string `json:"originalScript,omitempty"` Fingerprint string `json:"fingerprint"` } type Adapter struct { configuration Configuration files FileSystem environment Environment } func New(configuration Configuration, files FileSystem, environment Environment) *Adapter { if files == nil { configured := NewOSFileSystem(configuration) files = configured } if environment == nil { environment = MapEnvironment{} } return &Adapter{configuration: configuration, files: files, environment: environment} } func (a *Adapter) ID() string { return "shell" } func (a *Adapter) Description() target.Description { return target.Description{ Name: "Shell", Summary: "Exports proxy variables for commands started by integrated Bash and Zsh sessions.", Scope: "future commands in the integrated Shell; not services, desktop settings, or existing processes", Permissions: "write access to the user's Shell files", Ownership: "generated environment fragment; the one-time startup hook remains when management is disabled", RestartRisk: "none; the installed wrapper reloads exports into the parent Shell after proxyctl exits", Details: "Affects commands started by the integrated Shell; it does not configure system services, desktop settings, or already-running processes. proxyctl owns a generated environment fragment while its one-time startup hook remains in the user's Shell file. User-file write permission is required; after proxyctl exits, the installed wrapper reloads exports and unsets into the parent Shell.", } } func (a *Adapter) Availability(context.Context) target.Availability { switch filepath.Base(a.configuration.Shell) { case "bash": return target.Availability{Available: true, Evidence: "Bash detected"} case "zsh": return target.Availability{Available: true, Evidence: "Zsh detected"} default: return target.Availability{Evidence: "Shell Target requires Bash or Zsh; neither was detected"} } } func (a *Adapter) Confirmation(bool) string { return "" } func (a *Adapter) EnvironmentPath() string { return a.configuration.EnvironmentFile } func (a *Adapter) RecoveryPath() string { return a.configuration.RecoveryFile } func (a *Adapter) StartupPath() string { return a.configuration.StartupFile } // DiagnosticChecks performs no writes or command execution. func (a *Adapter) DiagnosticChecks(_ context.Context, _ *profile.Profile) []target.Check { return []target.Check{ {Name: "required commands", OK: true, Evidence: "Shell Target requires no external commands"}, target.ReadOnlyWriteAuthority(a.configuration.EnvironmentFile, a.configuration.RecoveryFile), } } func (a *Adapter) Detect(ctx context.Context, active *profile.Profile) target.Detection { inspection, err := a.inspect(active) return target.Detection{Availability: a.Availability(ctx), Inspection: inspection, Err: redactError(err, active)} } // Inspect retains the maintenance contract: it always returns the safest // state supported by the evidence. Detect adds the suppressed read error so an // interactive check cannot present unreadable state as a successful result. func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection { inspection, _ := a.inspect(active) return inspection } func (a *Adapter) inspect(active *profile.Profile) (target.Inspection, error) { metadata, managed, metadataErr := a.readRecovery() generated, generatedErr := a.files.ReadFile(a.configuration.EnvironmentFile) if managed || (metadataErr != nil && !errors.Is(metadataErr, fs.ErrNotExist)) { if metadataErr != nil { return target.Inspection{State: target.Drifted, Evidence: "managed Shell recovery metadata cannot be read"}, fmt.Errorf("inspect Shell recovery metadata: %w", metadataErr) } if generatedErr != nil { return target.Inspection{State: target.Drifted, Evidence: "managed Shell environment is missing or unreadable"}, fmt.Errorf("inspect managed Shell environment: %w", generatedErr) } if active != nil && fingerprint(generated) == metadata.Fingerprint && fingerprint(renderEnabled(*active)) == metadata.Fingerprint { return target.Inspection{State: target.Enabled, Evidence: "managed Shell environment matches the Active Profile"}, nil } return target.Inspection{State: target.Drifted, Evidence: "managed Shell environment does not match the Active Profile"}, nil } if generatedErr != nil && !errors.Is(generatedErr, fs.ErrNotExist) { return target.Inspection{State: target.Disabled, Evidence: "Shell environment could not be inspected"}, fmt.Errorf("inspect Shell environment: %w", generatedErr) } if generatedErr == nil && recognizesProxy(generated) { return target.Inspection{State: target.External, Evidence: "recognized proxy assignments exist in the Shell environment file"}, nil } for _, name := range proxyVariables { if value, ok := a.environment.LookupEnv(name); ok && value != "" { return target.Inspection{State: target.External, Evidence: "recognized proxy variable " + name + " exists"}, nil } } return target.Inspection{State: target.Disabled, Evidence: "no recognized Shell proxy configuration detected"}, nil } func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.Profile) target.OperationResult { // Enabling requires a supported current Shell, but disabling must remain // available so reset can remove managed state after the user's Shell changes. if enabled && !a.Availability(ctx).Available { return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("Shell Target is unavailable: Bash or Zsh was not detected")} } 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")} } var err error if enabled { err = a.enable(*active) } else { err = a.disable() } inspection := a.Inspect(ctx, active) if err != nil { err = redactError(err, active) } return target.OperationResult{Inspection: inspection, Err: err} } func (a *Adapter) enable(active profile.Profile) error { normalized, err := profile.ValidateAndNormalize(active) if err != nil { return err } metadata, managed, err := a.readRecovery() if err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("read Shell recovery metadata: %w", err) } if !managed { metadata.Version = shellRecoveryVersion if existing, readErr := a.files.ReadFile(a.configuration.EnvironmentFile); readErr == nil && recognizesProxy(existing) { original := string(existing) metadata.OriginalScript = &original } else { metadata.Original = make(map[string]string) for _, name := range proxyVariables { if value, ok := a.environment.LookupEnv(name); ok { metadata.Original[name] = value } } } } generated := renderEnabled(normalized) metadata.Fingerprint = fingerprint(generated) data, err := json.MarshalIndent(metadata, "", " ") if err != nil { return err } // The true original must be durable before the managed environment becomes // visible. A retry then reuses this metadata instead of snapshotting its own // partially applied output as External state. if err := a.writeOwned(a.configuration.RecoveryFile, append(data, '\n')); err != nil { return fmt.Errorf("persist Shell recovery metadata before changing environment: %w", err) } if err := a.writeOwned(a.configuration.EnvironmentFile, generated); err != nil { return fmt.Errorf("write Shell environment after recovery metadata persisted: %w", err) } return nil } func (a *Adapter) disable() error { metadata, managed, err := a.readRecovery() if errors.Is(err, fs.ErrNotExist) || !managed { return nil } if err != nil { return fmt.Errorf("read Shell recovery metadata: %w", err) } restored := renderRestored(metadata.Original) if metadata.OriginalScript != nil { restored = []byte(*metadata.OriginalScript) } if err := a.writeOwned(a.configuration.EnvironmentFile, restored); err != nil { return fmt.Errorf("restore Shell environment: %w", err) } if err := a.files.Remove(a.configuration.RecoveryFile); err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("remove Shell recovery metadata after environment changed: %w", err) } return nil } func (a *Adapter) readRecovery() (recovery, bool, error) { data, err := a.files.ReadFile(a.configuration.RecoveryFile) 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("Shell recovery metadata must be a JSON object") } var value recovery if err := json.Unmarshal(data, &value); err != nil { return recovery{}, true, err } if err := validateRecovery(value, fields); err != nil { return recovery{}, true, err } return value, true, nil } func validateRecovery(value recovery, fields map[string]json.RawMessage) error { for _, name := range []string{"version", "original", "fingerprint"} { if _, present := fields[name]; !present { return fmt.Errorf("Shell recovery metadata is missing required field %q", name) } } if value.Version != shellRecoveryVersion { return fmt.Errorf("unsupported Shell recovery metadata version %d", value.Version) } decoded, err := hex.DecodeString(value.Fingerprint) if err != nil || len(decoded) != sha256.Size { return errors.New("Shell recovery metadata has an invalid fingerprint") } _, hasOriginalScript := fields["originalScript"] if hasOriginalScript { if value.OriginalScript == nil || value.Original != nil || len(*value.OriginalScript) == 0 || !recognizesProxy([]byte(*value.OriginalScript)) { return errors.New("Shell recovery metadata has inconsistent original script state") } return nil } if value.Original == nil { return errors.New("Shell recovery metadata is missing the original environment snapshot") } for name := range value.Original { known := false for _, candidate := range proxyVariables { known = known || name == candidate } if !known { return fmt.Errorf("Shell recovery metadata contains unsupported variable %q", name) } } return nil } func (a *Adapter) ownership() *Ownership { if !a.configuration.OwnFiles { return nil } return &Ownership{UID: a.configuration.UID, GID: a.configuration.GID} } func (a *Adapter) writeOwned(path string, data []byte) error { return a.files.AtomicWrite(path, data, 0o600, a.ownership()) } func renderEnabled(p profile.Profile) []byte { values := map[string]string{"HTTP_PROXY": p.HTTPURL, "http_proxy": p.HTTPURL, "HTTPS_PROXY": p.HTTPSURL, "https_proxy": p.HTTPSURL, "ALL_PROXY": p.AllURL, "all_proxy": p.AllURL, "NO_PROXY": strings.Join(p.Bypass, ","), "no_proxy": strings.Join(p.Bypass, ",")} var output strings.Builder output.WriteString("# Generated by proxyctl. Do not edit.\n") for _, name := range proxyVariables { if value := values[name]; value != "" { fmt.Fprintf(&output, "export %s=%s\n", name, shellQuote(value)) } else { fmt.Fprintf(&output, "unset %s\n", name) } } return []byte(output.String()) } func renderRestored(original map[string]string) []byte { var output strings.Builder output.WriteString("# Generated by proxyctl. Restores the environment present before enable.\n") var unset []string for _, name := range proxyVariables { if value, ok := original[name]; ok { fmt.Fprintf(&output, "export %s=%s\n", name, shellQuote(value)) } else { unset = append(unset, name) } } if len(unset) > 0 { fmt.Fprintf(&output, "unset %s\n", strings.Join(unset, " ")) } return []byte(output.String()) } func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } func fingerprint(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) } func recognizesProxy(data []byte) bool { supported := make(map[string]bool, len(proxyVariables)) for _, name := range proxyVariables { supported[name] = true } for _, raw := range strings.Split(string(data), "\n") { line := strings.TrimSpace(raw) if line == "" || strings.HasPrefix(line, "#") { continue } if strings.HasPrefix(line, "export") { if len(line) == len("export") || (line[len("export")] != ' ' && line[len("export")] != '\t') { continue } line = strings.TrimSpace(line[len("export"):]) } name, _, found := strings.Cut(line, "=") if !found || strings.TrimSpace(name) != name || !supported[name] { continue } return true } return false } type redactedError struct { message string class error } func (e *redactedError) Error() string { return e.message } func (e *redactedError) Is(target error) bool { return e.class != nil && errors.Is(e.class, target) } func redactError(err error, active *profile.Profile) error { if err == nil { return nil } message := profile.RedactText(err.Error()) class := classifySafeError(err) if active == nil { return &redactedError{message: message, class: class} } for _, raw := range []string{active.SingleURL, active.HTTPURL, active.HTTPSURL, active.AllURL} { if raw == "" { continue } message = strings.ReplaceAll(message, raw, profile.ObscureURL(raw)) if parsed, parseErr := url.Parse(raw); parseErr == nil && parsed.User != nil { if password, ok := parsed.User.Password(); ok && password != "" { message = strings.ReplaceAll(message, password, "REDACTED") } } } return &redactedError{message: message, class: class} } func classifySafeError(err error) error { for _, safe := range []error{os.ErrPermission, fs.ErrNotExist, fs.ErrExist, context.Canceled, context.DeadlineExceeded} { if errors.Is(err, safe) { return safe } } return nil } func (a *Adapter) Install(_ context.Context, executable string) error { contents, err := a.files.ReadFile(a.configuration.StartupFile) if err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("read Shell startup file: %w", err) } cleaned := removeIntegration(string(contents)) if strings.Contains(cleaned, integrationStart) || strings.Contains(cleaned, integrationEnd) { return errors.New("Shell startup file contains malformed proxyctl integration markers; no changes made") } if cleaned != "" && !strings.HasSuffix(cleaned, "\n") { cleaned += "\n" } block := integrationStart + "\n" + "[ -r " + shellQuote(a.configuration.EnvironmentFile) + " ] && . " + shellQuote(a.configuration.EnvironmentFile) + "\n" + "proxyctl() {\n" + " command " + shellQuote(executable) + " \"$@\"\n" + " proxyctl_status=$?\n" + " [ -r " + shellQuote(a.configuration.EnvironmentFile) + " ] && . " + shellQuote(a.configuration.EnvironmentFile) + "\n" + " return $proxyctl_status\n" + "}\n" + integrationEnd + "\n" mode := os.FileMode(0o644) if existingMode, modeErr := a.files.FileMode(a.configuration.StartupFile); modeErr == nil { mode = existingMode } if err := a.files.AtomicWrite(a.configuration.StartupFile, []byte(cleaned+block), mode, a.ownership()); err != nil { return fmt.Errorf("install Shell integration: %w", err) } return nil } func (a *Adapter) Uninstall(_ context.Context) error { contents, err := a.files.ReadFile(a.configuration.StartupFile) if errors.Is(err, fs.ErrNotExist) { return nil } if err != nil { return fmt.Errorf("read Shell startup file: %w", err) } cleaned := removeIntegration(string(contents)) if strings.Contains(cleaned, integrationStart) || strings.Contains(cleaned, integrationEnd) { return errors.New("Shell startup file contains malformed proxyctl integration markers; no changes made") } if cleaned == string(contents) { return nil } mode := os.FileMode(0o644) if existingMode, modeErr := a.files.FileMode(a.configuration.StartupFile); modeErr == nil { mode = existingMode } if err := a.files.AtomicWrite(a.configuration.StartupFile, []byte(cleaned), mode, a.ownership()); err != nil { return fmt.Errorf("uninstall Shell integration: %w", err) } return nil } func removeIntegration(contents string) string { for { start := strings.Index(contents, integrationStart) if start < 0 { return contents } endRelative := strings.Index(contents[start:], integrationEnd) if endRelative < 0 { return contents } end := start + endRelative + len(integrationEnd) if end < len(contents) && contents[end] == '\n' { end++ } contents = contents[:start] + contents[end:] } }