🗝
summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/ipc.rs83
-rw-r--r--src/main.rs12
-rw-r--r--src/server/admin.rs93
-rw-r--r--src/server/config.rs29
-rw-r--r--src/server/login.rs208
-rw-r--r--src/server/mod.rs160
-rw-r--r--src/server/nginx_check.rs41
-rw-r--r--src/server/panel.rs30
-rw-r--r--src/server/store.rs330
-rw-r--r--src/tui/accounts.rs169
-rw-r--r--src/tui/mod.rs159
11 files changed, 1314 insertions, 0 deletions
diff --git a/src/ipc.rs b/src/ipc.rs
new file mode 100644
index 0000000..f3e7d0a
--- /dev/null
+++ b/src/ipc.rs
@@ -0,0 +1,83 @@
+use std::fmt::{Display, Formatter};
+
+use bincode::{Decode, Encode};
+use paste::paste;
+
+pub trait RequestDefinition: Encode {
+    type Response: ResponseDefinition;
+    fn into_request(self) -> IPCRequest;
+}
+
+pub trait ResponseDefinition: Sized + Decode {
+    type Error: std::error::Error;
+    fn from_response(response: IPCResponse) -> Option<Result<Self, Self::Error>>;
+    fn into_response(self) -> IPCResponse;
+}
+
+macro_rules! define {
+    ($(@$name:ident { $($req_n:ident: $req_t:ty),* } => { $($resp_n:ident: $resp_t:ty)* } | $($error:ident),*)*) => {
+        $(
+            paste! {
+                #[derive(Encode, Decode)]
+                pub struct [<$name Request>] { $(pub $req_n: $req_t),* }
+                impl RequestDefinition for [<$name Request>] {
+                    type Response = [<$name Response>];
+                    fn into_request(self) -> IPCRequest {
+                        IPCRequest::$name(self)
+                    }
+                }
+
+                #[derive(Encode, Decode)]
+                pub struct [<$name Response>] { $(pub $resp_n: $resp_t),* }
+                impl ResponseDefinition for [<$name Response>] {
+                    type Error = [<$name Error>];
+                    fn from_response(response: IPCResponse) -> Option<Result<Self, Self::Error>> {
+                        match response {
+                            IPCResponse::$name(inner) => Some(inner),
+                            _ => None,
+                        }
+                    }
+                    fn into_response(self) -> IPCResponse {
+                        IPCResponse::$name(Ok(self))
+                    }
+                }
+
+                #[derive(Encode, Decode, Debug)]
+                pub enum [<$name Error>] {
+                    $($error,)*
+                }
+
+                impl Display for [<$name Error>] {
+                    #[allow(unused)]
+                    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+                        match *self {
+                            $(Self::$error => f.write_str(stringify!($error)),)*
+                        }
+                    }
+                }
+
+                impl std::error::Error for [<$name Error>] {}
+            }
+        )*
+        #[derive(Encode, Decode)]
+        pub enum IPCRequest {
+            $(
+                $name(paste!([<$name Request>])),
+            )*
+        }
+        #[derive(Encode, Decode)]
+        pub enum IPCResponse {
+            $(
+                $name(Result<paste!([<$name Response>]), paste!([<$name Error>])>),
+            )*
+        }
+    };
+}
+
+define! {
+    @ListAccounts {} => { names: Vec<String> } |
+    @GetAccount { name: String } => { scopes: Vec<String> } | NotFound
+    @DeleteAccount { name: String } => {} |
+    @CreateInvite {} => { link: String } |
+    @UpdateScopes { account: String, scopes: Vec<String> } => {} | NotFound
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..936f37c
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,12 @@
+mod ipc;
+mod server;
+mod tui;
+
+#[tokio::main]
+async fn main() -> eyre::Result<()> {
+    match std::env::args().skip(1).next().as_deref() {
+        Some("serve") | None => server::serve().await,
+        Some("ctl") => tui::run(),
+        _ => Err(eyre::eyre!("unknown command")),
+    }
+}
diff --git a/src/server/admin.rs b/src/server/admin.rs
new file mode 100644
index 0000000..54bdf12
--- /dev/null
+++ b/src/server/admin.rs
@@ -0,0 +1,93 @@
+use std::{future::Future, path::Path};
+
+use tap::Pipe;
+use tokio::{
+    io::{AsyncReadExt, AsyncWriteExt},
+    net::{UnixListener, UnixStream},
+};
+
+use crate::ipc::*;
+
+use super::store::Store;
+
+pub fn serve(
+    bind: &Path,
+    web_base: String,
+    store: Store,
+) -> eyre::Result<impl Future<Output = eyre::Result<()>>> {
+    if bind.exists() {
+        std::fs::remove_file(bind)?;
+    }
+    let listener = UnixListener::bind(bind)?;
+
+    Ok(async move {
+        loop {
+            match listener.accept().await {
+                Ok((stream, _)) => tokio::spawn({
+                    let store = store.clone();
+                    let web_base = web_base.clone();
+                    async move {
+                        if let Err(err) = handle(stream, store, &web_base).await {
+                            eprintln!("error handling admin connection: {err:?}");
+                        }
+                    }
+                }),
+                Err(err) => {
+                    eprintln!("error accepting admin connection: {err:?}");
+                    continue;
+                }
+            };
+        }
+    })
+}
+
+async fn handle(mut stream: UnixStream, store: Store, web_base: &str) -> eyre::Result<()> {
+    while let Ok(request_length) = stream.read_u16().await {
+        let mut request_buffer = vec![0u8; request_length as usize];
+        stream.read_exact(&mut request_buffer).await?;
+        let (request, _): (IPCRequest, _) =
+            bincode::decode_from_slice(&request_buffer, bincode::config::standard())?;
+        let response = match request {
+            IPCRequest::ListAccounts(_) => ListAccountsResponse {
+                names: store.list_accounts().await,
+            }
+            .into_response(),
+            IPCRequest::GetAccount(GetAccountRequest { name }) => {
+                IPCResponse::GetAccount(match store.get_account(&name).await {
+                    Some(account) => Ok(GetAccountResponse {
+                        scopes: account.scopes.clone(),
+                    }),
+                    None => Err(GetAccountError::NotFound),
+                })
+            }
+            IPCRequest::DeleteAccount(DeleteAccountRequest { name }) => {
+                store.delete_account(&name).await?;
+                DeleteAccountResponse {}.into_response()
+            }
+            IPCRequest::CreateInvite(_) => store
+                .create_invite()
+                .await?
+                .pipe(|invite| CreateInviteResponse {
+                    link: format!("{web_base}/invite/{invite}"),
+                })
+                .into_response(),
+            IPCRequest::UpdateScopes(UpdateScopesRequest { account, scopes }) => {
+                IPCResponse::UpdateScopes(
+                    match store
+                        .update_account(&account, |account| {
+                            account.scopes = scopes;
+                        })
+                        .await?
+                    {
+                        true => Ok(UpdateScopesResponse {}),
+                        false => Err(UpdateScopesError::NotFound),
+                    },
+                )
+            }
+        };
+        let response_buffer = bincode::encode_to_vec(response, bincode::config::standard())?;
+        stream.write_u16(response_buffer.len() as u16).await?;
+        stream.write_all(&response_buffer).await?;
+    }
+    Ok(())
+}
diff --git a/src/server/config.rs b/src/server/config.rs
new file mode 100644
index 0000000..4563f34
--- /dev/null
+++ b/src/server/config.rs
@@ -0,0 +1,29 @@
+use std::net::SocketAddr;
+use std::path::{Path, PathBuf};
+
+use eyre::Context;
+use tap::Pipe;
+
+#[derive(serde::Deserialize)]
+pub struct Config {
+    pub web_socket: SocketAddr,
+    pub web_base: String,
+    pub cookie_domain: Option<String>,
+    pub admin_socket: PathBuf,
+    pub data: PathBuf,
+}
+
+impl Config {
+    pub fn load(path: &Path) -> eyre::Result<Self> {
+        let mut config: Config = path
+            .pipe(std::fs::read_to_string)
+            .wrap_err("reading config file")?
+            .as_str()
+            .pipe(toml::from_str)
+            .wrap_err("parsing config file")?;
+        if config.web_base.ends_with('/') {
+            config.web_base = config.web_base.trim_end_matches('/').to_string();
+        }
+        Ok(config)
+    }
+}
diff --git a/src/server/login.rs b/src/server/login.rs
new file mode 100644
index 0000000..f9cee70
--- /dev/null
+++ b/src/server/login.rs
@@ -0,0 +1,208 @@
+use std::time::Duration;
+
+use argon2::{Argon2, PasswordVerifier};
+use axum::{
+    extract::{Path, State},
+    http::StatusCode,
+    response::{IntoResponse, Redirect, Response},
+    routing::get,
+    Form, Router,
+};
+use axum_extra::extract::{
+    cookie::{Cookie, SameSite},
+    CookieJar,
+};
+use maud::{html, PreEscaped};
+use tap::Tap;
+
+use crate::server::store::Store;
+
+use super::{render_html, ApiState, CookieDomain, MakeError, MakeErrorMessage};
+
+pub fn bind(app: Router<ApiState>) -> Router<ApiState> {
+    app.route("/login", get(handle_login_get).post(handle_login_post))
+        .route("/logout", get(logout))
+        .route(
+            "/invite/:invite",
+            get(handle_invite_get).post(handle_invite_post),
+        )
+}
+
+fn make_form(status: StatusCode, error: Option<&str>, submit: &str) -> Response {
+    render_html(
+        html!(title {"login"}),
+        html! {
+            @if let Some(error) = error {
+                p { "error: " (PreEscaped(error)) }
+            }
+            form action="" method="post" {
+                label for="name" { "name:" }
+                input type="text" id="name" name="name" {}
+                label for="password" { "password:" }
+                input type="password" id="password" name="password" {}
+                input type="submit" value=(submit) {}
+            }
+        },
+    )
+    .tap_mut(|response| *response.status_mut() = status)
+}
+
+#[axum::debug_handler]
+async fn handle_login_get() -> Response {
+    make_form(StatusCode::OK, None, "login")
+}
+
+#[derive(serde::Deserialize)]
+struct FormData {
+    name: String,
+    password: String,
+}
+
+#[axum::debug_handler(state = ApiState)]
+async fn handle_login_post(
+    jar: CookieJar,
+    State(store): State<Store>,
+    State(CookieDomain(cookie_domain)): State<CookieDomain>,
+    Form(form): Form<FormData>,
+) -> Result<Response, Response> {
+    // read lock scope
+    {
+        let account = store.get_account(&form.name).await.ok_or_else(|| {
+            make_form(StatusCode::FORBIDDEN, Some("invalid credentials"), "login")
+        })?;
+        Argon2::default()
+            .verify_password(form.password.as_bytes(), &account.password.parsed())
+            .ok()
+            .ok_or_else(|| {
+                make_form(StatusCode::FORBIDDEN, Some("invalid credentials"), "login")
+            })?;
+    }
+    let token = store
+        .create_token(&form.name)
+        .await
+        .make_error()?
+        .error_message(
+            StatusCode::INTERNAL_SERVER_ERROR,
+            "account disappeared while generating token",
+        )?;
+
+    let mut cookie = Cookie::new("dissociate-token", token);
+    cookie.set_http_only(true);
+    cookie.set_max_age(Duration::from_secs(60 * 60 * 24 * 29).try_into().ok());
+    cookie.set_path("/");
+    cookie.set_same_site(SameSite::Strict);
+    cookie.set_secure(true);
+    if let Some(cookie_domain) = cookie_domain {
+        cookie.set_domain(cookie_domain);
+    }
+
+    Ok((jar.add(cookie), Redirect::to("/")).into_response())
+}
+
+#[axum::debug_handler]
+async fn logout(jar: CookieJar) -> Response {
+    (
+        jar.remove(Cookie::from("dissociate-token")),
+        Redirect::to("/login"),
+    )
+        .into_response()
+}
+
+fn invite_error() -> Response {
+    render_html(
+        html!(),
+        html! {
+            h1 { "unknown invite" }
+            a href="/" { "return home" }
+        },
+    )
+    .tap_mut(|response| *response.status_mut() = StatusCode::NOT_FOUND)
+}
+
+#[axum::debug_handler]
+async fn handle_invite_get(Path(invite): Path<String>, State(store): State<Store>) -> Response {
+    if !store.check_invite(&invite).await {
+        return invite_error();
+    }
+    make_form(StatusCode::OK, None, "create account")
+}
+
+const NAME_ERROR: &str = "invalid name
+requirements:
+must not be empty
+must not exceed 32 characters
+must not contain control characters";
+#[axum::debug_handler(state = ApiState)]
+async fn handle_invite_post(
+    jar: CookieJar,
+    Path(invite): Path<String>,
+    State(store): State<Store>,
+    State(CookieDomain(cookie_domain)): State<CookieDomain>,
+    Form(form): Form<FormData>,
+) -> Result<Response, Response> {
+    if form.name.to_ascii_lowercase() == "empty" || form.password.to_ascii_lowercase() == "empty" {
+        return Err(make_form(
+            StatusCode::BAD_REQUEST,
+            Some("are you jokester?"),
+            "create account",
+        ));
+    }
+    if form.name.is_empty() || form.name.len() > 32 || form.name.chars().any(|ch| ch.is_control()) {
+        return Err(make_form(
+            StatusCode::BAD_REQUEST,
+            Some(&NAME_ERROR.replace('\n', "<br>")),
+            "create account",
+        ));
+    }
+    if form.password.is_empty() {
+        return Err(make_form(
+            StatusCode::BAD_REQUEST,
+            Some("password cannot be empty"),
+            "create account",
+        ));
+    }
+
+    if store.get_account(&form.name).await.is_some() {
+        return Err(make_form(
+            StatusCode::CONFLICT,
+            Some("name taken"),
+            "create account",
+        ));
+    }
+
+    if !store.use_invite(&invite).await.make_error()? {
+        return Err(invite_error());
+    }
+
+    if !store
+        .create_account(&form.name, &form.password)
+        .await
+        .make_error()?
+    {
+        return Err(make_form(
+            StatusCode::CONFLICT,
+            Some("name taken"),
+            "create account",
+        ));
+    }
+    let token = store
+        .create_token(&form.name)
+        .await
+        .make_error()?
+        .error_message(
+            StatusCode::INTERNAL_SERVER_ERROR,
+            "account disappeared while generating token",
+        )?;
+
+    let mut cookie = Cookie::new("dissociate-token", token);
+    cookie.set_http_only(true);
+    cookie.set_max_age(Duration::from_secs(60 * 60 * 24 * 29).try_into().ok());
+    cookie.set_path("/");
+    cookie.set_same_site(SameSite::Strict);
+    cookie.set_secure(true);
+    if let Some(cookie_domain) = cookie_domain {
+        cookie.set_domain(cookie_domain);
+    }
+
+    Ok((jar.add(cookie), Redirect::to("/")).into_response())
+}
diff --git a/src/server/mod.rs b/src/server/mod.rs
new file mode 100644
index 0000000..d9b3beb
--- /dev/null
+++ b/src/server/mod.rs
@@ -0,0 +1,160 @@
+mod admin;
+mod config;
+mod login;
+mod nginx_check;
+mod panel;
+mod store;
+
+use std::{future::IntoFuture, path::PathBuf};
+
+use axum::{
+    body::Body,
+    extract::FromRef,
+    http::{header::CONTENT_TYPE, StatusCode},
+    response::{IntoResponse, Redirect, Response},
+    routing::get,
+    Router,
+};
+use axum_extra::extract::CookieJar;
+use eyre::Context;
+use maud::{html, PreEscaped};
+use tap::Pipe;
+use tokio::{net::TcpListener, select};
+
+use crate::server::store::Store;
+
+use self::config::Config;
+
+pub async fn serve() -> eyre::Result<()> {
+    let config_path = std::env::args()
+        .skip(2)
+        .next()
+        .map(PathBuf::from)
+        .unwrap_or_else(|| std::env::current_dir().unwrap().join("dissociate.toml"));
+    let config = Config::load(&config_path)?;
+
+    let store = Store::load(config.data).await?;
+    println!("admin listening on {:?}", config.admin_socket);
+    let admin_serve = admin::serve(&config.admin_socket, config.web_base.clone(), store.clone())?;
+
+    let listener = TcpListener::bind(config.web_socket).await?;
+
+    let app = Router::new()
+        .pipe(login::bind)
+        .pipe(nginx_check::bind)
+        .pipe(panel::bind)
+        .with_state(ApiState {
+            store,
+            cookie_domain: CookieDomain(config.cookie_domain),
+            web_base: WebBase(config.web_base),
+        })
+        .fallback(get(|| async {
+            render_html(
+                html!(title { "not found" }),
+                html! {
+                    h1 { "404 not found" }
+                    p {"sowwy :("}
+                },
+            )
+        }));
+
+    println!("web listening on {:?}", config.web_socket);
+    let web_serve = axum::serve(listener, app).into_future();
+
+    select! {
+        res = admin_serve => res.wrap_err("in admin"),
+        res = web_serve => res.wrap_err("in web"),
+    }
+}
+
+#[derive(Clone, FromRef)]
+struct ApiState {
+    pub store: Store,
+    pub cookie_domain: CookieDomain,
+    pub web_base: WebBase,
+}
+
+#[derive(Clone)]
+struct CookieDomain(Option<String>);
+
+#[derive(Clone)]
+struct WebBase(String);
+
+fn render_html(head: PreEscaped<impl AsRef<str>>, body: PreEscaped<impl AsRef<str>>) -> Response {
+    let html = html! {
+        (PreEscaped("<!doctype html>"))
+        html {
+            head {(head)}
+            body {(body)}
+        }
+    }
+    .into_string();
+    Response::builder()
+        .header(CONTENT_TYPE, "text/html; charset=utf-8")
+        .body(Body::new(html))
+        .unwrap()
+}
+
+trait MakeErrorMessage<T> {
+    fn error_message(self, status: StatusCode, message: impl ToString) -> Result<T, Response>;
+}
+
+impl<T, E: ToString> MakeErrorMessage<T> for Result<T, E> {
+    fn error_message(self, status: StatusCode, message: impl ToString) -> Result<T, Response> {
+        self.map_err(|err| {
+            render_html(
+                html!(title { "error" }),
+                html! {
+                    h1 { (status.canonical_reason().unwrap_or_else(|| status.as_str())) }
+                    pre { (err.to_string()) ": " (message.to_string()) }
+                },
+            )
+        })
+    }
+}
+
+impl<T> MakeErrorMessage<T> for Option<T> {
+    fn error_message(self, status: StatusCode, message: impl ToString) -> Result<T, Response> {
+        self.ok_or_else(|| {
+            render_html(
+                html!(title { "error" }),
+                html! {
+                    h1 { (status.canonical_reason().unwrap_or_else(|| status.as_str())) }
+                    pre { (message.to_string()) }
+                },
+            )
+        })
+    }
+}
+
+trait MakeError<T> {
+    fn make_error(self) -> Result<T, Response>;
+}
+
+impl<T> MakeError<T> for std::io::Result<T> {
+    fn make_error(self) -> Result<T, Response> {
+        self.error_message(StatusCode::INTERNAL_SERVER_ERROR, "internal io error")
+    }
+}
+
+trait Nevermind<T> {
+    fn prompt_login(self) -> Result<T, Response>;
+    fn prompt_logout(self) -> Result<T, Response>;
+}
+
+impl<T> Nevermind<T> for Option<T> {
+    fn prompt_login(self) -> Result<T, Response> {
+        self.ok_or_else(|| Redirect::to("/login").into_response())
+    }
+
+    fn prompt_logout(self) -> Result<T, Response> {
+        self.ok_or_else(|| Redirect::to("/login").into_response())
+    }
+}
+
+async fn account_auth(jar: &CookieJar, store: &Store) -> Option<String> {
+    let cookie = jar.get("dissociate-token")?;
+    let token = cookie.value();
+    let name = store.check_token(token).await?;
+    Some(name)
+}
diff --git a/src/server/nginx_check.rs b/src/server/nginx_check.rs
new file mode 100644
index 0000000..7b67f26
--- /dev/null
+++ b/src/server/nginx_check.rs
@@ -0,0 +1,41 @@
+// for ngx_http_auth_request_module authentication
+// make sure you have cookie_domain set properly
+// depends on https://git.mia.jetzt/sysconf/tree/patches/nginx_auth_redirect.patch
+
+use axum::{
+    extract::{Path, State},
+    http::StatusCode,
+    response::{IntoResponse, Redirect, Response},
+    routing::get,
+    Router,
+};
+use axum_extra::extract::CookieJar;
+
+use crate::server::{account_auth, store::Store};
+
+use super::{ApiState, WebBase};
+
+pub fn bind(app: Router<ApiState>) -> Router<ApiState> {
+    app.route("/nginx_check/:scope", get(nginx_check))
+}
+
+#[axum::debug_handler(state = ApiState)]
+async fn nginx_check(
+    jar: CookieJar,
+    Path(scope): Path<String>,
+    State(store): State<Store>,
+    State(WebBase(web_base)): State<WebBase>,
+) -> Response {
+    let nevermind = || Redirect::to(&format!("{web_base}/logout")).into_response();
+    let Some(name) = account_auth(&jar, &store).await else {
+        return nevermind();
+    };
+    let Some(account) = store.get_account(&name).await else {
+        return nevermind();
+    };
+    if account.scopes.contains(&scope) {
+        StatusCode::OK.into_response()
+    } else {
+        StatusCode::FORBIDDEN.into_response()
+    }
+}
diff --git a/src/server/panel.rs b/src/server/panel.rs
new file mode 100644
index 0000000..addb0d8
--- /dev/null
+++ b/src/server/panel.rs
@@ -0,0 +1,30 @@
+use axum::{extract::State, response::Response, routing::get, Router};
+use axum_extra::extract::CookieJar;
+use maud::html;
+use tap::Pipe;
+
+use crate::server::{store::Store, Nevermind};
+
+use super::{account_auth, render_html, ApiState};
+
+pub fn bind(app: Router<ApiState>) -> Router<ApiState> {
+    app.route("/", get(get_panel))
+}
+
+#[axum::debug_handler(state = ApiState)]
+async fn get_panel(jar: CookieJar, State(store): State<Store>) -> Result<Response, Response> {
+    Ok(account_auth(&jar, &store)
+        .await
+        .prompt_login()?
+        .pipe(render_normal_panel))
+}
+
+fn render_normal_panel(name: String) -> Response {
+    render_html(
+        html!(title { "dissociate" }),
+        html! {
+            p { "currently logged in as " (name) }
+            a href="/logout" { button { "log out" } }
+        },
+    )
+}
diff --git a/src/server/store.rs b/src/server/store.rs
new file mode 100644
index 0000000..98c1bcc
--- /dev/null
+++ b/src/server/store.rs
@@ -0,0 +1,330 @@
+use std::{
+    collections::HashMap,
+    path::PathBuf,
+    sync::Arc,
+    time::{Duration, SystemTime, UNIX_EPOCH},
+};
+
+use argon2::{
+    password_hash::{rand_core::OsRng, Encoding, PasswordHashString, SaltString},
+    Argon2, PasswordHash, PasswordHasher,
+};
+use nanoid::nanoid;
+use serde::{de::Unexpected, Deserialize, Serialize};
+use serde_json::{Map, Value};
+use tap::{Pipe, Tap};
+use tokio::sync::{RwLock, RwLockReadGuard};
+
+#[derive(Clone)]
+pub struct Store(Arc<RwLock<StoreInner>>);
+
+impl Store {
+    pub async fn load(path: PathBuf) -> eyre::Result<Self> {
+        let mut inner = StoreInner::new(path.clone());
+        if path.try_exists()? {
+            let mut map: Map<String, Value> =
+                { std::fs::File::open(&path)?.pipe(serde_json::from_reader)? };
+            let accounts: Vec<Account> =
+                serde_json::from_value(map.remove("accounts").unwrap_or_default())?;
+            let apps: Vec<App> = serde_json::from_value(map.remove("apps").unwrap_or_default())?;
+            for mut account in accounts {
+                account.tokens = account
+                    .tokens
+                    .into_iter()
+                    .filter(|token| token.expires > SystemTime::now())
+                    .collect();
+                for token in &account.tokens {
+                    inner.token_map.insert(
+                        token.value.clone(),
+                        (account.name.clone(), token.expires.clone()),
+                    );
+                }
+                inner.accounts.insert(account.name.clone(), account);
+            }
+            for app in apps {
+                inner.apps.insert(app.name.clone(), app);
+            }
+            inner.invites = serde_json::from_value(map.remove("invites").unwrap_or_default())?;
+            inner.invites = inner
+                .invites
+                .into_iter()
+                .filter(|invite| invite.expires > SystemTime::now())
+                .collect();
+        } else {
+            Self::save(&mut inner).await?;
+        }
+        Ok(Store(Arc::new(RwLock::new(inner))))
+    }
+
+    async fn save(inner: &mut StoreInner) -> std::io::Result<()> {
+        let mut map: Map<String, Value> = Map::new();
+        map.insert(
+            "accounts".into(),
+            serde_json::to_value(inner.accounts.values().collect::<Vec<_>>()).unwrap(),
+        );
+        map.insert(
+            "apps".into(),
+            serde_json::to_value(inner.apps.values().collect::<Vec<_>>()).unwrap(),
+        );
+        map.insert(
+            "invites".into(),
+            serde_json::to_value(&inner.invites).unwrap(),
+        );
+        let data = serde_json::to_vec_pretty(&map).unwrap();
+        let mut temp = inner.path.clone();
+        temp.set_file_name(
+            temp.file_name()
+                .unwrap_or_default()
+                .to_os_string()
+                .tap_mut(|name| name.push(".tmp")),
+        );
+        tokio::fs::write(&temp, data).await?;
+        tokio::fs::rename(temp, &inner.path).await?;
+        Ok(())
+    }
+
+    pub async fn get_account(&self, name: &str) -> Option<RwLockReadGuard<'_, Account>> {
+        let guard = self.0.read().await;
+        RwLockReadGuard::try_map(guard, |guard| guard.accounts.get(name)).ok()
+    }
+
+    pub async fn create_account(&self, name: &str, password: &str) -> std::io::Result<bool> {
+        let hash = Argon2::default()
+            .hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
+            .unwrap()
+            .pipe(|hash| hash.serialize())
+            .pipe(OwnedPasswordHash::from);
+        let mut guard = self.0.write().await;
+        if guard.accounts.get(name).is_some() {
+            return Ok(false);
+        }
+        guard.accounts.insert(
+            name.to_string(),
+            Account {
+                name: name.to_string(),
+                password: hash,
+                tokens: Default::default(),
+                scopes: Default::default(),
+            },
+        );
+        Self::save(&mut guard).await?;
+        Ok(true)
+    }
+
+    pub async fn delete_account(&self, name: &str) -> std::io::Result<()> {
+        let mut guard = self.0.write().await;
+        if let Some(account) = guard.accounts.remove(name) {
+            for token in account.tokens {
+                guard.token_map.remove(&token.value);
+            }
+            Self::save(&mut guard).await
+        } else {
+            Ok(())
+        }
+    }
+
+    pub async fn update_account(
+        &self,
+        name: &str,
+        with: impl FnOnce(&mut Account),
+    ) -> std::io::Result<bool> {
+        let mut guard = self.0.write().await;
+        if let Some(account) = guard.accounts.get_mut(name) {
+            with(account);
+            Self::save(&mut guard).await.map(|_| true)
+        } else {
+            Ok(false)
+        }
+    }
+
+    pub async fn list_accounts(&self) -> Vec<String> {
+        self.0
+            .read()
+            .await
+            .accounts
+            .values()
+            .map(|account| account.name.clone())
+            .collect()
+    }
+
+    pub async fn create_token(&self, name: &str) -> std::io::Result<Option<String>> {
+        let mut guard = self.0.write().await;
+        let token = nanoid!(32);
+        if let Some(account) = guard.accounts.get_mut(name) {
+            let expires = SystemTime::now() + Duration::from_secs(60 * 60 * 24 * 30);
+            account.tokens.push(ExpiringValue {
+                value: token.clone(),
+                expires: expires.clone(),
+            });
+            guard
+                .token_map
+                .insert(token.clone(), (name.to_string(), expires));
+            Self::save(&mut guard).await?;
+            Ok(Some(token))
+        } else {
+            Ok(None)
+        }
+    }
+
+    pub async fn check_token(&self, token: &str) -> Option<String> {
+        let guard = self.0.read().await;
+        let Some((name, expires)) = guard.token_map.get(token) else {
+            return None;
+        };
+        if *expires < SystemTime::now() {
+            return None;
+        }
+        Some(name.clone())
+    }
+
+    pub async fn create_invite(&self) -> std::io::Result<String> {
+        let mut guard = self.0.write().await;
+        let invite = nanoid!(32);
+        let expires = SystemTime::now() + Duration::from_secs(60 * 60 * 24 * 14);
+        guard.invites.push(ExpiringValue {
+            value: invite.clone(),
+            expires,
+        });
+        Self::save(&mut guard).await?;
+        Ok(invite)
+    }
+
+    pub async fn check_invite(&self, invite: &str) -> bool {
+        let guard = self.0.read().await;
+        let now = SystemTime::now();
+        guard
+            .invites
+            .iter()
+            .any(|check| check.expires > now && check.value == invite)
+    }
+
+    pub async fn use_invite(&self, invite: &str) -> std::io::Result<bool> {
+        let mut guard = self.0.write().await;
+        let now = SystemTime::now();
+        if let Some((index, _)) = guard
+            .invites
+            .iter()
+            .enumerate()
+            .find(|(_, check)| check.expires > now && check.value == invite)
+        {
+            guard.invites.swap_remove(index);
+            Self::save(&mut guard).await?;
+            Ok(true)
+        } else {
+            Ok(false)
+        }
+    }
+}
+
+pub struct StoreInner {
+    path: PathBuf,
+    accounts: HashMap<String, Account>,
+    apps: HashMap<String, App>,
+    invites: Vec<ExpiringValue>,
+    token_map: HashMap<String, (String, SystemTime)>,
+}
+
+impl StoreInner {
+    fn new(at: PathBuf) -> Self {
+        Self {
+            path: at,
+            accounts: Default::default(),
+            apps: Default::default(),
+            invites: Default::default(),
+            token_map: Default::default(),
+        }
+    }
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+pub struct Account {
+    pub name: String,
+    pub password: OwnedPasswordHash,
+    pub tokens: Vec<ExpiringValue>,
+    pub scopes: Vec<String>,
+}
+
+#[ouroboros::self_referencing]
+pub struct OwnedPasswordHash {
+    owned: PasswordHashString,
+    #[borrows(owned)]
+    #[not_covariant]
+    parsed: PasswordHash<'this>,
+}
+
+impl OwnedPasswordHash {
+    pub fn from(inner: PasswordHashString) -> Self {
+        OwnedPasswordHash::new(inner, |inner| inner.password_hash())
+    }
+
+    pub fn parsed(&self) -> PasswordHash {
+        self.with_parsed(|x| x.clone())
+    }
+}
+
+impl Serialize for OwnedPasswordHash {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        self.with_owned(|owned| owned.as_str().serialize(serializer))
+    }
+}
+
+impl<'de> Deserialize<'de> for OwnedPasswordHash {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        Ok(String::deserialize(deserializer)?
+            .pipe(|hash| {
+                PasswordHashString::parse(&hash, Encoding::B64).map_err(|_| {
+                    <D::Error as serde::de::Error>::invalid_value(
+                        Unexpected::Str(&hash),
+                        &"valid password hash",
+                    )
+                })
+            })?
+            .pipe(OwnedPasswordHash::from))
+    }
+}
+
+pub struct ExpiringValue {
+    pub value: String,
+    pub expires: SystemTime,
+}
+
+impl Serialize for ExpiringValue {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        <(&str, u64)>::serialize(
+            &(
+                &self.value,
+                (self.expires.duration_since(SystemTime::UNIX_EPOCH))
+                    .unwrap()
+                    .as_secs(),
+            ),
+            serializer,
+        )
+    }
+}
+
+impl<'de> Deserialize<'de> for ExpiringValue {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        <(String, u64)>::deserialize(deserializer).map(|(token, unix)| ExpiringValue {
+            value: token,
+            expires: UNIX_EPOCH + Duration::from_secs(unix),
+        })
+    }
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+pub struct App {
+    name: String,
+    secret: String,
+}
diff --git a/src/tui/accounts.rs b/src/tui/accounts.rs
new file mode 100644
index 0000000..a43028c
--- /dev/null
+++ b/src/tui/accounts.rs
@@ -0,0 +1,169 @@
+use std::{cell::RefCell, rc::Rc};
+
+use cursive::{
+    view::Nameable,
+    views::{Button, Dialog, LinearLayout, SelectView, TextArea},
+    Cursive,
+};
+
+use crate::ipc::{
+    DeleteAccountRequest, GetAccountRequest, ListAccountsRequest, UpdateScopesRequest,
+};
+
+use super::{CursiveIpc, ToTextView, ViewExt};
+
+pub fn show(siv: &mut Cursive) {
+    if let Some(accounts) = siv.ipc(ListAccountsRequest {}) {
+        siv.add_layer(
+            SelectView::new()
+                .autojump()
+                .with_all_str(accounts.names)
+                .on_submit(|siv, name: &str| {
+                    manage_account(siv, name);
+                })
+                .float("accounts"),
+        );
+    }
+}
+
+fn manage_account(siv: &mut Cursive, name: &str) {
+    let name = name.to_string();
+    if let Some(_) = siv.ipc(GetAccountRequest { name: name.clone() }) {
+        siv.add_layer(
+            LinearLayout::vertical()
+                .child({
+                    let name = name.clone();
+                    enum SelectOption {
+                        Back,
+                        Scopes,
+                        Delete,
+                    }
+                    SelectView::new()
+                        .item("back", SelectOption::Back)
+                        .item("scopes", SelectOption::Scopes)
+                        .item("delete", SelectOption::Delete)
+                        .on_submit(move |siv, opt| match opt {
+                            SelectOption::Back => {
+                                siv.pop_layer();
+                            }
+                            SelectOption::Scopes => edit_scopes(siv, &name),
+                            SelectOption::Delete => delete_account(siv, &name),
+                        })
+                })
+                .float(&name),
+        )
+    }
+}
+
+fn edit_scopes(siv: &mut Cursive, name: &str) {
+    let name = name.to_string();
+    let Some(account) = siv.ipc(GetAccountRequest { name: name.clone() }) else {
+        siv.pop_layer();
+        return; // it was just there!
+    };
+    let scopes = Rc::new(RefCell::new(account.scopes));
+    enum SelectOption {
+        Scope(String),
+        Back,
+        Add,
+    }
+    siv.add_layer(
+        SelectView::new()
+            .autojump()
+            .item("back", SelectOption::Back)
+            .item("add", SelectOption::Add)
+            .with_all(
+                scopes
+                    .clone()
+                    .borrow()
+                    .iter()
+                    .map(|scope| (scope.to_string(), SelectOption::Scope(scope.clone()))),
+            )
+            .on_submit(move |siv, opt| {
+                match opt {
+                    SelectOption::Scope(scope) => {
+                        let mut scopes = scopes.borrow_mut();
+                        let index = scopes
+                            .iter()
+                            .enumerate()
+                            .find(|(_, check)| *check == scope)
+                            .unwrap()
+                            .0;
+                        scopes.swap_remove(index);
+                        ipc_update_scopes(siv, &name, &scopes);
+                        siv.pop_layer();
+                        edit_scopes(siv, &name);
+                    }
+                    SelectOption::Back => {
+                        siv.pop_layer();
+                    }
+                    SelectOption::Add => add_scope(siv, name.clone(), scopes.clone()),
+                };
+            })
+            .float("edit scopes"),
+    );
+}
+
+fn add_scope(siv: &mut Cursive, name: String, scopes: Rc<RefCell<Vec<String>>>) {
+    siv.add_layer(
+        LinearLayout::vertical()
+            .child(TextArea::new().with_name("scope"))
+            .child(Button::new("add", {
+                move |siv| {
+                    let text_area = siv.find_name::<TextArea>("scope").unwrap();
+                    let scope = text_area.get_content();
+                    if scope.is_empty() {
+                        siv.pop_layer();
+                        return;
+                    }
+                    let mut scopes = scopes.borrow_mut();
+                    scopes.push(scope.to_string());
+                    ipc_update_scopes(siv, &name, &scopes);
+                    siv.pop_layer();
+                    siv.pop_layer();
+                    edit_scopes(siv, &name);
+                }
+            }))
+            .float("add scope"),
+    )
+}
+
+fn ipc_update_scopes(siv: &mut Cursive, name: &str, scopes: &Vec<String>) {
+    siv.ipc(UpdateScopesRequest {
+        account: name.to_string(),
+        scopes: scopes.clone(),
+    });
+}
+
+fn delete_account(siv: &mut Cursive, name: &str) {
+    let name = name.to_string();
+    siv.add_layer(
+        LinearLayout::vertical()
+            .child(format!("delete {name}?").text_view())
+            .child(TextArea::new().with_name("confirm"))
+            .child(Button::new("submit", move |siv| {
+                if let Some(confirm) = siv.find_name::<TextArea>("confirm") {
+                    if confirm.get_content() == "yes" {
+                        if siv
+                            .ipc(DeleteAccountRequest { name: name.clone() })
+                            .is_some()
+                        {
+                            siv.add_layer(Dialog::around("success".text_view()).button(
+                                "ok",
+                                |siv| {
+                                    // all the way back to the root
+                                    siv.pop_layer();
+                                    siv.pop_layer();
+                                    siv.pop_layer();
+                                    siv.pop_layer();
+                                },
+                            ))
+                        }
+                    } else {
+                        siv.pop_layer();
+                    }
+                }
+            }))
+            .float("are you sure"),
+    );
+}
diff --git a/src/tui/mod.rs b/src/tui/mod.rs
new file mode 100644
index 0000000..7c748c1
--- /dev/null
+++ b/src/tui/mod.rs
@@ -0,0 +1,159 @@
+mod accounts;
+
+use std::{
+    io::{Read, Write},
+    os::unix::net::UnixStream,
+};
+
+use cursive::{
+    event::Key,
+    theme::{BorderStyle, Palette, Theme},
+    utils::markup::StyledString,
+    view::Margins,
+    views::{Dialog, PaddedView, Panel, ScrollView, SelectView, TextView},
+    Cursive, CursiveExt,
+};
+use eyre::ContextCompat;
+
+use crate::ipc::{CreateInviteRequest, IPCResponse, RequestDefinition, ResponseDefinition};
+
+pub fn run() -> eyre::Result<()> {
+    let Some(socket_path) = std::env::args().skip(2).next() else {
+        eyre::bail!("must specify socket path");
+    };
+    let stream = UnixStream::connect(socket_path)?;
+    let ipc = Ipc(stream);
+
+    let mut siv = Cursive::new();
+    siv.set_user_data(ipc);
+
+    siv.set_theme(Theme {
+        shadow: false,
+        borders: BorderStyle::Simple,
+        palette: Palette::terminal_default(),
+    });
+
+    enum SelectItem {
+        Accounts,
+        Invite,
+    }
+    siv.add_layer(
+        SelectView::new()
+            .item("accounts", SelectItem::Accounts)
+            .item("invite", SelectItem::Invite)
+            .on_submit(move |siv, item| match item {
+                SelectItem::Accounts => accounts::show(siv),
+                SelectItem::Invite => invite(siv),
+            })
+            .float("dissociate"),
+    );
+
+    siv.add_global_callback(Key::Esc, |siv| {
+        if siv.pop_layer().is_none() || siv.screen().is_empty() {
+            siv.quit();
+        }
+    });
+
+    siv.run();
+
+    Ok(())
+}
+
+fn invite(siv: &mut Cursive) {
+    if let Some(invite) = siv.ipc(CreateInviteRequest {}) {
+        siv.add_layer(
+            Dialog::around(invite.link.text_view().hpad())
+                .button("ok", |siv| {
+                    siv.pop_layer();
+                })
+                .title("invite")
+                .pad(),
+        );
+    }
+}
+
+struct Ipc(UnixStream);
+
+impl Ipc {
+    fn exec<T: RequestDefinition>(
+        &mut self,
+        request: T,
+    ) -> eyre::Result<Result<T::Response, <T::Response as ResponseDefinition>::Error>> {
+        let request_buffer =
+            bincode::encode_to_vec(request.into_request(), bincode::config::standard())?;
+        self.0
+            .write_all(&(request_buffer.len() as u16).to_be_bytes())?;
+        self.0.write_all(&request_buffer)?;
+        let mut response_len_buffer = [0u8; 2];
+        self.0.read_exact(&mut response_len_buffer)?;
+        let response_len = u16::from_be_bytes(response_len_buffer) as usize;
+        let mut response_buffer = vec![0u8; response_len];
+        self.0.read_exact(&mut response_buffer)?;
+        let (response, _): (IPCResponse, _) =
+            bincode::decode_from_slice(&response_buffer, bincode::config::standard())?;
+        T::Response::from_response(response).wrap_err("got wrong response type")
+    }
+}
+
+trait CursiveIpc {
+    fn ipc<T: RequestDefinition>(&mut self, request: T) -> Option<T::Response>;
+}
+
+impl CursiveIpc for Cursive {
+    fn ipc<T: RequestDefinition>(&mut self, request: T) -> Option<T::Response> {
+        match self.user_data::<Ipc>().unwrap().exec(request) {
+            Ok(Ok(resp)) => Some(resp),
+            Err(err) => {
+                self.add_layer(
+                    Dialog::new()
+                        .content(format!("ipc error: {err:?}").text_view())
+                        .button("quit", |siv| siv.quit()),
+                );
+                None
+            }
+            Ok(Err(err)) => {
+                self.add_layer(
+                    Dialog::new()
+                        .content(format!("error: {err}").text_view())
+                        .button("ok", |siv| {
+                            siv.pop_layer();
+                        }),
+                );
+                None
+            }
+        }
+    }
+}
+
+trait ViewExt: Sized {
+    fn float(self, title: &str) -> PaddedView<Panel<ScrollView<PaddedView<Self>>>>;
+    fn pad(self) -> PaddedView<Self>;
+    fn hpad(self) -> PaddedView<Self>;
+}
+
+impl<V: Sized> ViewExt for V {
+    fn float(self, title: &str) -> PaddedView<Panel<ScrollView<PaddedView<Self>>>> {
+        PaddedView::new(
+            Margins::lrtb(1, 1, 1, 1),
+            Panel::new(ScrollView::new(PaddedView::new(Margins::lr(1, 1), self))).title(title),
+        )
+    }
+
+    fn pad(self) -> PaddedView<Self> {
+        PaddedView::new(Margins::lrtb(1, 1, 1, 1), self)
+    }
+
+    fn hpad(self) -> PaddedView<Self> {
+        PaddedView::new(Margins::lr(1, 1), self)
+    }
+}
+
+trait ToTextView {
+    fn text_view(self) -> TextView;
+}
+
+impl<S: Into<StyledString>> ToTextView for S {
+    fn text_view(self) -> TextView {
+        TextView::new(self)
+    }
+}