// Package shell implements the Bash and Zsh Shell Target. package shell import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io/fs" "net/url" "os" "path/filepath" "strings" "proxyctl/internal/profile" "proxyctl/internal/target" ) const ( integrationStart = "# >>> proxyctl shell integration >>>" integrationEnd = "# <<< proxyctl shell integration <<<" ) 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 } // 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") } 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, }, 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{} func (OSFileSystem) ReadFile(path string) ([]byte, error) { info, err := os.Lstat(path) if err != nil { return nil, err } if !info.Mode().IsRegular() { return nil, fmt.Errorf("%s is not a regular file", path) } return os.ReadFile(path) } func (OSFileSystem) Remove(path string) error { return os.Remove(path) } func (OSFileSystem) FileMode(path string) (os.FileMode, error) { info, err := os.Lstat(path) if err != nil { return 0, err } if !info.Mode().IsRegular() { return 0, fmt.Errorf("%s is not a regular file", path) } return info.Mode().Perm(), nil } func (OSFileSystem) AtomicWrite(path string, data []byte, mode os.FileMode, owner *Ownership) error { if !filepath.IsAbs(path) { return errors.New("Shell Target path must be absolute") } directory := filepath.Dir(path) var missing []string for current := directory; ; current = filepath.Dir(current) { if _, err := os.Stat(current); err == nil { break } else if !errors.Is(err, fs.ErrNotExist) { return err } missing = append(missing, current) if filepath.Dir(current) == current { break } } if err := os.MkdirAll(directory, 0o700); err != nil { return err } for i := len(missing) - 1; i >= 0; i-- { if owner != nil && (owner.UID != os.Geteuid() || owner.GID != os.Getegid()) { if err := os.Chown(missing[i], owner.UID, owner.GID); err != nil { return err } } if err := os.Chmod(missing[i], 0o700); err != nil { return err } } var temporary string for range 100 { bytes := make([]byte, 8) if _, err := rand.Read(bytes); err != nil { return err } temporary = filepath.Join(directory, ".proxyctl-"+hex.EncodeToString(bytes)) file, err := os.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) if errors.Is(err, fs.ErrExist) { continue } if err != nil { return err } ok := false defer func() { if !ok { _ = os.Remove(temporary) } }() if owner != nil && (owner.UID != os.Geteuid() || owner.GID != os.Getegid()) { if err = file.Chown(owner.UID, owner.GID); err != nil { _ = file.Close() return err } } if _, err = file.Write(data); err == nil { err = file.Sync() } if closeErr := file.Close(); err == nil { err = closeErr } if err != nil { return err } if err = os.Rename(temporary, path); err != nil { return err } ok = true dir, err := os.Open(directory) if err != nil { return err } err = dir.Sync() closeErr := dir.Close() if err != nil { return err } return closeErr } return errors.New("allocate temporary Shell Target file") } type recovery struct { Original map[string]string `json:"original,omitempty"` 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 { files = OSFileSystem{} } 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 Bash and Zsh commands.", Details: "Affects commands started by the integrated Shell; it does not configure system services. Installation updates the user's Shell startup file and requires no privilege escalation."} } 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) EnvironmentPath() string { return a.configuration.EnvironmentFile } func (a *Adapter) RecoveryPath() string { return a.configuration.RecoveryFile } func (a *Adapter) StartupPath() string { return a.configuration.StartupFile } func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection { 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"} } if generatedErr != nil { return target.Inspection{State: target.Drifted, Evidence: "managed Shell environment is missing or unreadable"} } 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"} } return target.Inspection{State: target.Drifted, Evidence: "managed Shell environment does not match the Active Profile"} } if generatedErr == nil && recognizesProxy(generated) { return target.Inspection{State: target.External, Evidence: "recognized proxy assignments exist in the Shell environment file"} } 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"} } } return target.Inspection{State: target.Disabled, Evidence: "no recognized Shell proxy configuration detected"} } func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.Profile) target.OperationResult { if !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 { 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) if err := a.writeOwned(a.configuration.EnvironmentFile, generated); err != nil { return fmt.Errorf("write Shell environment: %w", err) } data, err := json.MarshalIndent(metadata, "", " ") if err != nil { return err } if err := a.writeOwned(a.configuration.RecoveryFile, append(data, '\n')); err != nil { return fmt.Errorf("write Shell recovery metadata after environment changed: %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 value recovery if err := json.Unmarshal(data, &value); err != nil { return recovery{}, true, err } return value, true, 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 { text := string(data) for _, name := range proxyVariables { if strings.Contains(text, "export "+name+"=") || strings.Contains(text, name+"=") { return true } } return false } type redactedError struct { message string cause error } func (e *redactedError) Error() string { return e.message } func (e *redactedError) Unwrap() error { return e.cause } func redactError(err error, active *profile.Profile) error { if err == nil { return nil } message := profile.RedactText(err.Error()) if active == nil { return &redactedError{message: message, cause: err} } 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, cause: err} } 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" + "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:] } }