1use 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
13pub 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
25pub 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 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
53pub 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
71fn 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
87fn 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 (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
152fn 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 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}