Skip to main content

cliban_server/
commands.rs

1//! Control-command router for exec requests (`ssh host <cmd>`).
2//!
3//! Pure sync: registry lookups only (blocking mutex, single-row queries),
4//! no per-tenant store access — so it is unit-testable without SSH.
5
6use chrono::Duration;
7use cliban_core::time;
8use cliban_tenancy::{Error as TenancyError, Role, Tenant, User};
9
10use crate::config::SignupPolicy;
11use crate::server::{AppState, KeyInfo};
12
13/// How long minted invites stay redeemable.
14pub const INVITE_TTL_DAYS: i64 = 7;
15
16pub const USAGE: &str = "\
17cliband commands (run as: ssh <host> <command>):
18  signup <slug> [token]   create a tenant; enrolls your key as owner
19  accept <code>           redeem an invite; enrolls your key as member
20  whoami                  show your key, user, and memberships
21  invite [slug]           mint a one-time invite code (owners only)
22  members [slug]          list a tenant's members
23";
24
25/// Command result: text for the channel, exit status for the client.
26pub struct Output {
27    pub text: String,
28    pub exit: u32,
29}
30
31fn ok(text: String) -> Output {
32    Output { text, exit: 0 }
33}
34
35fn fail(text: String) -> Output {
36    Output { text, exit: 1 }
37}
38
39fn tenancy_fail(cmd: &str, e: TenancyError) -> Output {
40    // Domain errors have user-safe Display strings ("slug has already been
41    // taken", "invite invalid or expired", ...). Infrastructure errors must
42    // not leak internals (paths, schema) to remote clients: log server-side,
43    // answer generically.
44    match e {
45        TenancyError::Sqlite(_) | TenancyError::Io(_) | TenancyError::Core(_) => {
46            eprintln!("cliband: {cmd}: {e}");
47            fail(format!("{cmd}: internal error\n"))
48        }
49        _ => fail(format!("{cmd}: {e}\n")),
50    }
51}
52
53/// Run one control command for the authenticated key. `key` is mutated when
54/// a command enrolls it (signup/accept) so later commands on the same
55/// connection see the new identity.
56pub fn run(state: &AppState, key: &mut KeyInfo, line: &str) -> Output {
57    let words: Vec<&str> = line.split_whitespace().collect();
58    match words.as_slice() {
59        ["signup", slug] => signup(state, key, slug, None),
60        ["signup", slug, token] => signup(state, key, slug, Some(token)),
61        ["accept", code] => accept(state, key, code),
62        ["whoami"] => whoami(state, key),
63        ["invite"] => invite(state, key, None),
64        ["invite", slug] => invite(state, key, Some(slug)),
65        ["members"] => members(state, key, None),
66        ["members", slug] => members(state, key, Some(slug)),
67        _ => fail(USAGE.to_string()),
68    }
69}
70
71/// Return the caller's registry user, enrolling the key (user + pubkey rows,
72/// named after the sanitized SSH username) on first touch.
73fn enroll(state: &AppState, key: &mut KeyInfo) -> Result<User, Output> {
74    if let Some(u) = &key.user {
75        return Ok(u.clone());
76    }
77    let name = display_name(&key.username);
78    let user = state
79        .manager
80        .registry()
81        .enroll_key(&name, &key.fingerprint, &key.openssh)
82        .map_err(|e| tenancy_fail("enroll", e))?;
83    key.user = Some(user.clone());
84    Ok(user)
85}
86
87/// Display names come from the client-offered SSH username. Keep a
88/// conservative charset (no control/ANSI bytes can reach other users'
89/// terminals via `members`/`whoami`) and cap the length.
90fn display_name(username: &str) -> String {
91    let name: String = username
92        .chars()
93        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
94        .take(32)
95        .collect();
96    if name.is_empty() {
97        "user".to_string()
98    } else {
99        name
100    }
101}
102
103fn signup(state: &AppState, key: &mut KeyInfo, slug: &str, token: Option<&str>) -> Output {
104    match (state.signup_policy, state.signup_token.as_deref()) {
105        (SignupPolicy::Open, _) => {}
106        (SignupPolicy::Closed, _) => return fail("signup: closed on this server\n".into()),
107        // Policy "token" with no token configured: deny-all until the
108        // operator sets signup_token (this is the shipped default config).
109        (SignupPolicy::Token, None) => {
110            return fail("signup: closed on this server (no signup token configured)\n".into())
111        }
112        (SignupPolicy::Token, Some(want)) => {
113            if token != Some(want) {
114                return fail("signup: invalid or missing signup token\n".into());
115            }
116        }
117    }
118    let user = match enroll(state, key) {
119        Ok(u) => u,
120        Err(out) => return out,
121    };
122    match state.manager.create_tenant(user.id, slug) {
123        Ok(t) => ok(format!(
124            "created tenant '{}'; your key is enrolled as owner\n",
125            t.slug
126        )),
127        Err(e) => tenancy_fail("signup", e),
128    }
129}
130
131fn whoami(state: &AppState, key: &KeyInfo) -> Output {
132    let Some(user) = &key.user else {
133        return ok(format!(
134            "key {} is not enrolled\n\n{}",
135            key.fingerprint, USAGE
136        ));
137    };
138    let mut text = format!("user: {}\nkey: {}\n", user.name, key.fingerprint);
139    match state.manager.registry().tenants_for_user(user.id) {
140        Ok(ts) if ts.is_empty() => text.push_str("memberships: none\n"),
141        Ok(ts) => {
142            text.push_str("memberships:\n");
143            for (t, role) in ts {
144                text.push_str(&format!("  {} ({})\n", t.slug, role.as_str()));
145            }
146        }
147        Err(e) => return tenancy_fail("whoami", e),
148    }
149    ok(text)
150}
151
152/// Resolve which tenant a command targets: an explicit slug must be one of
153/// the caller's memberships; no slug is allowed only when the caller belongs
154/// to exactly one tenant.
155fn resolve_tenant(
156    state: &AppState,
157    user: &User,
158    slug: Option<&str>,
159    cmd: &str,
160) -> Result<(Tenant, Role), Output> {
161    let ts = state
162        .manager
163        .registry()
164        .tenants_for_user(user.id)
165        .map_err(|e| tenancy_fail(cmd, e))?;
166    match slug {
167        Some(s) => ts
168            .into_iter()
169            .find(|(t, _)| t.slug == s)
170            .ok_or_else(|| fail(format!("{cmd}: you are not a member of '{s}'\n"))),
171        None => match ts.len() {
172            0 => Err(fail(format!("{cmd}: you have no tenant memberships\n"))),
173            1 => Ok(ts.into_iter().next().expect("len checked")),
174            _ => {
175                let slugs: Vec<String> = ts.into_iter().map(|(t, _)| t.slug).collect();
176                Err(fail(format!(
177                    "{cmd}: you belong to several tenants, pass a slug: {}\n",
178                    slugs.join(", ")
179                )))
180            }
181        },
182    }
183}
184
185fn accept(state: &AppState, key: &mut KeyInfo, code: &str) -> Output {
186    // Enroll first (the registry needs a user id to redeem). A bad code
187    // after enrollment leaves a known key with zero memberships — harmless,
188    // and whoami reports it truthfully.
189    let user = match enroll(state, key) {
190        Ok(u) => u,
191        Err(out) => return out,
192    };
193    match state.manager.registry().redeem_invite(code, user.id) {
194        Ok(t) => ok(format!("joined tenant '{}' as member\n", t.slug)),
195        Err(e) => tenancy_fail("accept", e),
196    }
197}
198
199fn invite(state: &AppState, key: &KeyInfo, slug: Option<&str>) -> Output {
200    let Some(user) = &key.user else {
201        return fail(format!("invite: your key is not enrolled\n\n{USAGE}"));
202    };
203    let (tenant, role) = match resolve_tenant(state, user, slug, "invite") {
204        Ok(v) => v,
205        Err(out) => return out,
206    };
207    if role != Role::Owner {
208        return fail(format!(
209            "invite: only owners of '{}' can invite\n",
210            tenant.slug
211        ));
212    }
213    let expires = time::format_usec(time::now_usec() + Duration::days(INVITE_TTL_DAYS));
214    match state.manager.registry().create_invite(&tenant.id, &expires) {
215        Ok(inv) => ok(format!(
216            "invite code for '{}': {}\nexpires: {}\nredeem with: ssh <host> accept {}\n",
217            tenant.slug, inv.code, inv.expires_at, inv.code
218        )),
219        Err(e) => tenancy_fail("invite", e),
220    }
221}
222
223fn members(state: &AppState, key: &KeyInfo, slug: Option<&str>) -> Output {
224    let Some(user) = &key.user else {
225        return fail(format!("members: your key is not enrolled\n\n{USAGE}"));
226    };
227    let (tenant, _role) = match resolve_tenant(state, user, slug, "members") {
228        Ok(v) => v,
229        Err(out) => return out,
230    };
231    match state.manager.registry().members(&tenant.id) {
232        Ok(ms) => {
233            let mut text = format!("members of '{}':\n", tenant.slug);
234            for (u, role) in ms {
235                text.push_str(&format!("  {} ({})\n", u.name, role.as_str()));
236            }
237            ok(text)
238        }
239        Err(e) => tenancy_fail("members", e),
240    }
241}