Add SSH cert flows and user service installer

This commit is contained in:
Eric Wendland 2026-05-16 00:17:08 +02:00
commit f302342b1c
21 changed files with 2158 additions and 14 deletions

View file

@ -118,6 +118,34 @@ impl Store {
state_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS ssh_cert_requests (
request_id TEXT PRIMARY KEY,
requester_node TEXT NOT NULL,
public_key TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
cert_kind TEXT NOT NULL,
principals_json TEXT NOT NULL,
requested_validity TEXT,
renewal_of TEXT,
reason TEXT,
status TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS ssh_certificates (
cert_id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
certificate TEXT NOT NULL,
certificate_fingerprint TEXT NOT NULL,
imported_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS ssh_revocations (
revocation_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
target TEXT NOT NULL,
reason TEXT,
created_at_ms INTEGER NOT NULL,
published INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', '1');
"#,
)?;
@ -200,6 +228,170 @@ impl Store {
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_ssh_cert_request(
&self,
request: &StoredSshCertRequest,
) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO ssh_cert_requests(
request_id, requester_node, public_key, public_key_fingerprint, cert_kind,
principals_json, requested_validity, renewal_of, reason, status, created_at_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#,
params![
request.request_id,
request.requester_node,
request.public_key,
request.public_key_fingerprint,
request.cert_kind,
serde_json::to_string(&request.principals)?,
request.requested_validity,
request.renewal_of,
request.reason,
request.status,
request.created_at_ms
],
)?;
Ok(())
}
pub fn get_ssh_cert_request(
&self,
request_id: &str,
) -> Result<Option<StoredSshCertRequest>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT request_id, requester_node, public_key, public_key_fingerprint, cert_kind,
principals_json, requested_validity, renewal_of, reason, status, created_at_ms
FROM ssh_cert_requests WHERE request_id = ?1"#,
)?;
let mut rows = stmt.query(params![request_id])?;
if let Some(row) = rows.next()? {
Ok(Some(stored_ssh_cert_request_from_row(row)?))
} else {
Ok(None)
}
}
pub fn update_ssh_cert_request_status(
&self,
request_id: &str,
status: &str,
) -> Result<(), StoreError> {
self.conn.execute(
"UPDATE ssh_cert_requests SET status = ?2 WHERE request_id = ?1",
params![request_id, status],
)?;
Ok(())
}
pub fn list_ssh_cert_requests(&self) -> Result<Vec<StoredSshCertRequest>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT request_id, requester_node, public_key, public_key_fingerprint, cert_kind,
principals_json, requested_validity, renewal_of, reason, status, created_at_ms
FROM ssh_cert_requests ORDER BY created_at_ms, request_id"#,
)?;
let rows = stmt.query_map([], stored_ssh_cert_request_from_row)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_ssh_certificate(
&self,
certificate: &StoredSshCertificate,
) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO ssh_certificates(
cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms
) VALUES (?1, ?2, ?3, ?4, ?5)"#,
params![
certificate.cert_id,
certificate.request_id,
certificate.certificate,
certificate.certificate_fingerprint,
certificate.imported_at_ms
],
)?;
Ok(())
}
pub fn list_ssh_certificates(&self) -> Result<Vec<StoredSshCertificate>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms
FROM ssh_certificates ORDER BY imported_at_ms, cert_id"#,
)?;
let rows = stmt.query_map([], |row| {
Ok(StoredSshCertificate {
cert_id: row.get(0)?,
request_id: row.get(1)?,
certificate: row.get(2)?,
certificate_fingerprint: row.get(3)?,
imported_at_ms: row.get(4)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_ssh_revocation(
&self,
revocation: &StoredSshRevocation,
) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO ssh_revocations(
revocation_id, kind, target, reason, created_at_ms, published
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
params![
revocation.revocation_id,
revocation.kind,
revocation.target,
revocation.reason,
revocation.created_at_ms,
if revocation.published { 1_i64 } else { 0_i64 }
],
)?;
Ok(())
}
pub fn list_ssh_revocations(&self) -> Result<Vec<StoredSshRevocation>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT revocation_id, kind, target, reason, created_at_ms, published
FROM ssh_revocations ORDER BY created_at_ms, revocation_id"#,
)?;
let rows = stmt.query_map([], |row| {
Ok(StoredSshRevocation {
revocation_id: row.get(0)?,
kind: row.get(1)?,
target: row.get(2)?,
reason: row.get(3)?,
created_at_ms: row.get(4)?,
published: row.get::<_, i64>(5)? != 0,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
}
fn stored_ssh_cert_request_from_row(
row: &rusqlite::Row<'_>,
) -> Result<StoredSshCertRequest, rusqlite::Error> {
let principals_json: String = row.get(5)?;
let principals = serde_json::from_str(&principals_json).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(error))
})?;
Ok(StoredSshCertRequest {
request_id: row.get(0)?,
requester_node: row.get(1)?,
public_key: row.get(2)?,
public_key_fingerprint: row.get(3)?,
cert_kind: row.get(4)?,
principals,
requested_validity: row.get(6)?,
renewal_of: row.get(7)?,
reason: row.get(8)?,
status: row.get(9)?,
created_at_ms: row.get(10)?,
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -217,6 +409,40 @@ pub struct CasObject {
pub path: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSshCertRequest {
pub request_id: String,
pub requester_node: String,
pub public_key: String,
pub public_key_fingerprint: String,
pub cert_kind: String,
pub principals: Vec<String>,
pub requested_validity: Option<String>,
pub renewal_of: Option<String>,
pub reason: Option<String>,
pub status: String,
pub created_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSshCertificate {
pub cert_id: String,
pub request_id: String,
pub certificate: String,
pub certificate_fingerprint: String,
pub imported_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSshRevocation {
pub revocation_id: String,
pub kind: String,
pub target: String,
pub reason: Option<String>,
pub created_at_ms: i64,
pub published: bool,
}
#[must_use]
pub fn now_ms() -> i64 {
let now = std::time::SystemTime::now()
@ -237,4 +463,47 @@ mod tests {
let resources = store.list_resources().expect("resources");
assert!(resources.is_empty());
}
#[test]
fn ssh_cert_request_and_revocation_roundtrip() {
let store = Store::open_memory().expect("open");
let request = StoredSshCertRequest {
request_id: "ssh-cert-request:1".to_owned(),
requester_node: "node:laptop".to_owned(),
public_key: "ssh-ed25519 AAAA test".to_owned(),
public_key_fingerprint: "ssh:blake3:test".to_owned(),
cert_kind: "user".to_owned(),
principals: vec!["eric".to_owned()],
requested_validity: Some("+52w".to_owned()),
renewal_of: None,
reason: Some("renewal".to_owned()),
status: "pending".to_owned(),
created_at_ms: 1,
};
store
.insert_ssh_cert_request(&request)
.expect("insert request");
assert_eq!(
store
.get_ssh_cert_request("ssh-cert-request:1")
.expect("get request"),
Some(request.clone())
);
let revocation = StoredSshRevocation {
revocation_id: "ssh-revocation:1".to_owned(),
kind: "public-key".to_owned(),
target: "ssh:blake3:test".to_owned(),
reason: Some("lost key".to_owned()),
created_at_ms: 2,
published: true,
};
store
.insert_ssh_revocation(&revocation)
.expect("insert revocation");
assert_eq!(
store.list_ssh_revocations().expect("list revocations"),
vec![revocation]
);
}
}