use std::fmt::Write as _;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct InitOptions {
pub org_label: String,
pub seats: Option<u32>,
pub reference_model: Option<String>,
pub persons: Vec<String>,
pub proxy_port: u16,
pub admin_port: u16,
}
impl Default for InitOptions {
fn default() -> Self {
Self {
org_label: String::new(),
seats: None,
reference_model: None,
persons: Vec::new(),
proxy_port: 8484,
admin_port: 8485,
}
}
}
#[derive(Debug)]
pub struct InitOutcome {
pub files: Vec<String>,
pub person_keys: Vec<(String, String)>,
}
pub fn run(dir: &Path, opts: &InitOptions) -> anyhow::Result<InitOutcome> {
std::fs::create_dir_all(dir)?;
for name in [
"config.toml",
".env",
"docker-compose.yml",
"README.md",
"gateway-keys.toml",
] {
anyhow::ensure!(
!dir.join(name).exists(),
"{} already exists — `gateway init` never overwrites an instance \
(delete the file or choose another directory)",
dir.join(name).display()
);
}
let proxy_token = random_token()?;
let admin_token = random_token()?;
let pg_password = random_token()?;
let mut files = Vec::new();
write_file(dir, &mut files, "config.toml", &render_config(opts), false)?;
write_file(
dir,
&mut files,
".env",
&render_env(&proxy_token, &admin_token, &pg_password),
true,
)?;
write_file(
dir,
&mut files,
"docker-compose.yml",
&render_compose(opts),
false,
)?;
write_file(dir, &mut files, ".gitignore", ".env\n", false)?;
let mut person_keys = Vec::new();
let keys_path = dir.join("gateway-keys.toml");
for person in &opts.persons {
let key = super::keys_cli::add_key(&keys_path, person, None, None, false)?;
person_keys.push((person.clone(), key));
}
if person_keys.is_empty() {
super::keys_cli::write_empty(&keys_path)?;
}
files.push("gateway-keys.toml".to_string());
write_file(dir, &mut files, "README.md", &render_readme(opts), false)?;
Ok(InitOutcome { files, person_keys })
}
fn write_file(
dir: &Path,
files: &mut Vec<String>,
name: &str,
contents: &str,
secret: bool,
) -> anyhow::Result<()> {
let path = dir.join(name);
std::fs::write(&path, contents)?;
#[cfg(unix)]
if secret {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
let _ = secret; files.push(name.to_string());
Ok(())
}
fn random_token() -> anyhow::Result<String> {
let mut buf = [0u8; 32];
getrandom::fill(&mut buf).map_err(|e| anyhow::anyhow!("CSPRNG unavailable: {e}"))?;
Ok(buf.iter().fold(String::new(), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
}))
}
fn render_config(opts: &InitOptions) -> String {
let mut out = String::from(
"# lean-ctx gateway configuration — generated by `lean-ctx gateway init`.\n\
# Secrets never live here: tokens and DATABASE_URL come from .env.\n\n\
# Bind beyond loopback (the container/K8s case) and require Bearer auth.\n\
proxy_bind_host = \"0.0.0.0\"\n\
proxy_require_token = true\n",
);
let _ = writeln!(out, "\n[gateway_server]");
let _ = writeln!(out, "admin_bind_host = \"0.0.0.0\"");
if let Some(seats) = opts.seats {
let _ = writeln!(out, "seats = {seats}");
}
if !opts.org_label.is_empty() {
let _ = writeln!(
out,
"org_label = \"{}\"",
opts.org_label.replace('"', "\\\"")
);
}
if let Some(reference) = opts
.reference_model
.as_deref()
.map(str::trim)
.filter(|m| !m.is_empty())
{
let _ = writeln!(
out,
"\n# Counterfactual baseline: what the org would have paid without lean-ctx.\n\
[proxy.baseline]\nreference_model = \"{reference}\""
);
}
out.push_str(
"\n# Registry providers (optional). Built-in routes work without any entry:\n\
# /anthropic/… /openai/… /gemini/…\n\
# Add self-hosted or Foundry endpoints like this:\n\
# [[proxy.providers]]\n\
# id = \"local\"\n\
# shape = \"openai\"\n\
# base_url = \"http://host.docker.internal:11434\"\n\
# local = true # billed at the shadow rate, not cloud list prices\n\
# # plain-HTTP non-loopback upstream additionally needs:\n\
# # [proxy] allow_insecure_http_upstream = true\n\
#\n\
# [[proxy.providers]]\n\
# id = \"foundry\"\n\
# shape = \"openai\"\n\
# base_url = \"https://<resource>.services.ai.azure.com/models\"\n\
# api_key_env = \"FOUNDRY_API_KEY\"\n\
\n\
# Active routing (optional): aliases + tier targets, see docs/reference/05-advanced.md.\n\
# Aliases are your org's model namespace — clients discover them via\n\
# GET /v1/models on the proxy port and select them by name in the IDE:\n\
# [proxy.routing]\n\
# enabled = true\n\
# [proxy.routing.aliases]\n\
# \"acme/fast\" = \"foundry:gpt-4o-mini\" # org name -> provider:model\n\
# \"acme/local\" = \"local:llama3.3\" # local target (shadow rate)\n",
);
out
}
fn render_env(proxy_token: &str, admin_token: &str, pg_password: &str) -> String {
format!(
"# Generated secrets — keep out of git (the generated .gitignore covers this file).\n\
# Rotate by editing here and `docker compose up -d` (containers restart with new values).\n\
LEAN_CTX_PROXY_TOKEN={proxy_token}\n\
LEAN_CTX_GATEWAY_ADMIN_TOKEN={admin_token}\n\
POSTGRES_PASSWORD={pg_password}\n\
DATABASE_URL=postgres://leanctx:{pg_password}@postgres:5432/leanctx\n"
)
}
fn render_compose(opts: &InitOptions) -> String {
format!(
r#"# lean-ctx gateway — pilot deployment (single host, docker compose).
# Production path: the lean-ctx-gateway Helm chart (see deploy template repo).
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: leanctx
POSTGRES_PASSWORD: ${{POSTGRES_PASSWORD}}
POSTGRES_DB: leanctx
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U leanctx -d leanctx"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
gateway:
image: ${{LEANCTX_IMAGE:-lean-ctx-gateway:latest}}
depends_on:
postgres:
condition: service_healthy
environment:
LEAN_CTX_PROXY_TOKEN: ${{LEAN_CTX_PROXY_TOKEN}}
LEAN_CTX_GATEWAY_ADMIN_TOKEN: ${{LEAN_CTX_GATEWAY_ADMIN_TOKEN}}
DATABASE_URL: ${{DATABASE_URL}}
volumes:
- ./config.toml:/etc/lean-ctx/config.toml:ro
- ./gateway-keys.toml:/etc/lean-ctx/gateway-keys.toml:ro
ports:
- "{proxy_port}:8484" # proxy — the surface clients use
- "127.0.0.1:{admin_port}:8485" # admin console — host-local only
restart: unless-stopped
volumes:
pgdata:
"#,
proxy_port = opts.proxy_port,
admin_port = opts.admin_port,
)
}
fn render_readme(opts: &InitOptions) -> String {
let org = if opts.org_label.is_empty() {
"your org"
} else {
&opts.org_label
};
format!(
r"# lean-ctx gateway — {org}
Generated by `lean-ctx gateway init`. Three steps to a running gateway:
## 1. Start
```bash
docker compose up -d
```
(The image comes from `LEANCTX_IMAGE`, default `lean-ctx-gateway:latest` — build it
with `docker build -f docker/Dockerfile.gateway -t lean-ctx-gateway:latest .` from
the engine repo, or point `LEANCTX_IMAGE` at your registry.)
## 2. Verify
```bash
lean-ctx gateway doctor --dir . # preflight: config, secrets, DB, ports
curl -s http://127.0.0.1:{proxy_port}/health # proxy liveness
open http://127.0.0.1:{admin_port}/ # admin console (token: LEAN_CTX_GATEWAY_ADMIN_TOKEN in .env)
```
## 3. Hand out keys
```bash
lean-ctx gateway keys add --person alice@example.com --team platform --project checkout --file gateway-keys.toml
lean-ctx gateway keys rotate --person alice@example.com --file gateway-keys.toml # compromised/expiring key: one atomic step
docker compose restart gateway # reload the key set
```
Point clients at the proxy:
```bash
export ANTHROPIC_BASE_URL=http://<host>:{proxy_port}/anthropic
export ANTHROPIC_AUTH_TOKEN=<the person's gk-… key>
```
Model catalog and personal view (each person uses their own key):
```bash
curl -s -H 'Authorization: Bearer <gk-… key>' http://<host>:{proxy_port}/v1/models # org model aliases
open http://<host>:{proxy_port}/me # personal usage dashboard
```
## Files
| File | Purpose | Committable |
|---|---|---|
| `config.toml` | engine config (org, baseline, providers, routing) | yes |
| `docker-compose.yml` | pilot deployment | yes |
| `gateway-keys.toml` | SHA-256 key hashes (no plaintext) | yes |
| `.env` | generated secrets | **no** (gitignored) |
",
proxy_port = opts.proxy_port,
admin_port = opts.admin_port,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn opts() -> InitOptions {
InitOptions {
org_label: "Zühlke Engineering AG".into(),
seats: Some(800),
reference_model: Some("claude-opus-4.5".into()),
persons: vec!["alice@zuehlke.com".into(), "bob@zuehlke.com".into()],
proxy_port: 8484,
admin_port: 8485,
}
}
#[test]
fn init_creates_complete_runnable_instance() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("gw");
let outcome = run(&dir, &opts()).unwrap();
for f in [
"config.toml",
".env",
"docker-compose.yml",
"README.md",
"gateway-keys.toml",
".gitignore",
] {
assert!(dir.join(f).exists(), "missing {f}");
}
let cfg = std::fs::read_to_string(dir.join("config.toml")).unwrap();
let parsed: toml::Value = toml::from_str(&cfg).expect("generated config must parse");
assert_eq!(
parsed["gateway_server"]["org_label"].as_str(),
Some("Zühlke Engineering AG")
);
assert_eq!(parsed["gateway_server"]["seats"].as_integer(), Some(800));
assert_eq!(
parsed["proxy"]["baseline"]["reference_model"].as_str(),
Some("claude-opus-4.5")
);
assert_eq!(parsed["proxy_bind_host"].as_str(), Some("0.0.0.0"));
assert_eq!(parsed["proxy_require_token"].as_bool(), Some(true));
let env = std::fs::read_to_string(dir.join(".env")).unwrap();
let token_of = |name: &str| {
env.lines()
.find_map(|l| l.strip_prefix(&format!("{name}=")))
.map(str::to_string)
.unwrap_or_default()
};
let proxy_token = token_of("LEAN_CTX_PROXY_TOKEN");
assert_eq!(proxy_token.len(), 64);
assert!(env.contains(&format!(
"DATABASE_URL=postgres://leanctx:{}@postgres:5432/leanctx",
token_of("POSTGRES_PASSWORD")
)));
assert!(!cfg.contains(&proxy_token));
let compose = std::fs::read_to_string(dir.join("docker-compose.yml")).unwrap();
assert!(!compose.contains(&proxy_token));
assert!(compose.contains("service_healthy"));
assert_eq!(outcome.person_keys.len(), 2);
let keys =
crate::proxy::gateway_identity::GatewayKeys::load(&dir.join("gateway-keys.toml"))
.unwrap();
for (person, key) in &outcome.person_keys {
let tags = keys.lookup(key).expect("generated key resolves");
assert_eq!(tags.person.as_deref(), Some(person.as_str()));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.join(".env"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[test]
fn init_refuses_to_overwrite_existing_instance() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("gw");
run(&dir, &InitOptions::default()).unwrap();
let err = run(&dir, &InitOptions::default()).unwrap_err();
assert!(
err.to_string().contains("never overwrites"),
"second init must refuse: {err}"
);
}
#[test]
fn tokens_are_distinct_per_init() {
let tmp = tempfile::tempdir().unwrap();
run(&tmp.path().join("a"), &InitOptions::default()).unwrap();
run(&tmp.path().join("b"), &InitOptions::default()).unwrap();
let env_a = std::fs::read_to_string(tmp.path().join("a/.env")).unwrap();
let env_b = std::fs::read_to_string(tmp.path().join("b/.env")).unwrap();
let first_line = |s: &str| {
s.lines()
.find(|l| l.starts_with("LEAN_CTX_PROXY_TOKEN"))
.unwrap()
.to_string()
};
assert_ne!(first_line(&env_a), first_line(&env_b));
}
}