Skip to main content

ssh_cli/
constants.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! Named domain constants (Rules Rust — no hardcoding).
5//!
6//! Compile-time values with semantic names live here. Runtime host credentials,
7//! per-VPS timeouts, and primary-key material are **never** hardcoded: they are
8//! loaded once from CLI / OS keyring / XDG files under
9//! [`crate::constants::APP_NAME`] (`directories::ProjectDirs` + optional `--config-dir`).
10//!
11//! ## 12-Factor alignment (this product)
12//!
13//! | Factor | How ssh-cli applies it |
14//! |--------|------------------------|
15//! | III Config | Hosts in XDG `config.toml`; secrets via CLI flags / keyring / `secrets.key` (not `SSH_CLI_*` env stores) |
16//! | Build/Release/Run | Version from `CARGO_PKG_VERSION`; commit via `build.rs`; no prod URL in binary |
17//! | Backing services | SSH endpoints are user registry data, not compile-time constants |
18//!
19//! Modules keep *local* constants when the name is only meaningful in that
20//! module (e.g. concurrency RAM budget). Cross-cutting identity, storage file
21//! names, env var names, network defaults, and process timing are centralized.
22
23// ── Identity / XDG ──────────────────────────────────────────────────────────
24
25/// Binary and project name (`clap`, keyring service, XDG directory leaf).
26pub const APP_NAME: &str = "ssh-cli";
27
28/// `directories::ProjectDirs::from` qualifier (empty = no reverse-DNS prefix).
29pub const PROJECT_QUALIFIER: &str = "";
30
31/// `directories::ProjectDirs::from` organization (empty = app-only path).
32pub const PROJECT_ORGANIZATION: &str = "";
33
34/// Registry file name under the config directory.
35pub const CONFIG_FILE_NAME: &str = "config.toml";
36
37/// Primary-key file name (next to [`CONFIG_FILE_NAME`]; mode 0o600).
38pub const SECRETS_KEY_FILE_NAME: &str = "secrets.key";
39
40/// TOFU host-key store file name (sibling of [`CONFIG_FILE_NAME`]).
41pub const KNOWN_HOSTS_FILE_NAME: &str = "known_hosts";
42
43/// Active-VPS marker file name (sibling of [`CONFIG_FILE_NAME`]).
44pub const ACTIVE_VPS_FILE_NAME: &str = "active";
45
46/// Persisted UI language preference file name (sibling of [`CONFIG_FILE_NAME`]).
47pub const LANG_PREFERENCE_FILE_NAME: &str = "lang";
48
49/// XDG subdirectory for TLS material (mTLS client certs, ACME account/certs).
50///
51/// Layout (all under config dir, mode 0o600 for secrets):
52/// - `tls/mtls/<name>/cert.pem` + `key.pem`
53/// - `tls/acme/account.json`
54/// - `tls/acme/<domain>/cert.pem` + `key.pem` + `order.json` (pending)
55pub const TLS_DIR_NAME: &str = "tls";
56
57/// Subdirectory of [`TLS_DIR_NAME`] for imported mTLS client identities.
58pub const TLS_MTLS_DIR_NAME: &str = "mtls";
59
60/// Subdirectory of [`TLS_DIR_NAME`] for ACME account + issued certificates.
61pub const TLS_ACME_DIR_NAME: &str = "acme";
62
63/// ACME account credentials file name (JSON, 0o600).
64pub const TLS_ACME_ACCOUNT_FILE_NAME: &str = "account.json";
65
66/// Certificate chain PEM file name under an ACME/mTLS identity directory.
67pub const TLS_CERT_FILE_NAME: &str = "cert.pem";
68
69/// Private key PEM file name under an ACME/mTLS identity directory.
70pub const TLS_KEY_FILE_NAME: &str = "key.pem";
71
72/// Pending ACME order state file name (agent two-step DNS-01 flow).
73pub const TLS_ACME_ORDER_FILE_NAME: &str = "order.json";
74
75// ── Environment variable names (historical / fail-closed surface) ───────────
76// Product config is CLI + XDG only. These names are kept for fail-closed
77// rejection of secrets env stores and for test cleanup of legacy vars.
78// G-AUD-12: do not reintroduce SSH_CLI_HOME / LANG / FORCE_TEXT as stores.
79// G-UNSAFE-08: plaintext opt-out is CLI-only (`--allow-plaintext-secrets`).
80// G-UNSAFE-14: concurrency is CLI-only (`--max-concurrency` + auto formula).
81
82/// Historical name only — **not** read as product config store.
83/// Use CLI `--config-dir` for isolated config roots.
84pub const ENV_HOME: &str = "SSH_CLI_HOME";
85
86/// Hex primary-key material. **Fail-closed:** presence is rejected (not a store).
87/// Use XDG `secrets.key`, `--secrets-key-file`, or `--use-keyring`.
88pub const ENV_SECRETS_KEY: &str = "SSH_CLI_SECRETS_KEY";
89
90/// Path to a primary-key file (hex). **Fail-closed:** presence is rejected (not a store).
91/// Use CLI `--secrets-key-file` or XDG `secrets.key`.
92pub const ENV_SECRETS_KEY_FILE: &str = "SSH_CLI_SECRETS_KEY_FILE";
93
94/// Historical name only — prefer CLI `--use-keyring` (not an env store).
95pub const ENV_USE_KEYRING: &str = "SSH_CLI_USE_KEYRING";
96
97/// Historical name only — **not** read as product store.
98/// Use CLI `--lang` or `locale set` (XDG `lang`).
99pub const ENV_LANG: &str = "SSH_CLI_LANG";
100
101/// Historical name only — **not** read as product store.
102/// Use `--json` / `--output-format text`.
103pub const ENV_FORCE_TEXT: &str = "SSH_CLI_FORCE_TEXT";
104
105// ── OS keyring identity ─────────────────────────────────────────────────────
106
107/// Keyring service name (must match historical installs).
108pub const KEYRING_SERVICE: &str = APP_NAME;
109
110/// Canonical keyring user for the primary key.
111pub const KEYRING_USER_PRIMARY: &str = "secrets-primary-key";
112
113/// Legacy keyring user (read-only migration alias).
114pub const KEYRING_USER_LEGACY: &str = "secrets-master-key";
115
116// ── Network defaults (named; overridable via CLI / registry) ────────────────
117
118/// Default SSH port when the user omits `--port` on `vps add`.
119pub const DEFAULT_SSH_PORT: u16 = 22;
120
121/// Default local bind for `tunnel --bind` (loopback-only for safety).
122pub const DEFAULT_TUNNEL_BIND_ADDR: &str = "127.0.0.1";
123
124/// Origin address advertised on SSH direct-tcpip channels for local forwards.
125pub const TUNNEL_CHANNEL_ORIGIN_ADDR: &str = "127.0.0.1";
126
127/// Origin port advertised on SSH direct-tcpip channels (`0` = ephemeral/unspecified).
128pub const TUNNEL_CHANNEL_ORIGIN_PORT: u16 = 0;
129
130/// Bind address requested from the server for `tunnel --reverse` (loopback default).
131///
132/// Empty would mean "all interfaces" per RFC 4254; loopback keeps the remote
133/// listener private unless the operator asks for exposure, matching the local
134/// `--bind` default rather than silently inverting it.
135pub const DEFAULT_REVERSE_BIND_ADDR: &str = "127.0.0.1";
136
137/// Queue depth for channels the server opens on an active reverse forward.
138///
139/// Bounded on purpose: the peer decides how fast these arrive, so an unbounded
140/// queue would let a hostile server grow our memory without ever connecting.
141pub const REVERSE_FORWARD_QUEUE_DEPTH: usize = 64;
142
143/// Maximum bytes accepted while parsing one SOCKS5 handshake.
144///
145/// RFC 1928 bounds every handshake message: the greeting is at most 257 bytes
146/// (2 + 255 methods) and the request at most 262 (4 + 1 length + 255 name + 2
147/// port), so 519 is the largest legal handshake. The budget sits above that and
148/// far below anything an attacker could use to make the proxy buffer at will.
149pub const SOCKS5_HANDSHAKE_MAX_BYTES: usize = 1024;
150
151/// SOCKS5 protocol version byte (RFC 1928).
152pub const SOCKS5_VERSION: u8 = 0x05;
153
154/// SOCKS5 "no authentication required" method (RFC 1928 §3).
155pub const SOCKS5_METHOD_NO_AUTH: u8 = 0x00;
156
157/// SOCKS5 "no acceptable methods" reply (RFC 1928 §3).
158pub const SOCKS5_METHOD_NONE_ACCEPTABLE: u8 = 0xFF;
159
160// ── Process / async timing (units in the name) ──────────────────────────────
161
162/// Tokio runtime graceful shutdown budget after the one-shot command returns.
163pub const RUNTIME_SHUTDOWN_TIMEOUT_SECS: u64 = 2;
164
165/// Tunnel accept-loop cooperative signal poll interval.
166pub const TUNNEL_SIGNAL_POLL_INTERVAL_MS: u64 = 200;
167
168/// Grace period to drain in-flight tunnel forwards after stop.
169pub const TUNNEL_FORWARD_DRAIN_TIMEOUT_SECS: u64 = 2;
170
171/// Multi-host fan-out signal poll interval inside `map_bounded`.
172pub const FAN_OUT_SIGNAL_POLL_INTERVAL_MS: u64 = 50;
173
174// ── SSH transport / TCP dial (Rules Rust — rede) ────────────────────────────
175
176/// Delay before starting the next Happy Eyeballs dial candidate (RFC 8305-inspired).
177pub const HAPPY_EYEBALLS_ATTEMPT_DELAY_MS: u64 = 50;
178
179/// SSH-level keepalive interval (seconds between keepalives when idle).
180pub const SSH_KEEPALIVE_INTERVAL_SECS: u64 = 15;
181
182/// Max unanswered SSH keepalives before russh closes the session.
183pub const SSH_KEEPALIVE_MAX: usize = 3;
184
185/// SSH identification string sent to the server (G-SSH-02).
186///
187/// Generic product id — must **not** embed the exact `russh` crate version
188/// (server fingerprinting / version targeting).
189pub const SSH_CLIENT_ID: &str = "SSH-2.0-ssh-cli";
190
191/// Initial SSH channel window size (2 MiB — OpenSSH-class throughput).
192pub const SSH_WINDOW_SIZE: u32 = 2 * 1024 * 1024;
193
194/// Maximum SSH packet size (32 KiB — RFC 4253 common default).
195pub const SSH_MAX_PACKET_SIZE: u32 = 32 * 1024;
196
197/// Rekey after this many seconds of session lifetime (RFC 4253 §9).
198pub const SSH_REKEY_TIME_SECS: u64 = 3600;
199
200/// Rekey after this many bytes read or written (1 GiB — RFC 4253 §9).
201pub const SSH_REKEY_BYTE_LIMIT: usize = 1 << 30;
202
203/// Enable TCP-level `SO_KEEPALIVE` on the SSH dial socket (complements SSH KA).
204pub const TCP_KEEPALIVE_ENABLED: bool = true;
205
206/// Minimum RSA private-key size accepted for publickey auth (bits).
207pub const SSH_RSA_MIN_BITS: usize = 2048;
208
209/// Preferred RSA size; smaller (but ≥ min) keys log a warning (bits).
210pub const SSH_RSA_PREFERRED_BITS: usize = 3072;
211
212/// Windows OpenSSH agent named pipe (platform default when `--use-agent` has no path).
213pub const WINDOWS_SSH_AGENT_PIPE: &str = r"\\.\pipe\openssh-ssh-agent";
214
215// ── SFTP subsystem (G-SFTP) ─────────────────────────────────────────────────
216
217/// SSH subsystem name for SFTP v3 (`request_subsystem`).
218pub const SFTP_SUBSYSTEM: &str = "sftp";
219
220/// Stream chunk size for SFTP file I/O (bytes). Never load whole files into RAM.
221pub const SFTP_IO_CHUNK: usize = 32 * 1024;
222
223// ── SCP wire (G-SCP) ────────────────────────────────────────────────────────
224
225/// Stream chunk size for SCP file I/O (bytes). Never load whole files into RAM.
226///
227/// B4: this value used to be declared twice as a function-local `const` inside
228/// `src/ssh/client_real_scp.rs`, each carrying a deferral marker admitting it
229/// belonged here. It is deliberately equal to [`SFTP_IO_CHUNK`] — both transfer
230/// paths stream through the same window — but kept as a distinct name so a
231/// future protocol-specific tuning of one does not silently retune the other.
232pub const SCP_IO_CHUNK: usize = 32 * 1024;
233
234/// Max bytes accepted for a single SCP protocol header line.
235///
236/// Headers are a few dozen bytes; the cap only exists so a hostile source cannot
237/// make us grow an unbounded buffer by never sending a newline.
238pub const SCP_HEADER_MAX_BYTES: usize = 16 * 1024;
239
240/// SFTP v3 permission field mask: permission bits only (no `S_IFMT` file-type bits).
241///
242/// Unix `st_mode` carries type in the high bits (e.g. `0o100644` for a regular
243/// file). The protocol `permissions` attribute must carry permission bits only
244/// (`0o7777` = mode + setuid/setgid/sticky). See G12 / G19.
245pub const SFTP_PERM_MASK: u32 = 0o7777;
246
247/// Permission mask applied to modes that arrive **from the network** (A3).
248///
249/// [`SFTP_PERM_MASK`] keeps setuid/setgid/sticky, which is correct when reading a
250/// mode from a **local** file we own and announcing it to the server. It is *not*
251/// correct in the opposite direction: a malicious or compromised server can mark a
252/// remote file setuid and have the download reproduce that bit on the local disk.
253/// Elevation bits must never be attacker-controlled, so inbound modes are clamped
254/// to plain `rwx` triples.
255pub const SFTP_PERM_MASK_UNTRUSTED: u32 = 0o0777;
256
257/// Max characters kept from a server-sent pre-auth banner before truncation (A1).
258///
259/// The banner is remote input that arrives *before* authentication. It is logged
260/// for diagnostics only, so a small cap is enough and keeps hostile input bounded.
261pub const AUTH_BANNER_MAX_CHARS: usize = 512;
262
263/// Max recursion depth for `sftp upload|download --recursive`.
264pub const SFTP_MAX_RECURSION_DEPTH: u32 = 64;
265
266/// Fail-closed cap on directory listing entries (`sftp ls` / recursive walk).
267pub const SFTP_LIST_MAX_ENTRIES: usize = 100_000;
268
269/// Basename used only when a multi-file source has no file name component.
270/// Prefer rejecting empty names; this is a last-resort label (G-SFTP-R13).
271pub const SFTP_FALLBACK_BASENAME: &str = "ssh-cli-unnamed";
272
273// ── Agent retry policy (Rules Rust — retry/backoff; re-invoke, not in-process) ─
274
275/// Max retries **after** the first failure for agents re-invoking on exit 74.
276///
277/// Total attempts = `AGENT_RETRY_MAX_RETRIES + 1` (3). Matches `docs/AGENTS.md`.
278pub const AGENT_RETRY_MAX_RETRIES: u32 = 2;
279
280/// Base backoff delay (ms) for agent full-jitter formula.
281pub const AGENT_RETRY_BASE_MS: u64 = 200;
282
283/// Ceiling for a single agent backoff sleep (ms).
284pub const AGENT_RETRY_MAX_DELAY_MS: u64 = 5_000;
285
286/// Hard cap on configured retries (prevents accidental retry storms).
287pub const HARD_RETRY_MAX_RETRIES: u32 = 10;
288
289/// Hard ceiling for a single delay (ms).
290pub const HARD_RETRY_MAX_DELAY_MS: u64 = 60_000;
291
292// ── At-rest crypto sizes (ChaCha20-Poly1305) ─────────────────────────────────
293
294/// Primary-key length in bytes.
295pub const PRIMARY_KEY_LEN_BYTES: usize = 32;
296
297/// Primary-key hex encoding length (`2 * PRIMARY_KEY_LEN_BYTES`).
298pub const PRIMARY_KEY_HEX_LEN: usize = 64;
299
300/// ChaCha20-Poly1305 nonce length in bytes.
301pub const AEAD_NONCE_LEN_BYTES: usize = 12;
302
303/// Poly1305 authentication tag length in bytes.
304pub const AEAD_TAG_LEN_BYTES: usize = 16;
305
306/// Unix permission mode for secret files (`config.toml`, `secrets.key`, `lang`).
307pub const SECRET_FILE_MODE_UNIX: u32 = 0o600;
308
309/// Unix permission mode for secret directories (TLS identity dirs, etc.).
310pub const SECRET_DIR_MODE_UNIX: u32 = 0o700;
311
312// ── Concurrency caps (single source; clap + fan-out) ─────────────────────────
313
314/// Minimum concurrent multi-host / fan-out units (always ≥ 1).
315pub const MIN_CONCURRENCY: usize = 1;
316
317/// Hard upper bound on concurrent multi-host sessions and SCP file channels.
318///
319/// Clap `--max-concurrency` / `--scp-file-concurrency` ranges must use this value.
320pub const MAX_CONCURRENCY: usize = 64;
321
322/// Alias used by the concurrency budget module (same as [`MAX_CONCURRENCY`]).
323pub const HARD_CAP: usize = MAX_CONCURRENCY;
324
325/// Documented per multi-host session RAM budget (bytes) for auto concurrency formula.
326pub const RAM_PER_TASK_BYTES: u64 = 16 * 1024 * 1024;
327
328/// I/O oversubscribe factor vs CPU count for multi-host fan-out.
329pub const IO_OVERSUBSCRIBE: usize = 4;
330
331/// Conservative CPU×IO cap when free RAM cannot be read (non-Linux).
332pub const NON_LINUX_CPU_CAP: usize = 8;
333
334// ── Wire error details (English by contract) ────────────────────────────────
335//
336// These are the *bodies* of failures that reach a JSON envelope, and the
337// envelope schema pins them to English: see the `message` description in
338// `docs/schemas/error-envelope.schema.json`, which states it stays English
339// regardless of `--lang`. Agents branch on `error_code`; prose that changes
340// with the operator's locale turns a stable discriminator into a moving one.
341//
342// They live here rather than inline because five modules raise the same three
343// failures, and a phrase copied five times drifts in four of them. The human
344// text mode still localizes the *label* around these bodies — that path runs
345// through `i18n::localized_error_text` and is untouched.
346
347/// Rejection when `scp upload` is pointed at anything but a regular file.
348pub const SCP_UPLOAD_FILE_ONLY_MSG: &str =
349    "upload only supports regular files (no directories / no -r)";
350
351/// Rejection when `scp download` is handed an existing directory as its target.
352pub const SCP_DOWNLOAD_LOCAL_NOT_DIRECTORY_MSG: &str =
353    "download local path must be a file path, not an existing directory";
354
355/// Abort raised when a signal arrives mid-transfer and the loop stops early.
356pub const OPERATION_CANCELLED_MSG: &str = "operation cancelled by user";
357
358// Compile-time invariants (const/static rules).
359const _: () = assert!(!APP_NAME.is_empty());
360const _: () = assert!(!CONFIG_FILE_NAME.is_empty());
361const _: () = assert!(!SECRETS_KEY_FILE_NAME.is_empty());
362const _: () = assert!(!KNOWN_HOSTS_FILE_NAME.is_empty());
363const _: () = assert!(!ACTIVE_VPS_FILE_NAME.is_empty());
364const _: () = assert!(!ENV_HOME.is_empty());
365const _: () = assert!(DEFAULT_SSH_PORT > 0);
366const _: () = assert!(!DEFAULT_TUNNEL_BIND_ADDR.is_empty());
367const _: () = assert!(RUNTIME_SHUTDOWN_TIMEOUT_SECS > 0);
368const _: () = assert!(TUNNEL_SIGNAL_POLL_INTERVAL_MS > 0);
369const _: () = assert!(TUNNEL_FORWARD_DRAIN_TIMEOUT_SECS > 0);
370const _: () = assert!(REVERSE_FORWARD_QUEUE_DEPTH > 0);
371// 519 is the largest handshake RFC 1928 permits; a smaller cap would reject
372// valid clients, which is a bug that only shows up against unusual peers.
373const _: () = assert!(SOCKS5_HANDSHAKE_MAX_BYTES >= 519);
374const _: () = assert!(SOCKS5_VERSION == 0x05);
375const _: () = assert!(FAN_OUT_SIGNAL_POLL_INTERVAL_MS > 0);
376const _: () = assert!(HAPPY_EYEBALLS_ATTEMPT_DELAY_MS > 0);
377const _: () = assert!(SSH_KEEPALIVE_INTERVAL_SECS > 0);
378const _: () = assert!(SSH_KEEPALIVE_MAX > 0);
379const _: () = assert!(!SSH_CLIENT_ID.is_empty());
380const _: () = assert!(SSH_WINDOW_SIZE > 0);
381const _: () = assert!(SSH_MAX_PACKET_SIZE > 0);
382const _: () = assert!(SSH_REKEY_TIME_SECS > 0);
383const _: () = assert!(SSH_REKEY_BYTE_LIMIT > 0);
384const _: () = assert!(SSH_RSA_MIN_BITS >= 2048);
385const _: () = assert!(SSH_RSA_PREFERRED_BITS >= SSH_RSA_MIN_BITS);
386const _: () = assert!(!WINDOWS_SSH_AGENT_PIPE.is_empty());
387const _: () = assert!(!SFTP_SUBSYSTEM.is_empty());
388const _: () = assert!(SFTP_IO_CHUNK > 0);
389const _: () = assert!(SFTP_PERM_MASK == 0o7777);
390const _: () = assert!(SFTP_MAX_RECURSION_DEPTH > 0);
391const _: () = assert!(SFTP_LIST_MAX_ENTRIES > 0);
392const _: () = assert!(!SFTP_FALLBACK_BASENAME.is_empty());
393const _: () = assert!(AGENT_RETRY_MAX_RETRIES > 0);
394const _: () = assert!(AGENT_RETRY_BASE_MS >= 50);
395const _: () = assert!(AGENT_RETRY_MAX_DELAY_MS >= AGENT_RETRY_BASE_MS);
396const _: () = assert!(HARD_RETRY_MAX_RETRIES >= AGENT_RETRY_MAX_RETRIES);
397const _: () = assert!(HARD_RETRY_MAX_DELAY_MS >= AGENT_RETRY_MAX_DELAY_MS);
398const _: () = assert!(PRIMARY_KEY_HEX_LEN == PRIMARY_KEY_LEN_BYTES * 2);
399const _: () = assert!(AEAD_NONCE_LEN_BYTES == 12);
400const _: () = assert!(AEAD_TAG_LEN_BYTES == 16);
401const _: () = assert!(SECRET_FILE_MODE_UNIX == 0o600);
402const _: () = assert!(!SCP_UPLOAD_FILE_ONLY_MSG.is_empty());
403const _: () = assert!(!SCP_DOWNLOAD_LOCAL_NOT_DIRECTORY_MSG.is_empty());
404const _: () = assert!(!OPERATION_CANCELLED_MSG.is_empty());