use chrono::Duration;
use cliban_core::time;
use cliban_tenancy::{Error as TenancyError, Role, Tenant, User};
use crate::config::SignupPolicy;
use crate::server::{AppState, KeyInfo};
pub const INVITE_TTL_DAYS: i64 = 7;
pub const USAGE: &str = "\
cliband commands (run as: ssh <host> <command>):
signup <slug> [token] create a tenant; enrolls your key as owner
accept <code> redeem an invite; enrolls your key as member
whoami show your key, user, and memberships
invite [slug] mint a one-time invite code (owners only)
members [slug] list a tenant's members
";
pub struct Output {
pub text: String,
pub exit: u32,
}
fn ok(text: String) -> Output {
Output { text, exit: 0 }
}
fn fail(text: String) -> Output {
Output { text, exit: 1 }
}
fn tenancy_fail(cmd: &str, e: TenancyError) -> Output {
match e {
TenancyError::Sqlite(_) | TenancyError::Io(_) | TenancyError::Core(_) => {
eprintln!("cliband: {cmd}: {e}");
fail(format!("{cmd}: internal error\n"))
}
_ => fail(format!("{cmd}: {e}\n")),
}
}
pub fn run(state: &AppState, key: &mut KeyInfo, line: &str) -> Output {
let words: Vec<&str> = line.split_whitespace().collect();
match words.as_slice() {
["signup", slug] => signup(state, key, slug, None),
["signup", slug, token] => signup(state, key, slug, Some(token)),
["accept", code] => accept(state, key, code),
["whoami"] => whoami(state, key),
["invite"] => invite(state, key, None),
["invite", slug] => invite(state, key, Some(slug)),
["members"] => members(state, key, None),
["members", slug] => members(state, key, Some(slug)),
_ => fail(USAGE.to_string()),
}
}
fn enroll(state: &AppState, key: &mut KeyInfo) -> Result<User, Output> {
if let Some(u) = &key.user {
return Ok(u.clone());
}
let name = display_name(&key.username);
let user = state
.manager
.registry()
.enroll_key(&name, &key.fingerprint, &key.openssh)
.map_err(|e| tenancy_fail("enroll", e))?;
key.user = Some(user.clone());
Ok(user)
}
fn display_name(username: &str) -> String {
let name: String = username
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
.take(32)
.collect();
if name.is_empty() {
"user".to_string()
} else {
name
}
}
fn signup(state: &AppState, key: &mut KeyInfo, slug: &str, token: Option<&str>) -> Output {
match (state.signup_policy, state.signup_token.as_deref()) {
(SignupPolicy::Open, _) => {}
(SignupPolicy::Closed, _) => return fail("signup: closed on this server\n".into()),
(SignupPolicy::Token, None) => {
return fail("signup: closed on this server (no signup token configured)\n".into())
}
(SignupPolicy::Token, Some(want)) => {
if token != Some(want) {
return fail("signup: invalid or missing signup token\n".into());
}
}
}
let user = match enroll(state, key) {
Ok(u) => u,
Err(out) => return out,
};
match state.manager.create_tenant(user.id, slug) {
Ok(t) => ok(format!(
"created tenant '{}'; your key is enrolled as owner\n",
t.slug
)),
Err(e) => tenancy_fail("signup", e),
}
}
fn whoami(state: &AppState, key: &KeyInfo) -> Output {
let Some(user) = &key.user else {
return ok(format!(
"key {} is not enrolled\n\n{}",
key.fingerprint, USAGE
));
};
let mut text = format!("user: {}\nkey: {}\n", user.name, key.fingerprint);
match state.manager.registry().tenants_for_user(user.id) {
Ok(ts) if ts.is_empty() => text.push_str("memberships: none\n"),
Ok(ts) => {
text.push_str("memberships:\n");
for (t, role) in ts {
text.push_str(&format!(" {} ({})\n", t.slug, role.as_str()));
}
}
Err(e) => return tenancy_fail("whoami", e),
}
ok(text)
}
fn resolve_tenant(
state: &AppState,
user: &User,
slug: Option<&str>,
cmd: &str,
) -> Result<(Tenant, Role), Output> {
let ts = state
.manager
.registry()
.tenants_for_user(user.id)
.map_err(|e| tenancy_fail(cmd, e))?;
match slug {
Some(s) => ts
.into_iter()
.find(|(t, _)| t.slug == s)
.ok_or_else(|| fail(format!("{cmd}: you are not a member of '{s}'\n"))),
None => match ts.len() {
0 => Err(fail(format!("{cmd}: you have no tenant memberships\n"))),
1 => Ok(ts.into_iter().next().expect("len checked")),
_ => {
let slugs: Vec<String> = ts.into_iter().map(|(t, _)| t.slug).collect();
Err(fail(format!(
"{cmd}: you belong to several tenants, pass a slug: {}\n",
slugs.join(", ")
)))
}
},
}
}
fn accept(state: &AppState, key: &mut KeyInfo, code: &str) -> Output {
let user = match enroll(state, key) {
Ok(u) => u,
Err(out) => return out,
};
match state.manager.registry().redeem_invite(code, user.id) {
Ok(t) => ok(format!("joined tenant '{}' as member\n", t.slug)),
Err(e) => tenancy_fail("accept", e),
}
}
fn invite(state: &AppState, key: &KeyInfo, slug: Option<&str>) -> Output {
let Some(user) = &key.user else {
return fail(format!("invite: your key is not enrolled\n\n{USAGE}"));
};
let (tenant, role) = match resolve_tenant(state, user, slug, "invite") {
Ok(v) => v,
Err(out) => return out,
};
if role != Role::Owner {
return fail(format!(
"invite: only owners of '{}' can invite\n",
tenant.slug
));
}
let expires = time::format_usec(time::now_usec() + Duration::days(INVITE_TTL_DAYS));
match state.manager.registry().create_invite(&tenant.id, &expires) {
Ok(inv) => ok(format!(
"invite code for '{}': {}\nexpires: {}\nredeem with: ssh <host> accept {}\n",
tenant.slug, inv.code, inv.expires_at, inv.code
)),
Err(e) => tenancy_fail("invite", e),
}
}
fn members(state: &AppState, key: &KeyInfo, slug: Option<&str>) -> Output {
let Some(user) = &key.user else {
return fail(format!("members: your key is not enrolled\n\n{USAGE}"));
};
let (tenant, _role) = match resolve_tenant(state, user, slug, "members") {
Ok(v) => v,
Err(out) => return out,
};
match state.manager.registry().members(&tenant.id) {
Ok(ms) => {
let mut text = format!("members of '{}':\n", tenant.slug);
for (u, role) in ms {
text.push_str(&format!(" {} ({})\n", u.name, role.as_str()));
}
ok(text)
}
Err(e) => tenancy_fail("members", e),
}
}