Skip to main content

lean_ctx/gateway_server/
init.rs

1//! `lean-ctx gateway init` (enterprise#47) — plug-and-play gateway setup.
2//!
3//! One command produces a complete, immediately runnable instance directory:
4//!
5//! ```text
6//! <dir>/config.toml         engine config (bind, tokens required, org, baseline)
7//! <dir>/gateway-keys.toml   per-person keys (only if --person given)
8//! <dir>/.env                generated secrets (tokens, Postgres password, DATABASE_URL)
9//! <dir>/docker-compose.yml  gateway + Postgres 17, healthchecks, restart policies
10//! <dir>/README.md           the 3-step quickstart for this instance
11//! ```
12//!
13//! Security posture: secrets live **only** in `.env` (0600, gitignored by the
14//! generated `.gitignore`); `config.toml` and the compose file are clean and
15//! committable. Existing files are never overwritten — rerunning on a
16//! non-empty directory fails loudly instead of rotating live credentials.
17
18use std::fmt::Write as _;
19use std::path::Path;
20
21/// Options for `gateway init` (parsed by the CLI layer).
22#[derive(Debug, Clone)]
23pub struct InitOptions {
24    pub org_label: String,
25    pub seats: Option<u32>,
26    pub reference_model: Option<String>,
27    /// Persons to create keys for right away (`--person a@x --person b@y`).
28    pub persons: Vec<String>,
29    pub proxy_port: u16,
30    pub admin_port: u16,
31}
32
33impl Default for InitOptions {
34    fn default() -> Self {
35        Self {
36            org_label: String::new(),
37            seats: None,
38            reference_model: None,
39            persons: Vec::new(),
40            proxy_port: 8484,
41            admin_port: 8485,
42        }
43    }
44}
45
46/// Result summary: what was created, which plaintext keys to hand out.
47#[derive(Debug)]
48pub struct InitOutcome {
49    pub files: Vec<String>,
50    /// `(person, plaintext_key)` — print once, never persisted.
51    pub person_keys: Vec<(String, String)>,
52}
53
54/// Runs the init: creates the directory and all files. See module docs.
55///
56/// # Errors
57/// Fails if any target file already exists, on CSPRNG failure, or on I/O.
58pub fn run(dir: &Path, opts: &InitOptions) -> anyhow::Result<InitOutcome> {
59    std::fs::create_dir_all(dir)?;
60    for name in [
61        "config.toml",
62        ".env",
63        "docker-compose.yml",
64        "README.md",
65        "gateway-keys.toml",
66    ] {
67        anyhow::ensure!(
68            !dir.join(name).exists(),
69            "{} already exists — `gateway init` never overwrites an instance \
70             (delete the file or choose another directory)",
71            dir.join(name).display()
72        );
73    }
74
75    let proxy_token = random_token()?;
76    let admin_token = random_token()?;
77    let pg_password = random_token()?;
78
79    let mut files = Vec::new();
80    write_file(dir, &mut files, "config.toml", &render_config(opts), false)?;
81    write_file(
82        dir,
83        &mut files,
84        ".env",
85        &render_env(&proxy_token, &admin_token, &pg_password),
86        true,
87    )?;
88    write_file(
89        dir,
90        &mut files,
91        "docker-compose.yml",
92        &render_compose(opts),
93        false,
94    )?;
95    write_file(dir, &mut files, ".gitignore", ".env\n", false)?;
96
97    // Person keys through the real key manager (same file format as auth).
98    let mut person_keys = Vec::new();
99    let keys_path = dir.join("gateway-keys.toml");
100    for person in &opts.persons {
101        let key = super::keys_cli::add_key(&keys_path, person, None, None, false)?;
102        person_keys.push((person.clone(), key));
103    }
104    if person_keys.is_empty() {
105        // The compose file mounts the key file — it must exist even when empty.
106        super::keys_cli::write_empty(&keys_path)?;
107    }
108    files.push("gateway-keys.toml".to_string());
109
110    write_file(dir, &mut files, "README.md", &render_readme(opts), false)?;
111
112    Ok(InitOutcome { files, person_keys })
113}
114
115fn write_file(
116    dir: &Path,
117    files: &mut Vec<String>,
118    name: &str,
119    contents: &str,
120    secret: bool,
121) -> anyhow::Result<()> {
122    let path = dir.join(name);
123    std::fs::write(&path, contents)?;
124    #[cfg(unix)]
125    if secret {
126        use std::os::unix::fs::PermissionsExt;
127        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
128    }
129    let _ = secret; // non-unix: mode bits not applicable
130    files.push(name.to_string());
131    Ok(())
132}
133
134/// 32 random bytes, hex — the same entropy class as `openssl rand -hex 32`.
135fn random_token() -> anyhow::Result<String> {
136    let mut buf = [0u8; 32];
137    getrandom::fill(&mut buf).map_err(|e| anyhow::anyhow!("CSPRNG unavailable: {e}"))?;
138    Ok(buf.iter().fold(String::new(), |mut acc, b| {
139        let _ = write!(acc, "{b:02x}");
140        acc
141    }))
142}
143
144fn render_config(opts: &InitOptions) -> String {
145    let mut out = String::from(
146        "# lean-ctx gateway configuration — generated by `lean-ctx gateway init`.\n\
147         # Secrets never live here: tokens and DATABASE_URL come from .env.\n\n\
148         # Bind beyond loopback (the container/K8s case) and require Bearer auth.\n\
149         proxy_bind_host = \"0.0.0.0\"\n\
150         proxy_require_token = true\n",
151    );
152    let _ = writeln!(out, "\n[gateway_server]");
153    // In-container the admin listener must bind all interfaces so the compose
154    // port mapping reaches it; exposure stays host-local via the mapping
155    // ("127.0.0.1:<port>:8485"). Outside containers the default is loopback.
156    let _ = writeln!(out, "admin_bind_host = \"0.0.0.0\"");
157    if let Some(seats) = opts.seats {
158        let _ = writeln!(out, "seats = {seats}");
159    }
160    if !opts.org_label.is_empty() {
161        let _ = writeln!(
162            out,
163            "org_label = \"{}\"",
164            opts.org_label.replace('"', "\\\"")
165        );
166    }
167    if let Some(reference) = opts
168        .reference_model
169        .as_deref()
170        .map(str::trim)
171        .filter(|m| !m.is_empty())
172    {
173        let _ = writeln!(
174            out,
175            "\n# Counterfactual baseline: what the org would have paid without lean-ctx.\n\
176             [proxy.baseline]\nreference_model = \"{reference}\""
177        );
178    }
179    out.push_str(
180        "\n# Registry providers (optional). Built-in routes work without any entry:\n\
181         #   /anthropic/…  /openai/…  /gemini/…\n\
182         # Add self-hosted or Foundry endpoints like this:\n\
183         # [[proxy.providers]]\n\
184         # id = \"local\"\n\
185         # shape = \"openai\"\n\
186         # base_url = \"http://host.docker.internal:11434\"\n\
187         # local = true   # billed at the shadow rate, not cloud list prices\n\
188         # # plain-HTTP non-loopback upstream additionally needs:\n\
189         # # [proxy] allow_insecure_http_upstream = true\n\
190         #\n\
191         # [[proxy.providers]]\n\
192         # id = \"foundry\"\n\
193         # shape = \"openai\"\n\
194         # base_url = \"https://<resource>.services.ai.azure.com/models\"\n\
195         # api_key_env = \"FOUNDRY_API_KEY\"\n\
196         \n\
197         # Active routing (optional): aliases + tier targets, see docs/reference/05-advanced.md.\n\
198         # Aliases are your org's model namespace — clients discover them via\n\
199         # GET /v1/models on the proxy port and select them by name in the IDE:\n\
200         # [proxy.routing]\n\
201         # enabled = true\n\
202         # [proxy.routing.aliases]\n\
203         # \"acme/fast\" = \"foundry:gpt-4o-mini\"      # org name -> provider:model\n\
204         # \"acme/local\" = \"local:llama3.3\"          # local target (shadow rate)\n",
205    );
206    out
207}
208
209fn render_env(proxy_token: &str, admin_token: &str, pg_password: &str) -> String {
210    format!(
211        "# Generated secrets — keep out of git (the generated .gitignore covers this file).\n\
212         # Rotate by editing here and `docker compose up -d` (containers restart with new values).\n\
213         LEAN_CTX_PROXY_TOKEN={proxy_token}\n\
214         LEAN_CTX_GATEWAY_ADMIN_TOKEN={admin_token}\n\
215         POSTGRES_PASSWORD={pg_password}\n\
216         DATABASE_URL=postgres://leanctx:{pg_password}@postgres:5432/leanctx\n"
217    )
218}
219
220fn render_compose(opts: &InitOptions) -> String {
221    format!(
222        r#"# lean-ctx gateway — pilot deployment (single host, docker compose).
223# Production path: the lean-ctx-gateway Helm chart (see deploy template repo).
224services:
225  postgres:
226    image: postgres:17-alpine
227    environment:
228      POSTGRES_USER: leanctx
229      POSTGRES_PASSWORD: ${{POSTGRES_PASSWORD}}
230      POSTGRES_DB: leanctx
231    volumes:
232      - pgdata:/var/lib/postgresql/data
233    healthcheck:
234      test: ["CMD-SHELL", "pg_isready -U leanctx -d leanctx"]
235      interval: 5s
236      timeout: 3s
237      retries: 10
238    restart: unless-stopped
239
240  gateway:
241    image: ${{LEANCTX_IMAGE:-lean-ctx-gateway:latest}}
242    depends_on:
243      postgres:
244        condition: service_healthy
245    environment:
246      LEAN_CTX_PROXY_TOKEN: ${{LEAN_CTX_PROXY_TOKEN}}
247      LEAN_CTX_GATEWAY_ADMIN_TOKEN: ${{LEAN_CTX_GATEWAY_ADMIN_TOKEN}}
248      DATABASE_URL: ${{DATABASE_URL}}
249    volumes:
250      - ./config.toml:/etc/lean-ctx/config.toml:ro
251      - ./gateway-keys.toml:/etc/lean-ctx/gateway-keys.toml:ro
252    ports:
253      - "{proxy_port}:8484"          # proxy — the surface clients use
254      - "127.0.0.1:{admin_port}:8485" # admin console — host-local only
255    restart: unless-stopped
256
257volumes:
258  pgdata:
259"#,
260        proxy_port = opts.proxy_port,
261        admin_port = opts.admin_port,
262    )
263}
264
265fn render_readme(opts: &InitOptions) -> String {
266    let org = if opts.org_label.is_empty() {
267        "your org"
268    } else {
269        &opts.org_label
270    };
271    format!(
272        r"# lean-ctx gateway — {org}
273
274Generated by `lean-ctx gateway init`. Three steps to a running gateway:
275
276## 1. Start
277
278```bash
279docker compose up -d
280```
281
282(The image comes from `LEANCTX_IMAGE`, default `lean-ctx-gateway:latest` — build it
283with `docker build -f docker/Dockerfile.gateway -t lean-ctx-gateway:latest .` from
284the engine repo, or point `LEANCTX_IMAGE` at your registry.)
285
286## 2. Verify
287
288```bash
289lean-ctx gateway doctor --dir .          # preflight: config, secrets, DB, ports
290curl -s http://127.0.0.1:{proxy_port}/health   # proxy liveness
291open http://127.0.0.1:{admin_port}/            # admin console (token: LEAN_CTX_GATEWAY_ADMIN_TOKEN in .env)
292```
293
294## 3. Hand out keys
295
296```bash
297lean-ctx gateway keys add --person alice@example.com --team platform --project checkout --file gateway-keys.toml
298lean-ctx gateway keys rotate --person alice@example.com --file gateway-keys.toml   # compromised/expiring key: one atomic step
299docker compose restart gateway   # reload the key set
300```
301
302Point clients at the proxy:
303
304```bash
305export ANTHROPIC_BASE_URL=http://<host>:{proxy_port}/anthropic
306export ANTHROPIC_AUTH_TOKEN=<the person's gk-… key>
307```
308
309Model catalog and personal view (each person uses their own key):
310
311```bash
312curl -s -H 'Authorization: Bearer <gk-… key>' http://<host>:{proxy_port}/v1/models   # org model aliases
313open http://<host>:{proxy_port}/me                                                   # personal usage dashboard
314```
315
316## Files
317
318| File | Purpose | Committable |
319|---|---|---|
320| `config.toml` | engine config (org, baseline, providers, routing) | yes |
321| `docker-compose.yml` | pilot deployment | yes |
322| `gateway-keys.toml` | SHA-256 key hashes (no plaintext) | yes |
323| `.env` | generated secrets | **no** (gitignored) |
324",
325        proxy_port = opts.proxy_port,
326        admin_port = opts.admin_port,
327    )
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    fn opts() -> InitOptions {
335        InitOptions {
336            org_label: "Zühlke Engineering AG".into(),
337            seats: Some(800),
338            reference_model: Some("claude-opus-4.5".into()),
339            persons: vec!["alice@zuehlke.com".into(), "bob@zuehlke.com".into()],
340            proxy_port: 8484,
341            admin_port: 8485,
342        }
343    }
344
345    #[test]
346    fn init_creates_complete_runnable_instance() {
347        let tmp = tempfile::tempdir().unwrap();
348        let dir = tmp.path().join("gw");
349        let outcome = run(&dir, &opts()).unwrap();
350
351        for f in [
352            "config.toml",
353            ".env",
354            "docker-compose.yml",
355            "README.md",
356            "gateway-keys.toml",
357            ".gitignore",
358        ] {
359            assert!(dir.join(f).exists(), "missing {f}");
360        }
361
362        // config.toml parses and carries the org parameters.
363        let cfg = std::fs::read_to_string(dir.join("config.toml")).unwrap();
364        let parsed: toml::Value = toml::from_str(&cfg).expect("generated config must parse");
365        assert_eq!(
366            parsed["gateway_server"]["org_label"].as_str(),
367            Some("Zühlke Engineering AG")
368        );
369        assert_eq!(parsed["gateway_server"]["seats"].as_integer(), Some(800));
370        assert_eq!(
371            parsed["proxy"]["baseline"]["reference_model"].as_str(),
372            Some("claude-opus-4.5")
373        );
374        assert_eq!(parsed["proxy_bind_host"].as_str(), Some("0.0.0.0"));
375        assert_eq!(parsed["proxy_require_token"].as_bool(), Some(true));
376
377        // Secrets: only in .env, wired into DATABASE_URL, never in config/compose.
378        let env = std::fs::read_to_string(dir.join(".env")).unwrap();
379        let token_of = |name: &str| {
380            env.lines()
381                .find_map(|l| l.strip_prefix(&format!("{name}=")))
382                .map(str::to_string)
383                .unwrap_or_default()
384        };
385        let proxy_token = token_of("LEAN_CTX_PROXY_TOKEN");
386        assert_eq!(proxy_token.len(), 64);
387        assert!(env.contains(&format!(
388            "DATABASE_URL=postgres://leanctx:{}@postgres:5432/leanctx",
389            token_of("POSTGRES_PASSWORD")
390        )));
391        assert!(!cfg.contains(&proxy_token));
392        let compose = std::fs::read_to_string(dir.join("docker-compose.yml")).unwrap();
393        assert!(!compose.contains(&proxy_token));
394        assert!(compose.contains("service_healthy"));
395
396        // Both persons got working keys resolvable via the real auth loader.
397        assert_eq!(outcome.person_keys.len(), 2);
398        let keys =
399            crate::proxy::gateway_identity::GatewayKeys::load(&dir.join("gateway-keys.toml"))
400                .unwrap();
401        for (person, key) in &outcome.person_keys {
402            let tags = keys.lookup(key).expect("generated key resolves");
403            assert_eq!(tags.person.as_deref(), Some(person.as_str()));
404        }
405
406        // .env is owner-only.
407        #[cfg(unix)]
408        {
409            use std::os::unix::fs::PermissionsExt;
410            let mode = std::fs::metadata(dir.join(".env"))
411                .unwrap()
412                .permissions()
413                .mode();
414            assert_eq!(mode & 0o777, 0o600);
415        }
416    }
417
418    #[test]
419    fn init_refuses_to_overwrite_existing_instance() {
420        let tmp = tempfile::tempdir().unwrap();
421        let dir = tmp.path().join("gw");
422        run(&dir, &InitOptions::default()).unwrap();
423        let err = run(&dir, &InitOptions::default()).unwrap_err();
424        assert!(
425            err.to_string().contains("never overwrites"),
426            "second init must refuse: {err}"
427        );
428    }
429
430    #[test]
431    fn tokens_are_distinct_per_init() {
432        let tmp = tempfile::tempdir().unwrap();
433        run(&tmp.path().join("a"), &InitOptions::default()).unwrap();
434        run(&tmp.path().join("b"), &InitOptions::default()).unwrap();
435        let env_a = std::fs::read_to_string(tmp.path().join("a/.env")).unwrap();
436        let env_b = std::fs::read_to_string(tmp.path().join("b/.env")).unwrap();
437        let first_line = |s: &str| {
438            s.lines()
439                .find(|l| l.starts_with("LEAN_CTX_PROXY_TOKEN"))
440                .unwrap()
441                .to_string()
442        };
443        assert_ne!(first_line(&env_a), first_line(&env_b));
444    }
445}