Compare commits

...
Author SHA1 Message Date
504d64ea57
init small ssh-cli to accept commands over SSH and operate local locks
All checks were successful
Build Container / Build Container (push) Successful in 1m42s
2026-07-18 23:20:07 +02:00
a7692dc894
fix bug in API static tokens where not actually extracted from request 2026-07-18 23:19:34 +02:00
58f5a8cd43
init nix based devshell 2026-07-18 21:09:51 +02:00
8 changed files with 2181 additions and 7 deletions

View file

@ -1,4 +1,7 @@
# integrate this into your own .envrc file with `source_env .envrc.dist`
if has nix; then
use flake
fi
watch_file api/pyproject.toml \
api/uv.lock

View file

@ -1,7 +1,7 @@
from typing import Annotated, Optional
import logging
from datetime import datetime, UTC, timedelta
from fastapi import Request, Depends, Response, Header
from fastapi import Request, Depends, Response
from fastapi.security import APIKeyHeader
from simple_openid_connect.data import TokenSuccessResponse
from simple_openid_connect.client import OpenidClient
@ -14,7 +14,12 @@ from dooris_api.ccujack import CCUJackClient
logger = logging.getLogger(__name__)
api_key_security_scheme = APIKeyHeader(name="Authorization", scheme_name="Static-Token", auto_error=False)
api_key_security_scheme = APIKeyHeader(
name="authorization",
scheme_name="Static-Token",
description="Set the Authorization header to 'Static-Token foobar123' with a token that is statically configured at application parameter",
auto_error=False,
)
async def get_oidc_client(req: Request) -> OpenidClient:
@ -134,14 +139,17 @@ def clear_oidc_auth_state(resp: Response):
def get_logged_in_token_user(req: Request, token: Optional[str]):
print(req.headers)
print(token)
if not token or not token.startswith("Static-Token "):
logger.debug("No static API-Token was part of the request")
return None
token = token.removeprefix("Static-Token ")
valid_tokens = app_config.get().static_api_tokens
if any((i == token for i in valid_tokens)):
if any((i == token for i in valid_tokens)):
logger.debug("Successfully authenticated a static API-Token")
return models.ApiUser(
is_anonymous=False,
@ -157,7 +165,10 @@ def get_logged_in_token_user(req: Request, token: Optional[str]):
async def get_api_user(
req: Request, resp: Response, oidc_client: OpenidClient, token: Annotated[Optional[str], Depends(api_key_security_scheme)] = None
req: Request,
resp: Response,
oidc_client: OpenidClient,
token: Annotated[Optional[str], Depends(api_key_security_scheme)] = None,
) -> models.ApiUser:
oidc_user = await get_logged_in_oidc_user(req, resp, oidc_client)
token_user = get_logged_in_token_user(req, token)
@ -182,9 +193,12 @@ ApiUser = Annotated[models.ApiUser, Depends(get_api_user)]
async def get_authenticated_user(
req: Request, resp: Response, oidc_client: OpenidClient
req: Request,
resp: Response,
oidc_client: OpenidClient,
token: Annotated[Optional[str], Depends(api_key_security_scheme)] = None,
) -> models.ApiUser:
user = await get_api_user(req, resp, oidc_client)
user = await get_api_user(req, resp, oidc_client, token)
if user.is_anonymous:
raise exceptions.HttpProblemException.unauthorized(req.url)
else:

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784356753,
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

36
flake.nix Normal file
View file

@ -0,0 +1,36 @@
{
description = "dooris";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { system = system; };
in
{
devShells.default = pkgs.mkShell {
packages = with pkgs; [
uv
pre-commit
ruff
python3
pnpm
rustc
cargo
rust-analyzer
rustfmt
];
};
}
);
}

1863
ssh-cli/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

12
ssh-cli/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "ssh-cli"
version = "0.1.0"
edition = "2024"
[dependencies]
color-eyre = "0.6.5"
eyre = "0.6.12"
reqwest = { version = "0.13.4", features = ["blocking", "json"] }
serde = { version = "1.0.228", features = ["derive"] }
tracing = "0.1.44"
tracing-subscriber = "0.3.23"

63
ssh-cli/src/api_models.rs Normal file
View file

@ -0,0 +1,63 @@
use serde::{Deserialize, Serialize};
#[allow(unused)]
#[derive(Debug, Eq, PartialEq, Hash, Deserialize)]
pub struct ApiLock {
pub id: String,
pub name: String,
pub status: ApiLockStatus,
}
#[allow(unused)]
#[derive(Debug, Eq, PartialEq, Hash, Deserialize)]
pub struct ApiLockStatus {
pub is_unreachable: bool,
#[serde(default)]
pub is_low_batteray: bool,
pub is_error_jammed: bool,
pub lock_target_level: String,
pub lock_state: String,
pub activity_state: String,
}
#[allow(unused)]
#[derive(Debug, Eq, PartialEq, Hash, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LockTargetLevel {
LOCKED,
UNLOCKED,
OPEN,
}
#[allow(unused)]
#[derive(Debug, Eq, PartialEq, Hash, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LockState {
UNKNOWN,
LOCKED,
UNLOCKED,
}
#[allow(unused)]
#[derive(Debug, Eq, PartialEq, Hash, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LockActivityState {
UNKNOWN,
LOCKING,
UNLOCKING,
STABLE,
}
#[allow(unused)]
#[derive(Debug, Eq, PartialEq, Hash, Serialize)]
pub struct LockOperationRequest {
pub desired_state: DesiredLockState,
}
#[allow(unused)]
#[derive(Debug, Eq, PartialEq, Hash, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DesiredLockState {
OPEN,
CLOSED,
}

122
ssh-cli/src/main.rs Normal file
View file

@ -0,0 +1,122 @@
//! This is a small CLI intended to be run on a host that accepts dooris commands via SSH
//!
//! The SSH server should be configured to force execution of this command via the following server config snippet
//!
//! ```sshd_config
//! Match User dooris
//! AuthorizedKeysFile /path/to/api/state/authorized_keys
//! ForceCommand /path/to/ssh-cli
//! SetEnv DOORIS_SERVER_URL=https://dooris.ccchh.net/ DOORIS_API_TOKEN=foobar123
//! ```
mod api_models;
use eyre::{Context, OptionExt, eyre};
use reqwest::{Url, header::HeaderValue};
use std::{env::VarError, process::exit};
use crate::api_models::LockOperationRequest;
fn main() -> eyre::Result<()> {
tracing_subscriber::fmt::init();
color_eyre::install()?;
let doors = fetch_doors()?;
let args = get_args()?;
let args = args.split(" ").collect::<Vec<_>>();
if args.len() == 0 || args[0] == "--help" || args[0] == "-h" || args[0] == "" {
println!("Help is not yet available. Come back later >~<");
exit(3);
} else if args[0] == "lock" || args[0] == "unlock" {
let cli_target_door = args
.get(1)
.ok_or_eyre("'unlock' and 'lock' cli requires the door name as second argument")?;
let api_target_door = doors
.iter()
.find(|i_door| i_door.name.eq_ignore_ascii_case(cli_target_door))
.ok_or({
let door_names = doors
.iter()
.map(|i_door| i_door.name.to_owned())
.collect::<Vec<_>>();
eyre!(
"No door named {cli_target_door:?} exists. Available doors are {door_names:?}.",
)
})?;
println!(
"Lock {} ({:?}) identified!",
api_target_door.id, api_target_door.name
);
operate_lock(
&api_target_door.id,
match args[0] {
"lock" => api_models::DesiredLockState::CLOSED,
"unlock" => api_models::DesiredLockState::OPEN,
_ => unreachable!(),
},
)?;
println!("Lock has been turned. Enjoy :3");
return Ok(());
} else {
println!("Unknown commandline. See --help for more info.");
exit(2);
}
}
/// Get passed arguments from SSH_ORIGINAL_COMMAND environment variable but falling back to direct CLI args
fn get_args() -> eyre::Result<String> {
match std::env::var("SSH_ORIGINAL_COMMAND") {
Ok(original_command) => Ok(original_command),
Err(VarError::NotUnicode(_)) => {
Err(eyre!("Your command is not very unicoded. Refusing >~<"))
}
Err(VarError::NotPresent) => Ok(std::env::args().skip(1).collect::<Vec<_>>().join(" ")),
}
}
fn http_client() -> eyre::Result<reqwest::blocking::Client> {
return reqwest::blocking::ClientBuilder::new()
.user_agent("dooris-ssh-cli")
.redirect(reqwest::redirect::Policy::none())
.http1_title_case_headers()
.build()
.context("Could not construct http client even though all values are statically known to be valid");
}
fn api_token() -> eyre::Result<HeaderValue> {
let server_token = std::env::var("DOORIS_API_TOKEN").map_err(|_| eyre!("This ssh-cli is not configured correctly and does not have it's DOORIS_API_TOKEN environment variable set correctly. Contact your nearest server maid to fix the dooris setup."))?;
format!("Static-Token {server_token}")
.try_into()
.context("Could not convert HTTP API Token to a valid HTTP header")
}
fn server_url() -> eyre::Result<Url> {
let server_url = std::env::var("DOORIS_SERVER_URL").map_err(|_| eyre!("This ssh-cli is not configured correctly and does not have it's DOORIS_SERVER_URL environment variable set correctly. Contact your nearest server maid to fix the dooris setup."))?;
Url::parse(&server_url).context("Configured DOORIS_SERVER_URL is not a valid URL. Contact your nearest server maid to fix the dooris setup")
}
fn fetch_doors() -> eyre::Result<Vec<api_models::ApiLock>> {
http_client()?
.get(server_url()?.join("api/locks/")?)
.header(reqwest::header::AUTHORIZATION, api_token()?)
.send()?
.error_for_status()
.context("Could not get list of locks from the dooris API")?
.json()
.context("Could not parse list-locks response from dooris API")
}
fn operate_lock(lock_id: &str, desired_state: api_models::DesiredLockState) -> eyre::Result<()> {
println!("Turning Lock, please stand by…");
http_client()?
.patch(server_url()?.join(&format!("api/locks/{lock_id}"))?)
.header(reqwest::header::AUTHORIZATION, api_token()?)
.json(&LockOperationRequest { desired_state })
.send()?
.error_for_status()
.context("Could not operate lock")?;
Ok(())
}