| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "net" |
| 6 | "strconv" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // RemoteConfig is the [remote] section: SSH hosts the remote module may |
| 11 | // connect to, and their default forwards/workspaces. Like [secrets] it is a |
| 12 | // user-global security control — LoadForRoot pins it back to the user config |
| 13 | // after the project merge so a cloned repo's reasonix.toml can never inject |
| 14 | // hosts, jump chains, or forwards. |
| 15 | type RemoteConfig struct { |
| 16 | // ImportSSHConfig surfaces ~/.ssh/config aliases in `reasonix remote import`. |
| 17 | ImportSSHConfig bool `toml:"import_ssh_config"` |
| 18 | Hosts []RemoteHostEntry `toml:"hosts"` |
| 19 | } |
| 20 | |
| 21 | // RemoteHostEntry describes one SSH target. Secrets follow the provider |
| 22 | // idiom: the entry names credential env vars (passphrase_env/password_env); |
| 23 | // values live in Reasonix's global .env, never in TOML. identity_file is a |
| 24 | // path — private key material itself is never stored by Reasonix. |
| 25 | type RemoteHostEntry struct { |
| 26 | Name string `toml:"name"` |
| 27 | Host string `toml:"host"` |
| 28 | Port int `toml:"port"` // 0 => 22 (or ssh_config value) |
| 29 | User string `toml:"user"` |
| 30 | IdentityFile string `toml:"identity_file"` |
| 31 | PassphraseEnv string `toml:"passphrase_env"` |
| 32 | PasswordEnv string `toml:"password_env"` |
| 33 | ProxyJump string `toml:"proxy_jump"` // OpenSSH ProxyJump syntax, comma-separated chain |
| 34 | Workspace string `toml:"workspace"` // default remote workspace dir |
| 35 | ServeInstall string `toml:"serve_install"` // remote CLI: auto|npm|upload|never |
| 36 | UseSSHConfig bool `toml:"use_ssh_config"` // layer ~/.ssh/config values under unset fields |
| 37 | Forwards []RemoteForwardEntry `toml:"forwards"` |
| 38 | } |
| 39 | |
| 40 | // RemoteForwardEntry is a persisted port-forward rule applied on connect. |
| 41 | type RemoteForwardEntry struct { |
| 42 | Type string `toml:"type"` // "local" (-L) | "remote" (-R) |
| 43 | Bind string `toml:"bind"` // "127.0.0.1:8080" or bare port => 127.0.0.1:<port> |
| 44 | Target string `toml:"target"` // host:port on the other side |
| 45 | } |
| 46 | |
| 47 | // RemoteServeInstallModes are the accepted serve_install values. |
| 48 | var RemoteServeInstallModes = []string{"auto", "npm", "upload", "never"} |
| 49 | |
| 50 | // Clone returns a deep copy. The global-only pin in loadForRoot must capture |
| 51 | // the pre-project-merge value, but TOML decoding mutates existing slice |
| 52 | // backing arrays in place — a shallow struct copy would alias Hosts (and each |
| 53 | // host's Forwards) and let a project reasonix.toml overwrite the "restored" |
| 54 | // global entries. |
| 55 | func (r RemoteConfig) Clone() RemoteConfig { |
| 56 | out := r |
| 57 | if r.Hosts != nil { |
| 58 | out.Hosts = make([]RemoteHostEntry, len(r.Hosts)) |
| 59 | for i, h := range r.Hosts { |
| 60 | h.Forwards = append([]RemoteForwardEntry(nil), h.Forwards...) |
| 61 | out.Hosts[i] = h |
| 62 | } |
| 63 | } |
| 64 | return out |
| 65 | } |
| 66 | |
| 67 | // ServeInstallMode returns the normalized install strategy, defaulting to auto. |
| 68 | func (e RemoteHostEntry) ServeInstallMode() string { |
| 69 | m := strings.ToLower(strings.TrimSpace(e.ServeInstall)) |
| 70 | if m == "" { |
| 71 | return "auto" |
| 72 | } |
| 73 | return m |
| 74 | } |
| 75 | |
| 76 | // PortOrDefault returns the configured port, defaulting to 22. |
| 77 | func (e RemoteHostEntry) PortOrDefault() int { |
| 78 | if e.Port > 0 { |
| 79 | return e.Port |
| 80 | } |
| 81 | return 22 |
| 82 | } |
| 83 | |
| 84 | func validateRemoteHost(e RemoteHostEntry) error { |
| 85 | if strings.TrimSpace(e.Name) == "" { |
| 86 | return fmt.Errorf("remote host: name is required") |
| 87 | } |
| 88 | if strings.ContainsAny(e.Name, " \t/:@") { |
| 89 | return fmt.Errorf("remote host %q: name must not contain spaces, '/', ':' or '@'", e.Name) |
| 90 | } |
| 91 | if strings.TrimSpace(e.Host) == "" { |
| 92 | return fmt.Errorf("remote host %q: host is required", e.Name) |
| 93 | } |
| 94 | if e.Port < 0 || e.Port > 65535 { |
| 95 | return fmt.Errorf("remote host %q: port %d out of range", e.Name, e.Port) |
| 96 | } |
| 97 | switch e.ServeInstallMode() { |
| 98 | case "auto", "npm", "upload", "never": |
| 99 | default: |
| 100 | return fmt.Errorf("remote host %q: serve_install must be one of auto|npm|upload|never", e.Name) |
| 101 | } |
| 102 | seenBinds := map[string]bool{} |
| 103 | for _, f := range e.Forwards { |
| 104 | kind := strings.ToLower(strings.TrimSpace(f.Type)) |
| 105 | switch kind { |
| 106 | case "local", "remote": |
| 107 | default: |
| 108 | return fmt.Errorf("remote host %q: forward type must be \"local\" or \"remote\"", e.Name) |
| 109 | } |
| 110 | if strings.TrimSpace(f.Bind) == "" || strings.TrimSpace(f.Target) == "" { |
| 111 | return fmt.Errorf("remote host %q: forward needs both bind and target", e.Name) |
| 112 | } |
| 113 | bind, err := validateRemoteForwardAddress(f.Bind, true) |
| 114 | if err != nil { |
| 115 | return fmt.Errorf("remote host %q: invalid forward bind %q: %w", e.Name, f.Bind, err) |
| 116 | } |
| 117 | if _, err := validateRemoteForwardAddress(f.Target, false); err != nil { |
| 118 | return fmt.Errorf("remote host %q: invalid forward target %q: %w", e.Name, f.Target, err) |
| 119 | } |
| 120 | key := kind + "\x00" + bind |
| 121 | if seenBinds[key] { |
| 122 | return fmt.Errorf("remote host %q: duplicate %s forward bind %q", e.Name, kind, f.Bind) |
| 123 | } |
| 124 | seenBinds[key] = true |
| 125 | } |
| 126 | return nil |
| 127 | } |
| 128 | |
| 129 | func validateRemoteForwardAddress(addr string, bind bool) (string, error) { |
| 130 | addr = strings.TrimSpace(addr) |
| 131 | if bind && !strings.Contains(addr, ":") { |
| 132 | addr = net.JoinHostPort("127.0.0.1", addr) |
| 133 | } |
| 134 | host, portText, err := net.SplitHostPort(addr) |
| 135 | if err != nil { |
| 136 | return "", err |
| 137 | } |
| 138 | if !bind && strings.TrimSpace(host) == "" { |
| 139 | return "", fmt.Errorf("host is required") |
| 140 | } |
| 141 | port, err := strconv.Atoi(portText) |
| 142 | if err != nil || port < 0 || port > 65535 || (!bind && port == 0) { |
| 143 | return "", fmt.Errorf("port out of range") |
| 144 | } |
| 145 | return net.JoinHostPort(host, strconv.Itoa(port)), nil |
| 146 | } |
| 147 | |
| 148 | // RemoteHost looks up a configured host by name. |
| 149 | func (c *Config) RemoteHost(name string) (RemoteHostEntry, bool) { |
| 150 | for _, h := range c.Remote.Hosts { |
| 151 | if h.Name == name { |
| 152 | return h, true |
| 153 | } |
| 154 | } |
| 155 | return RemoteHostEntry{}, false |
| 156 | } |
| 157 | |
| 158 | // UpsertRemoteHost adds e, or replaces the host with the same name |
| 159 | // (preserving position). Mirrors UpsertPlugin. |
| 160 | func (c *Config) UpsertRemoteHost(e RemoteHostEntry) error { |
| 161 | if err := validateRemoteHost(e); err != nil { |
| 162 | return err |
| 163 | } |
| 164 | for i := range c.Remote.Hosts { |
| 165 | if c.Remote.Hosts[i].Name == e.Name { |
| 166 | c.Remote.Hosts[i] = e |
| 167 | return nil |
| 168 | } |
| 169 | } |
| 170 | c.Remote.Hosts = append(c.Remote.Hosts, e) |
| 171 | return nil |
| 172 | } |
| 173 | |
| 174 | // RemoveRemoteHost deletes the named host, reporting whether it was present. |
| 175 | func (c *Config) RemoveRemoteHost(name string) bool { |
| 176 | for i := range c.Remote.Hosts { |
| 177 | if c.Remote.Hosts[i].Name == name { |
| 178 | c.Remote.Hosts = append(c.Remote.Hosts[:i], c.Remote.Hosts[i+1:]...) |
| 179 | return true |
| 180 | } |
| 181 | } |
| 182 | return false |
| 183 | } |
| 184 |