Compare commits
2 Commits
Author | SHA1 | Date | |
---|---|---|---|
e4c9d60814 | |||
65c2703885 |
1448
Cargo.lock
generated
1448
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
47
Cargo.toml
47
Cargo.toml
@ -1,8 +1,43 @@
|
||||
[workspace]
|
||||
[package]
|
||||
name = "ssh-cert-dist"
|
||||
version = "0.1.0"
|
||||
authors = ["shimun <shimun@shimun.net>"]
|
||||
edition = "2021"
|
||||
|
||||
members = [
|
||||
"common",
|
||||
"server",
|
||||
"client",
|
||||
]
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[features]
|
||||
default = [ "client", "reload", "info", "authorized" ]
|
||||
reload = []
|
||||
authorized =[ "dep:jwt-compact" ]
|
||||
index = []
|
||||
info = [ "axum/json", "ssh-key/serde" ]
|
||||
client = [ "dep:url", "dep:reqwest" ]
|
||||
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.66"
|
||||
async-trait = "0.1.59"
|
||||
axum = { version = "0.6.1", features = ["http2"] }
|
||||
axum-extra = { version = "0.4.1", features = ["typed-routing"] }
|
||||
chrono = "0.4.23"
|
||||
clap = { version = "4.0.29", features = ["env", "derive"] }
|
||||
jwt-compact = { version = "0.6.0", features = ["serde_cbor", "std", "clock"], optional = true }
|
||||
rand = "0.8.5"
|
||||
reqwest = { version = "0.11.13", optional = true }
|
||||
serde = { version = "1.0.148", features = ["derive"] }
|
||||
ssh-key = { version = "0.5.1", features = ["ed25519", "p256", "p384", "rsa", "signature"] }
|
||||
thiserror = "1.0.37"
|
||||
tokio = { version = "1.22.0", features = ["io-std", "test-util", "tracing", "macros", "fs"] }
|
||||
tower = { version = "0.4.13", features = ["util"] }
|
||||
tower-http = { version = "0.3.4", features = ["map-request-body", "trace"] }
|
||||
tracing = { version = "0.1.37", features = ["release_max_level_debug"] }
|
||||
tracing-subscriber = "0.3.16"
|
||||
url = { version = "2.3.1", optional = true }
|
||||
|
||||
[patch.crates-io]
|
||||
ssh-key = { git = "https://github.com/a-dma/SSH.git", branch = "u2f_signatures" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.3.0"
|
||||
|
||||
|
@ -1,33 +0,0 @@
|
||||
[package]
|
||||
name = "ssh-cert-dist-client"
|
||||
version = "0.1.0"
|
||||
authors = ["shimun <shimun@shimun.net>"]
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "sshcd"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.66"
|
||||
async-trait = "0.1.59"
|
||||
axum-extra = { version = "0.4.1", features = ["typed-routing"] }
|
||||
chrono = "0.4.23"
|
||||
clap = { version = "4.0.29", features = ["env", "derive"] }
|
||||
rand = "0.8.5"
|
||||
reqwest = { version = "0.11.13" }
|
||||
serde = { version = "1.0.148", features = ["derive"] }
|
||||
ssh-key = { version = "0.6.0-rc.2", features = ["ed25519", "p256", "p384", "rsa", "serde"] }
|
||||
thiserror = "1.0.37"
|
||||
tokio = { version = "1.22.0", features = ["io-std", "test-util", "tracing", "macros", "fs"] }
|
||||
tracing = { version = "0.1.37", features = ["release_max_level_debug"] }
|
||||
tracing-subscriber = "0.3.16"
|
||||
url = { version = "2.3.1" }
|
||||
ssh-cert-dist-common = { path = "../common" }
|
||||
clap_complete_command = "0.5.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.3.0"
|
||||
|
||||
[profile.relese]
|
||||
opt-level = 1
|
@ -1,10 +0,0 @@
|
||||
use clap::Parser;
|
||||
|
||||
mod client;
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
client::run(client::ClientCommand::parse()).await
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
[package]
|
||||
name = "ssh-cert-dist-common"
|
||||
version = "0.1.0"
|
||||
authors = ["shimun <shimun@shimun.net>"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.66"
|
||||
async-trait = "0.1.59"
|
||||
axum = { version = "0.6.1" }
|
||||
axum-extra = { version = "0.4.1", features = ["typed-routing"] }
|
||||
chrono = "0.4.26"
|
||||
hex = { version = "0.4.3", features = ["serde"] }
|
||||
serde = { version = "1.0.148", features = ["derive"] }
|
||||
ssh-key = { version = "0.6.0-rc.2", features = ["ed25519", "p256", "p384", "rsa"] }
|
||||
thiserror = "1.0.37"
|
||||
tokio = { version = "1.22.0", features = ["io-std", "test-util", "tracing", "macros", "fs"] }
|
||||
tracing = { version = "0.1.37", features = ["release_max_level_debug"] }
|
||||
tracing-subscriber = "0.3.16"
|
||||
shell-escape = "0.1.5"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.3.0"
|
||||
|
@ -1,8 +0,0 @@
|
||||
mod certs;
|
||||
mod renew;
|
||||
mod routes;
|
||||
mod util;
|
||||
|
||||
pub use certs::*;
|
||||
pub use renew::*;
|
||||
pub use routes::*;
|
@ -1,49 +0,0 @@
|
||||
use std::borrow::Cow;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use chrono::Duration;
|
||||
use shell_escape::escape;
|
||||
use ssh_key::Certificate;
|
||||
|
||||
/// Generates an command to renew the given certs
|
||||
pub fn renew_command(cert: &Certificate, ca_path: &str, file_name: Option<&str>) -> String {
|
||||
let validity = cert
|
||||
.valid_before_time()
|
||||
.duration_since(cert.valid_after_time())
|
||||
.unwrap_or(Duration::zero().to_std().unwrap());
|
||||
let expiry = cert.valid_before_time().checked_add(validity).unwrap();
|
||||
let expiry_date = expiry.duration_since(UNIX_EPOCH).unwrap();
|
||||
let host_key = if cert.cert_type().is_host() {
|
||||
" -h"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let opts = cert
|
||||
.critical_options()
|
||||
.iter()
|
||||
.map(|(opt, val)| {
|
||||
if val.is_empty() {
|
||||
opt.clone()
|
||||
} else {
|
||||
format!("{opt}={val}")
|
||||
}
|
||||
})
|
||||
.map(|arg| format!("-O {}", escape(arg.into())))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let opts = opts.trim();
|
||||
let renew_command = format!(
|
||||
"ssh-keygen -s {ca_path} {host_key} -I {} -n {} -z {} -V {}:{} {opts} {}",
|
||||
escape(cert.key_id().into()),
|
||||
escape(cert.valid_principals().join(",").into()),
|
||||
cert.serial() + 1,
|
||||
cert.valid_after(),
|
||||
expiry_date.as_secs(),
|
||||
escape(
|
||||
file_name
|
||||
.map(|name| name.trim_end_matches("-cert.pub")).map(Cow::Borrowed)
|
||||
.unwrap_or_else(|| escape(format!("{}.pub", cert.key_id()).into()))
|
||||
)
|
||||
);
|
||||
renew_command
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
use axum_extra::routing::TypedPath;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ssh_key::Fingerprint;
|
||||
|
||||
#[derive(TypedPath, Deserialize)]
|
||||
#[typed_path("/certs")]
|
||||
pub struct CertList;
|
||||
|
||||
#[derive(TypedPath, Deserialize)]
|
||||
#[typed_path("/cert/:identifier")]
|
||||
pub struct GetCert {
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
#[derive(TypedPath, Deserialize)]
|
||||
#[typed_path("/certs/:pubkey_hash")]
|
||||
pub struct GetCertsPubkey {
|
||||
pub pubkey_hash: Fingerprint,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct CertIds {
|
||||
pub ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(TypedPath, Deserialize)]
|
||||
#[typed_path("/cert/:identifier/info")]
|
||||
pub struct GetCertInfo {
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
#[derive(TypedPath, Deserialize)]
|
||||
#[typed_path("/cert/:identifier")]
|
||||
pub struct PostCertInfo {
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
#[derive(TypedPath)]
|
||||
#[typed_path("/cert")]
|
||||
pub struct PutCert;
|
@ -1,6 +0,0 @@
|
||||
#[macro_export]
|
||||
macro_rules! env_key {
|
||||
( $var:expr ) => {
|
||||
concat!("SSH_CD_", $var)
|
||||
};
|
||||
}
|
36
flake.lock
generated
36
flake.lock
generated
@ -7,11 +7,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1698420672,
|
||||
"narHash": "sha256-/TdeHMPRjjdJub7p7+w55vyABrsJlt5QkznPYy55vKA=",
|
||||
"lastModified": 1662220400,
|
||||
"narHash": "sha256-9o2OGQqu4xyLZP9K6kNe1pTHnyPz0Wr3raGYnr9AIgY=",
|
||||
"owner": "nmattia",
|
||||
"repo": "naersk",
|
||||
"rev": "aeb58d5e8faead8980a807c840232697982d47b9",
|
||||
"rev": "6944160c19cb591eb85bbf9b2f2768a935623ed3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -22,11 +22,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1705496572,
|
||||
"narHash": "sha256-rPIe9G5EBLXdBdn9ilGc0nq082lzQd0xGGe092R/5QE=",
|
||||
"lastModified": 1669411043,
|
||||
"narHash": "sha256-LfPd3+EY+jaIHTRIEOUtHXuanxm59YKgUacmSzaqMLc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "842d9d80cfd4560648c785f8a4e6f3b096790e19",
|
||||
"rev": "5dc7114b7b256d217fe7752f1614be2514e61bb8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -41,31 +41,13 @@
|
||||
"utils": "utils"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709126324,
|
||||
"narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=",
|
||||
"lastModified": 1667395993,
|
||||
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "d465f4819400de7c8d874d50b982301f28a84605",
|
||||
"rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
63
flake.nix
63
flake.nix
@ -13,7 +13,7 @@
|
||||
outputs = inputs @ { self, nixpkgs, utils, naersk, ... }:
|
||||
let
|
||||
root = inputs.source or self;
|
||||
pname = "ssh-cert-dist";
|
||||
pname = (builtins.fromTOML (builtins.readFile (root + "/Cargo.toml"))).package.name;
|
||||
# toolchains: stable, beta, default(nightly)
|
||||
toolchain = pkgs:
|
||||
if inputs ? fenix then inputs.fenix.packages."${pkgs.system}".complete.toolchain
|
||||
@ -24,30 +24,15 @@
|
||||
in
|
||||
rec {
|
||||
# `nix build`
|
||||
packages."${pname}-server" = (self.overlay pkgs pkgs)."${pname}-server";
|
||||
packages."${pname}-client" = (self.overlay pkgs pkgs)."${pname}-client";
|
||||
|
||||
packages."${pname}-client-snap" = pkgs.snapTools.makeSnap {
|
||||
meta = {
|
||||
name = pname;
|
||||
architectures = [ "amd64" ];
|
||||
confinement = "strict";
|
||||
apps.hello.command = apps."${pname}-client".program;
|
||||
};
|
||||
};
|
||||
packages.${pname} = (self.overlay pkgs pkgs).${pname};
|
||||
|
||||
packages.dockerImage = pkgs.runCommandLocal "docker-${pname}.tar.gz" { } "${apps.streamDockerImage.program} | gzip --fast > $out";
|
||||
|
||||
packages.default = packages."${pname}-client";
|
||||
packages.default = packages.${pname};
|
||||
|
||||
# `nix run`
|
||||
apps."${pname}-server" = utils.lib.mkApp {
|
||||
drv = packages."${pname}-server";
|
||||
exePath = "/bin/sshcd-server";
|
||||
};
|
||||
apps."${pname}-client" = utils.lib.mkApp {
|
||||
drv = packages."${pname}-client";
|
||||
exePath = "/bin/sshcd";
|
||||
apps.${pname} = utils.lib.mkApp {
|
||||
drv = packages.${pname};
|
||||
};
|
||||
|
||||
# `nix run .#streamDockerImage | docker load`
|
||||
@ -56,12 +41,12 @@
|
||||
name = pname;
|
||||
tag = self.shortRev or "latest";
|
||||
config = {
|
||||
Entrypoint = apps."${pname}-server".program;
|
||||
Entrypoint = apps.default.program;
|
||||
};
|
||||
};
|
||||
exePath = "";
|
||||
};
|
||||
apps.default = apps."${pname}-client";
|
||||
apps.default = apps.${pname};
|
||||
|
||||
# `nix flake check`
|
||||
checks = {
|
||||
@ -93,15 +78,7 @@
|
||||
rustc --version
|
||||
printf "\nbuild inputs: ${pkgs.lib.concatStringsSep ", " (map (bi: bi.name) (buildInputs ++ nativeBuildInputs))}"
|
||||
function server() {
|
||||
if [ ! -e "certs/ca.pub" ]; then
|
||||
mkdir -p certs keys
|
||||
ssh-keygen -t ed25519 -f certs/ca -q -N ""
|
||||
ssh-keygen -t ed25519 -f keys/host -q -N ""
|
||||
ssh-keygen -t ed25519 -f keys/client -q -N ""
|
||||
ssh-keygen -s certs/ca -V +1000d -h -I host -n localhost,127.0.0.1 -h keys/host.pub
|
||||
ssh-keygen -s certs/ca -V +1000d -I client -n "client,client@localhost" keys/client.pub -O force-command="echo Hello World"
|
||||
fi
|
||||
cargo watch -x "run --bin sshcd-server --all-features -- ''${@}"
|
||||
cargo watch -x "run --all-features -- server ''${@}"
|
||||
}
|
||||
'';
|
||||
};
|
||||
@ -123,32 +100,12 @@
|
||||
];
|
||||
nativeBuildInputs = with prev; [
|
||||
pkg-config
|
||||
installShellFiles
|
||||
];
|
||||
installCompletions = cmd: ''
|
||||
mkdir completions
|
||||
for shell in bash zsh fish; do
|
||||
$out/bin/${cmd} completions --shell $shell > completions/${cmd}.$shell
|
||||
installShellCompletion --cmd ${cmd} --$shell completions/${cmd}.$shell
|
||||
done
|
||||
'';
|
||||
in
|
||||
{
|
||||
"${pname}-server" =
|
||||
"${pname}" =
|
||||
naersk-lib.buildPackage {
|
||||
name = "${pname}-server";
|
||||
inherit root buildInputs nativeBuildInputs;
|
||||
# postInstall = ''
|
||||
# ${installCompletions}
|
||||
# '';
|
||||
};
|
||||
"${pname}-client" =
|
||||
naersk-lib.buildPackage {
|
||||
name = "${pname}-client";
|
||||
postInstall = ''
|
||||
${installCompletions "sshcd"}
|
||||
'';
|
||||
inherit root buildInputs nativeBuildInputs;
|
||||
inherit pname root buildInputs nativeBuildInputs;
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -11,35 +11,23 @@ in
|
||||
Unit.Description = "ssh-cert-dist service for ${path}";
|
||||
Service = {
|
||||
Environment = "RUST_LOG=debug";
|
||||
ExecStart = "${pkgs.writeShellApplication {
|
||||
name = "sshcd";
|
||||
runtimeInputs = [ cfg.package ];
|
||||
ExecStart = toString (pkgs.writeShellApplication {
|
||||
name = "ssh-cert-dist-${options.name}";
|
||||
runtimeInputs = [ pkgs.ssh-cert-dist ];
|
||||
text = ''
|
||||
${optionalString options.fetch ''
|
||||
sshcd fetch --cert-dir '${path}' --api-endpoint '${cfg.endpoint}'
|
||||
ssh-cert-dist client fetch --cert-dir '${path}' --api-endpoint '${cfg.endpoint}'
|
||||
''}
|
||||
${optionalString options.upload ''
|
||||
sshcd upload --api-endpoint '${cfg.endpoint}' ${path}/*
|
||||
ssh-cert-dist client upload --api-endpoint '${cfg.endpoint}' ${path}/*
|
||||
''}
|
||||
|
||||
'';
|
||||
}}/bin/sshcd";
|
||||
});
|
||||
};
|
||||
};
|
||||
})
|
||||
cfg.directories);
|
||||
config.systemd.user.timers = mkIf cfg.enable (mapAttrs'
|
||||
(path: options: {
|
||||
inherit (options) name; value = {
|
||||
Unit.Description = "ssh-cert-dist service for ${path}";
|
||||
Timer = {
|
||||
OnCalendar = options.interval;
|
||||
Persistent = true;
|
||||
Unit = "${options.name}.service";
|
||||
};
|
||||
Install.WantedBy = [ "timers.target" ];
|
||||
};
|
||||
})
|
||||
cfg.directories);
|
||||
config.home.sessionVariables = mkIf (cfg.enable && cfg.endpoint != null) {
|
||||
SSH_CD_API = cfg.endpoint;
|
||||
};
|
||||
|
@ -14,7 +14,7 @@ in
|
||||
};
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.ssh-cert-dist-server;
|
||||
default = pkgs.ssh-cert-dist;
|
||||
};
|
||||
ca = mkOption {
|
||||
type = types.path;
|
||||
@ -57,7 +57,7 @@ in
|
||||
chown ${cfg.user}:${cfg.group} ${cfg.dataDir}
|
||||
''}";
|
||||
User = cfg.user;
|
||||
ExecStart = "${cfg.package}/bin/sshcd-server";
|
||||
ExecStart = "${cfg.package}/bin/ssh-cert-dist server";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
@ -13,11 +13,6 @@
|
||||
type = types.bool;
|
||||
default = false;
|
||||
};
|
||||
interval = mkOption {
|
||||
type = types.str;
|
||||
default = "daily";
|
||||
description = "https://www.freedesktop.org/software/systemd/man/systemd.time.html";
|
||||
};
|
||||
};
|
||||
};
|
||||
endpointOption = mkOption {
|
||||
@ -27,7 +22,7 @@
|
||||
};
|
||||
packageOption = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.ssh-cert-dist-client;
|
||||
default = pkgs.ssh-cert-dist;
|
||||
};
|
||||
|
||||
in
|
||||
|
@ -1,43 +0,0 @@
|
||||
[package]
|
||||
name = "ssh-cert-dist-server"
|
||||
version = "0.1.0"
|
||||
authors = ["shimun <shimun@shimun.net>"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[features]
|
||||
default = [ "reload", "info", "authorized" ]
|
||||
reload = []
|
||||
authorized =[ "dep:jwt-compact" ]
|
||||
index = []
|
||||
info = [ "axum/json", "ssh-key/serde" ]
|
||||
|
||||
[[bin]]
|
||||
name = "sshcd-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.66"
|
||||
async-trait = "0.1.59"
|
||||
axum = { version = "0.6.1", features = ["http2"] }
|
||||
axum-extra = { version = "0.4.1", features = ["typed-routing"] }
|
||||
chrono = "0.4.23"
|
||||
clap = { version = "4.0.29", features = ["env", "derive"] }
|
||||
jwt-compact = { version = "0.6.0", features = ["serde_cbor", "std", "clock"], optional = true }
|
||||
rand = "0.8.5"
|
||||
serde = { version = "1.0.148", features = ["derive"] }
|
||||
ssh-key = { version = "0.6.0-rc.2", features = ["ed25519", "p256", "p384", "rsa"] }
|
||||
thiserror = "1.0.37"
|
||||
tokio = { version = "1.22.0", features = ["io-std", "test-util", "tracing", "macros", "fs"] }
|
||||
tower = { version = "0.4.13" }
|
||||
tower-http = { version = "0.3.4", features = ["map-request-body", "trace", "util"] }
|
||||
tracing = { version = "0.1.37", features = ["release_max_level_debug"] }
|
||||
tracing-subscriber = "0.3.16"
|
||||
ssh-cert-dist-common = { path = "../common" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.3.0"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 1
|
@ -1,10 +0,0 @@
|
||||
use clap::Parser;
|
||||
|
||||
mod api;
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
api::run(api::ApiArgs::parse()).await
|
||||
}
|
@ -1,38 +1,38 @@
|
||||
mod extract;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{self, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::certs::{load_cert_by_id, read_certs, read_pubkey, store_cert};
|
||||
use crate::env_key;
|
||||
use anyhow::Context;
|
||||
|
||||
use axum::body;
|
||||
use axum::extract::rejection::QueryRejection;
|
||||
use axum::extract::{Query, State};
|
||||
|
||||
use ssh_cert_dist_common::*;
|
||||
|
||||
use axum::{http::StatusCode, response::IntoResponse, Json, Router};
|
||||
use axum_extra::routing::RouterExt;
|
||||
use axum_extra::routing::{
|
||||
RouterExt, // for `Router::typed_*`
|
||||
TypedPath,
|
||||
};
|
||||
use clap::{Args, Parser};
|
||||
use jwt_compact::alg::{Hs256, Hs256Key};
|
||||
use jwt_compact::AlgorithmExt;
|
||||
use jwt_compact::{AlgorithmExt, Token, UntrustedToken};
|
||||
use rand::{thread_rng, Rng};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ssh_key::{Certificate, Fingerprint, PublicKey};
|
||||
use ssh_key::private::Ed25519Keypair;
|
||||
use ssh_key::{certificate, Certificate, PrivateKey, PublicKey};
|
||||
use tokio::sync::Mutex;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::{trace::TraceLayer, ServiceBuilderExt};
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
use self::extract::{CertificateBody, JWTAuthenticated, JWTString, SignatureBody};
|
||||
use self::extract::{AsJWTVerifier, CertificateBody, JWTAuthenticated, JWTString, SignatureBody};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "sshcd-server")]
|
||||
pub struct ApiArgs {
|
||||
#[clap(short = 'a', long = "address", env = env_key!("SOCKET_ADDRESS"))]
|
||||
address: SocketAddr,
|
||||
@ -78,7 +78,7 @@ impl Default for ApiArgs {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiState {
|
||||
struct ApiState {
|
||||
certs: Arc<Mutex<HashMap<String, Certificate>>>,
|
||||
cert_dir: PathBuf,
|
||||
ca: PublicKey,
|
||||
@ -87,6 +87,13 @@ pub struct ApiState {
|
||||
jwt_key: Hs256Key,
|
||||
}
|
||||
|
||||
impl AsJWTVerifier for ApiState {
|
||||
type Algo = Hs256;
|
||||
fn as_secret(&self) -> &<Self::Algo as jwt_compact::Algorithm>::VerifyingKey {
|
||||
&self.jwt_key
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiState {
|
||||
async fn new(
|
||||
cert_dir: impl AsRef<path::Path>,
|
||||
@ -141,7 +148,6 @@ pub async fn run(
|
||||
|
||||
let app = Router::new()
|
||||
.typed_get(get_certs_identifier)
|
||||
.typed_get(get_certs_pubkey)
|
||||
.typed_put(put_cert_update)
|
||||
.typed_get(get_cert_info)
|
||||
.typed_post(post_certs_identifier);
|
||||
@ -179,14 +185,10 @@ pub enum ApiError {
|
||||
AuthenticationRequired(String),
|
||||
#[error("invalid ssh signature")]
|
||||
InvalidSignature,
|
||||
#[error("malformed ssh signature: {0}")]
|
||||
ParseSignature(anyhow::Error),
|
||||
#[error("malformed ssh certificate: {0}")]
|
||||
ParseCertificate(anyhow::Error),
|
||||
#[error("{0}")]
|
||||
JWTParse(#[from] jwt_compact::ParseError),
|
||||
#[error("{0}")]
|
||||
#[error("invalid jwt")]
|
||||
JWTVerify(#[from] jwt_compact::ValidationError),
|
||||
#[error("invalid jwt")]
|
||||
JWTParse(#[from] jwt_compact::ParseError),
|
||||
#[error("{0}")]
|
||||
Query(#[from] QueryRejection),
|
||||
}
|
||||
@ -195,7 +197,6 @@ type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
trace!({ error = ?self }, "returned error for request");
|
||||
(
|
||||
match self {
|
||||
Self::CertificateNotFound => StatusCode::NOT_FOUND,
|
||||
@ -215,7 +216,10 @@ async fn fallback_404() -> ApiResult<()> {
|
||||
Err(ApiError::CertificateNotFound)
|
||||
}
|
||||
|
||||
#[cfg(feature = "index")]
|
||||
#[derive(TypedPath, Deserialize)]
|
||||
#[typed_path("/certs")]
|
||||
pub struct CertList;
|
||||
|
||||
async fn list_certs(
|
||||
_: CertList,
|
||||
State(ApiState { certs, .. }): State<ApiState>,
|
||||
@ -237,20 +241,10 @@ struct AuthClaims {
|
||||
identifier: String,
|
||||
}
|
||||
|
||||
async fn request_client_auth(enabled: bool, identifier: &str, jwt_key: &Hs256Key) -> ApiResult<()> {
|
||||
use jwt_compact::{Claims, Header, TimeOptions};
|
||||
if enabled {
|
||||
let claims = Claims::new(AuthClaims {
|
||||
identifier: identifier.into(),
|
||||
})
|
||||
.set_duration(&TimeOptions::default(), chrono::Duration::seconds(120));
|
||||
let challenge = Hs256
|
||||
.compact_token(Header::default(), &claims, &jwt_key)
|
||||
.context("jwt sign")?;
|
||||
return Err(ApiError::AuthenticationRequired(challenge));
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
#[derive(TypedPath, Deserialize)]
|
||||
#[typed_path("/certs/:identifier")]
|
||||
pub struct GetCert {
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
/// Retrieve an certificate for identifier
|
||||
@ -267,7 +261,16 @@ async fn get_certs_identifier(
|
||||
..
|
||||
}): State<ApiState>,
|
||||
) -> ApiResult<String> {
|
||||
request_client_auth(client_auth, &identifier, &jwt_key).await?;
|
||||
use jwt_compact::{AlgorithmExt, Claims, Header, TimeOptions};
|
||||
|
||||
if client_auth {
|
||||
let claims = Claims::new(AuthClaims { identifier })
|
||||
.set_duration(&TimeOptions::default(), chrono::Duration::seconds(120));
|
||||
let challenge = Hs256
|
||||
.compact_token(Header::default(), &claims, &jwt_key)
|
||||
.context("jwt sign")?;
|
||||
return Err(ApiError::AuthenticationRequired(challenge));
|
||||
}
|
||||
let certs = certs.lock().await;
|
||||
let cert = certs
|
||||
.get(&identifier)
|
||||
@ -275,22 +278,10 @@ async fn get_certs_identifier(
|
||||
Ok(cert.to_openssh().context("to openssh")?)
|
||||
}
|
||||
|
||||
async fn get_certs_pubkey(
|
||||
GetCertsPubkey { pubkey_hash }: GetCertsPubkey,
|
||||
State(ApiState {
|
||||
certs,
|
||||
jwt_key: _,
|
||||
client_auth: _,
|
||||
..
|
||||
}): State<ApiState>,
|
||||
) -> ApiResult<Json<CertIds>> {
|
||||
let certs = certs.lock().await;
|
||||
let ids = certs
|
||||
.values()
|
||||
.filter(|cert| &cert.public_key().fingerprint(pubkey_hash.algorithm()) == &pubkey_hash)
|
||||
.map(|cert| cert.key_id().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(CertIds { ids }))
|
||||
#[derive(TypedPath, Deserialize)]
|
||||
#[typed_path("/certs/:identifier/info")]
|
||||
pub struct GetCertInfo {
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "info")]
|
||||
@ -298,12 +289,9 @@ async fn get_certs_pubkey(
|
||||
struct CertInfo {
|
||||
principals: Vec<String>,
|
||||
ca: PublicKey,
|
||||
ca_hash: Fingerprint,
|
||||
identity: PublicKey,
|
||||
identity_hash: Fingerprint,
|
||||
key_id: String,
|
||||
expiry: SystemTime,
|
||||
renew_command: String,
|
||||
}
|
||||
|
||||
impl From<&Certificate> for CertInfo {
|
||||
@ -311,12 +299,9 @@ impl From<&Certificate> for CertInfo {
|
||||
CertInfo {
|
||||
principals: cert.valid_principals().to_vec(),
|
||||
ca: cert.signature_key().clone().into(),
|
||||
ca_hash: cert.signature_key().fingerprint(ssh_key::HashAlg::Sha256),
|
||||
identity: cert.public_key().clone().into(),
|
||||
identity_hash: cert.public_key().fingerprint(ssh_key::HashAlg::Sha256),
|
||||
key_id: cert.key_id().to_string(),
|
||||
expiry: cert.valid_before_time(),
|
||||
renew_command: renew_command(cert, "./ca", None),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -341,30 +326,39 @@ async fn get_cert_info(
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
#[derive(TypedPath, Deserialize)]
|
||||
#[typed_path("/certs/:identifier")]
|
||||
pub struct PostCertInfo {
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PostCertsQuery {
|
||||
challenge: String,
|
||||
}
|
||||
|
||||
impl From<Query<PostCertsQuery>> for JWTString {
|
||||
fn from(Query(PostCertsQuery { challenge }): Query<PostCertsQuery>) -> Self {
|
||||
Self::from(challenge)
|
||||
impl Into<JWTString> for Query<PostCertsQuery> {
|
||||
fn into(self) -> JWTString {
|
||||
self.0.challenge.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// POST with signed challenge
|
||||
async fn post_certs_identifier(
|
||||
PostCertInfo { identifier }: PostCertInfo,
|
||||
State(ApiState { certs, .. }): State<ApiState>,
|
||||
State(ApiState { certs, jwt_key, .. }): State<ApiState>,
|
||||
JWTAuthenticated {
|
||||
data: auth_claims, ..
|
||||
}: JWTAuthenticated<AuthClaims, Query<PostCertsQuery>>,
|
||||
data: AuthClaims {
|
||||
identifier: authenticated_identifier,
|
||||
},
|
||||
..
|
||||
}: JWTAuthenticated<AuthClaims, ApiState, Query<PostCertsQuery>, ApiError>,
|
||||
Query(PostCertsQuery { challenge }): Query<PostCertsQuery>,
|
||||
SignatureBody(sig): SignatureBody,
|
||||
) -> ApiResult<String> {
|
||||
let certs = certs.lock().await;
|
||||
let cert = certs.get(&identifier).ok_or(ApiError::InvalidSignature)?;
|
||||
if auth_claims.identifier != identifier {
|
||||
if authenticated_identifier != identifier {
|
||||
return Err(ApiError::InvalidSignature);
|
||||
}
|
||||
let pubkey: PublicKey = cert.public_key().clone().into();
|
||||
@ -379,6 +373,10 @@ async fn post_certs_identifier(
|
||||
Ok(cert.to_openssh().context("to openssh")?)
|
||||
}
|
||||
|
||||
#[derive(TypedPath)]
|
||||
#[typed_path("/cert")]
|
||||
pub struct PutCert;
|
||||
|
||||
/// Upload an cert with an higher serial than the previous
|
||||
async fn put_cert_update(
|
||||
_: PutCert,
|
||||
@ -427,14 +425,12 @@ async fn put_cert_update(
|
||||
let identity = cert.key_id();
|
||||
info!(%identity, ?principals, "updating certificate");
|
||||
certs.lock().await.insert(cert.key_id().to_string(), cert);
|
||||
Ok(format!("{prev_serial} -> {serial}"))
|
||||
Ok(format!("{} -> {}", prev_serial, serial))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ssh_key::{certificate, private::Ed25519Keypair, PrivateKey};
|
||||
use std::env::temp_dir;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
|
||||
@ -447,23 +443,14 @@ mod tests {
|
||||
}
|
||||
|
||||
fn ca_pub() -> PublicKey {
|
||||
PublicKey::new(
|
||||
ca_key().public.into(),
|
||||
format!(
|
||||
"TEST CA {}",
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
),
|
||||
)
|
||||
PublicKey::new(ca_key().public.into(), "TEST CA")
|
||||
}
|
||||
|
||||
fn user_key() -> Ed25519Keypair {
|
||||
Ed25519Keypair::from_seed(&[1u8; 32])
|
||||
}
|
||||
|
||||
fn user_cert(ca: Ed25519Keypair, user_key: PublicKey, validity: Duration) -> Certificate {
|
||||
fn user_cert(ca: Ed25519Keypair, user_key: PublicKey) -> Certificate {
|
||||
let ca_private: PrivateKey = ca.into();
|
||||
let unix_time = |time: SystemTime| -> u64 {
|
||||
time.duration_since(SystemTime::UNIX_EPOCH)
|
||||
@ -474,16 +461,15 @@ mod tests {
|
||||
[0u8; 16],
|
||||
user_key,
|
||||
unix_time(SystemTime::now()),
|
||||
unix_time(SystemTime::now() + validity),
|
||||
)
|
||||
.unwrap();
|
||||
unix_time(SystemTime::now() + Duration::from_secs(30)),
|
||||
);
|
||||
|
||||
builder
|
||||
.valid_principal("git")
|
||||
.unwrap()
|
||||
.key_id("test_cert")
|
||||
.unwrap()
|
||||
.comment(&format!("A TEST CERT, VALID FOR {}s", validity.as_secs()))
|
||||
.comment("A TEST CERT")
|
||||
.unwrap();
|
||||
|
||||
builder.sign(&ca_private).unwrap()
|
||||
@ -497,49 +483,24 @@ mod tests {
|
||||
cert_dir: dbg!(temp_dir()),
|
||||
validation_args: Default::default(),
|
||||
client_auth: false,
|
||||
jwt_key: Hs256Key::new([0u8; 16]),
|
||||
jwt_key: Hs256Key::new(&[0u8; 16]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_certificate() {
|
||||
let valid_cert = user_cert(ca_key(), user_key().public.into(), Duration::from_secs(30));
|
||||
let valid_cert = user_cert(ca_key(), user_key().public.into());
|
||||
let ca_pub: PublicKey = ca_pub();
|
||||
assert!(valid_cert
|
||||
.validate(&[ca_pub.fingerprint(Default::default())])
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_cert() {
|
||||
let state = api_state();
|
||||
let ca = ca_key();
|
||||
let user: PublicKey = user_key().public.into();
|
||||
let (cert_first, cert_newer, cert_outdated) = {
|
||||
(
|
||||
user_cert(ca.clone(), user.clone(), Duration::from_secs(300)),
|
||||
user_cert(ca.clone(), user.clone(), Duration::from_secs(600)),
|
||||
user_cert(ca.clone(), user.clone(), Duration::from_secs(30)),
|
||||
)
|
||||
};
|
||||
let res = put_cert_update(PutCert, State(state.clone()), CertificateBody(cert_first)).await;
|
||||
assert!(dbg!(res).is_ok());
|
||||
let res = put_cert_update(PutCert, State(state.clone()), CertificateBody(cert_newer)).await;
|
||||
assert!(res.is_ok());
|
||||
let res = put_cert_update(
|
||||
PutCert,
|
||||
State(state.clone()),
|
||||
CertificateBody(cert_outdated),
|
||||
)
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn routes() -> anyhow::Result<()> {
|
||||
let state = api_state();
|
||||
let valid_cert = user_cert(ca_key(), user_key().public.into(), Duration::from_secs(30));
|
||||
let invalid_cert = user_cert(ca_key2(), user_key().public.into(), Duration::from_secs(30));
|
||||
let valid_cert = user_cert(ca_key(), user_key().public.into());
|
||||
let invalid_cert = user_cert(ca_key2(), user_key().public.into());
|
||||
let res = put_cert_update(
|
||||
PutCert,
|
||||
State(state.clone()),
|
||||
@ -596,7 +557,7 @@ mod tests {
|
||||
identifier: "test_cert".into(),
|
||||
},
|
||||
State(state.clone()),
|
||||
JWTAuthenticated::new(AuthClaims {
|
||||
JWTAuthenticated::from(AuthClaims {
|
||||
identifier: "test_cert".into(),
|
||||
}),
|
||||
Query(PostCertsQuery { challenge }),
|
@ -1,17 +1,21 @@
|
||||
use std::fmt::Debug;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use super::{ApiError, ApiState};
|
||||
use super::ApiError;
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
async_trait,
|
||||
body::BoxBody,
|
||||
extract::{FromRequest, FromRequestParts},
|
||||
http::Request,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use jwt_compact::{alg::Hs256, AlgorithmExt, Token, UntrustedToken};
|
||||
use jwt_compact::{
|
||||
alg::{SigningKey, VerifyingKey},
|
||||
AlgorithmSignature, ParseError, Token, UntrustedToken, ValidationError,
|
||||
};
|
||||
use jwt_compact::{Algorithm, AlgorithmExt};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use ssh_key::{Certificate, SshSig};
|
||||
use std::marker::PhantomData;
|
||||
use std::{fmt::Debug, ops::Deref};
|
||||
use tracing::trace;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@ -31,8 +35,7 @@ where
|
||||
.context("failed to extract body")?;
|
||||
|
||||
let cert = Certificate::from_openssh(&body)
|
||||
.with_context(|| format!("failed to parse '{}'", body))
|
||||
.map_err(ApiError::ParseCertificate)?;
|
||||
.with_context(|| format!("failed to parse '{}'", body))?;
|
||||
trace!(%body, "extracted certificate");
|
||||
Ok(Self(cert))
|
||||
}
|
||||
@ -53,14 +56,17 @@ where
|
||||
.await
|
||||
.context("failed to extract body")?;
|
||||
|
||||
let sig = SshSig::from_pem(&body)
|
||||
.with_context(|| format!("failed to parse '{}'", body))
|
||||
.map_err(ApiError::ParseSignature)?;
|
||||
let sig = SshSig::from_pem(&body).with_context(|| format!("failed to parse '{}'", body))?;
|
||||
trace!(%body, "extracted signature");
|
||||
Ok(Self(sig))
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AsJWTVerifier: Send + Sync {
|
||||
type Algo: Algorithm + Default;
|
||||
fn as_secret(&self) -> &<Self::Algo as Algorithm>::VerifyingKey;
|
||||
}
|
||||
|
||||
pub struct JWTString(String);
|
||||
|
||||
impl From<String> for JWTString {
|
||||
@ -69,24 +75,39 @@ impl From<String> for JWTString {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: be generic over ApiState -> AsRef<Target=Hs256>, AsRef<Target=A> where A: AlgorithmExt
|
||||
#[derive(Debug)]
|
||||
pub struct JWTAuthenticated<
|
||||
T: Serialize + DeserializeOwned + Clone + Debug,
|
||||
Q: FromRequestParts<ApiState> + Debug + Into<JWTString>,
|
||||
> where
|
||||
ApiError: From<<Q as FromRequestParts<ApiState>>::Rejection>,
|
||||
{
|
||||
pub struct JWTAuthenticated<T, S, Q, E> {
|
||||
pub data: T,
|
||||
_marker: PhantomData<Q>,
|
||||
_marker: PhantomData<(Q, S, E)>,
|
||||
}
|
||||
|
||||
impl<T, S, Q, E> From<T> for JWTAuthenticated<T, S, Q, E> {
|
||||
fn from(data: T) -> Self {
|
||||
Self {
|
||||
data,
|
||||
_marker: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, S, Q, E> Deref for JWTAuthenticated<T, S, Q, E> {
|
||||
type Target = T;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.data
|
||||
}
|
||||
}
|
||||
|
||||
impl<
|
||||
T: Serialize + DeserializeOwned + Clone + Debug,
|
||||
Q: FromRequestParts<ApiState> + Debug + Into<JWTString>,
|
||||
> JWTAuthenticated<T, Q>
|
||||
where
|
||||
ApiError: From<<Q as FromRequestParts<ApiState>>::Rejection>,
|
||||
S: AsJWTVerifier,
|
||||
Q: FromRequestParts<S> + Debug + Into<JWTString>,
|
||||
E: From<<Q as FromRequestParts<S>>::Rejection>
|
||||
+ From<ValidationError>
|
||||
+ From<ParseError>
|
||||
+ Debug
|
||||
+ Send
|
||||
+ Sync,
|
||||
> JWTAuthenticated<T, S, Q, E>
|
||||
{
|
||||
pub fn new(data: T) -> Self {
|
||||
Self {
|
||||
@ -99,22 +120,27 @@ where
|
||||
#[async_trait]
|
||||
impl<
|
||||
T: Serialize + DeserializeOwned + Clone + Debug,
|
||||
Q: FromRequestParts<ApiState> + Debug + Into<JWTString>,
|
||||
> FromRequestParts<ApiState> for JWTAuthenticated<T, Q>
|
||||
where
|
||||
ApiError: From<<Q as FromRequestParts<ApiState>>::Rejection>,
|
||||
S: AsJWTVerifier,
|
||||
Q: FromRequestParts<S> + Debug + Into<JWTString>,
|
||||
E: From<<Q as FromRequestParts<S>>::Rejection>
|
||||
+ From<ValidationError>
|
||||
+ From<ParseError>
|
||||
+ Debug
|
||||
+ Send
|
||||
+ Sync
|
||||
+ IntoResponse,
|
||||
> FromRequestParts<S> for JWTAuthenticated<T, S, Q, E>
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
type Rejection = E;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
state: &ApiState,
|
||||
state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let JWTString(token) = Q::from_request_parts(parts, state).await?.into();
|
||||
let token = UntrustedToken::new(&token).map_err(ApiError::JWTParse)?;
|
||||
let verified: Token<T> = Hs256
|
||||
.validate_integrity(&token, &state.jwt_key)
|
||||
.map_err(ApiError::JWTVerify)?;
|
||||
let token = UntrustedToken::new(&token)?;
|
||||
let verified: Token<T> =
|
||||
<S::Algo as Default>::default().validate_integrity(&token, &state.as_secret())?;
|
||||
Ok(Self {
|
||||
data: verified.claims().custom.clone(),
|
||||
_marker: Default::default(),
|
@ -24,11 +24,11 @@ pub async fn read_certs(
|
||||
if !ca_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
read_certs_dir(&ca_dir).await
|
||||
read_dir(&ca_dir).await
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
pub async fn read_certs_dir(path: impl AsRef<Path> + Debug) -> anyhow::Result<Vec<Certificate>> {
|
||||
pub async fn read_dir(path: impl AsRef<Path> + Debug) -> anyhow::Result<Vec<Certificate>> {
|
||||
let mut dir = fs::read_dir(path.as_ref())
|
||||
.await
|
||||
.with_context(|| format!("read certs dir '{:?}'", path.as_ref()))?;
|
||||
@ -55,26 +55,6 @@ pub async fn read_certs_dir(path: impl AsRef<Path> + Debug) -> anyhow::Result<Ve
|
||||
Ok(certs)
|
||||
}
|
||||
|
||||
pub async fn read_pubkey_dir(path: impl AsRef<Path> + Debug) -> anyhow::Result<Vec<PublicKey>> {
|
||||
let mut dir = fs::read_dir(path.as_ref())
|
||||
.await
|
||||
.with_context(|| format!("read certs dir '{:?}'", path.as_ref()))?;
|
||||
let mut pubs = Vec::new();
|
||||
while let Some(entry) = dir.next_entry().await? {
|
||||
//TODO: investigate why path().ends_with doesn't work
|
||||
let file_name = entry.file_name().into_string().unwrap();
|
||||
if !file_name.ends_with(".pub") || file_name.ends_with("-cert.pub") {
|
||||
trace!("skipped {:?} due to missing '.pub' extension", entry.path());
|
||||
continue;
|
||||
}
|
||||
let cert = load_public_key(entry.path()).await?;
|
||||
if let Some(cert) = cert {
|
||||
pubs.push(cert);
|
||||
}
|
||||
}
|
||||
Ok(pubs)
|
||||
}
|
||||
|
||||
fn parse_utf8(bytes: Vec<u8>) -> anyhow::Result<String> {
|
||||
String::from_utf8(bytes).context("invalid utf-8")
|
||||
}
|
||||
@ -142,15 +122,3 @@ pub async fn load_cert(file: impl AsRef<Path> + Debug) -> anyhow::Result<Option<
|
||||
|| format!("parse {:?} as openssh certificate", &file),
|
||||
)?))
|
||||
}
|
||||
|
||||
pub async fn load_public_key(file: impl AsRef<Path> + Debug) -> anyhow::Result<Option<PublicKey>> {
|
||||
let contents = match fs::read(&file).await {
|
||||
Ok(contents) => contents,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e).with_context(|| format!("read {:?}", &file)),
|
||||
};
|
||||
let string_repr = parse_utf8(contents)?;
|
||||
Ok(Some(PublicKey::from_openssh(&string_repr).with_context(
|
||||
|| format!("parse {:?} as openssh public key", &file),
|
||||
)?))
|
||||
}
|
@ -1,24 +1,25 @@
|
||||
use anyhow::{bail, Context};
|
||||
use anyhow::bail;
|
||||
use axum_extra::routing::TypedPath;
|
||||
use clap::{CommandFactory, Parser, Subcommand, ValueHint};
|
||||
use clap_complete_command::Shell;
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use ssh_key::Certificate;
|
||||
use std::io::{stdin, stdout};
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use tokio::fs;
|
||||
use tokio::io::{stdin, AsyncBufReadExt, BufReader};
|
||||
use tracing::{debug, error, info, instrument, trace};
|
||||
|
||||
use url::Url;
|
||||
|
||||
use ssh_cert_dist_common::*;
|
||||
use crate::api::PutCert;
|
||||
use crate::certs::load_cert;
|
||||
use crate::env_key;
|
||||
use crate::{api::GetCert, certs::read_dir};
|
||||
|
||||
#[derive(Parser)]
|
||||
pub struct ClientArgs {
|
||||
/// Url for the API endpoint
|
||||
#[clap(short = 'a', long = "api-endpoint",value_hint = ValueHint::Url, env = env_key!("API"))]
|
||||
#[clap(short = 'a', long = "api-endpoint", env = env_key!("API"))]
|
||||
api: Url,
|
||||
/// Require interaction before writing certificates
|
||||
#[clap(short = 'i', long = "interactive", env = env_key!("INTERACTIVE"))]
|
||||
@ -29,9 +30,7 @@ pub struct ClientArgs {
|
||||
pub struct FetchArgs {
|
||||
#[clap(flatten)]
|
||||
args: ClientArgs,
|
||||
#[clap(short = 'k', long = "key-update", env = env_key!("KEY_UPDATE"))]
|
||||
prohibit_key_update: bool,
|
||||
#[clap(short = 'c', long = "cert-dir",value_hint = ValueHint::DirPath, env = env_key!("CERT_DIR"))]
|
||||
#[clap(short = 'c', long = "cert-dir", env = env_key!("CERT_DIR") )]
|
||||
cert_dir: PathBuf,
|
||||
/// minimum time in days between now and expiry to consider checking
|
||||
#[clap(short = 'd', long = "days", default_value = "60", env = env_key!("MIN_DELTA_DAYS"))]
|
||||
@ -43,25 +42,11 @@ pub struct UploadArgs {
|
||||
#[clap(flatten)]
|
||||
args: ClientArgs,
|
||||
/// Certificates to be uploaded
|
||||
#[clap(value_hint = ValueHint::FilePath, env = env_key!("FILES"))]
|
||||
#[clap(env = env_key!("FILES"))]
|
||||
files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
pub struct RenewCommandArgs {
|
||||
/// Execute the renew command
|
||||
#[clap(short = 'x')]
|
||||
execute: bool,
|
||||
/// Path to the CA private key
|
||||
#[clap(long="ca",value_hint = ValueHint::DirPath, env = env_key!("CA_KEY"))]
|
||||
ca_key: Option<PathBuf>,
|
||||
/// Certificates to generate commands for
|
||||
#[clap(value_hint = ValueHint::FilePath,env = env_key!("FILES"))]
|
||||
files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "sshcd")]
|
||||
#[derive(Args)]
|
||||
pub struct ClientCommand {
|
||||
#[clap(subcommand)]
|
||||
cmd: ClientCommands,
|
||||
@ -71,23 +56,12 @@ pub struct ClientCommand {
|
||||
pub enum ClientCommands {
|
||||
Fetch(FetchArgs),
|
||||
Upload(UploadArgs),
|
||||
RenewCommand(RenewCommandArgs),
|
||||
#[clap(hide = true)]
|
||||
Completions {
|
||||
#[arg(long = "shell", value_enum)]
|
||||
shell: Shell,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(ClientCommand { cmd }: ClientCommand) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
ClientCommands::Fetch(args) => fetch(args).await,
|
||||
ClientCommands::Upload(args) => upload(args).await,
|
||||
ClientCommands::RenewCommand(args) => renew(args).await,
|
||||
ClientCommands::Completions { shell } => {
|
||||
shell.generate(&mut ClientCommand::command(), &mut std::io::stdout());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,18 +112,15 @@ async fn upload_cert(client: Client, url: Url, cert: Certificate) -> anyhow::Res
|
||||
async fn fetch(
|
||||
FetchArgs {
|
||||
cert_dir,
|
||||
prohibit_key_update,
|
||||
min_delta_days: min_delta,
|
||||
args: ClientArgs { api, interactive },
|
||||
}: FetchArgs,
|
||||
) -> anyhow::Result<()> {
|
||||
let certs = read_certs_dir(&cert_dir).await?;
|
||||
// let publics_keys = read_pubkey_dir(&cert_dir).await?;
|
||||
let certs = read_dir(&cert_dir).await?;
|
||||
let client = reqwest::Client::new();
|
||||
let threshold_exp = min_delta.and_then(|min_delta| {
|
||||
SystemTime::now().checked_add(Duration::from_secs(60 * 60 * 24 * min_delta as u64))
|
||||
});
|
||||
// let standalone_certs = publics_keys.into_iter().map(|(name, key)| )
|
||||
let updates = certs
|
||||
.into_iter()
|
||||
.filter(|cert| {
|
||||
@ -166,13 +137,8 @@ async fn fetch(
|
||||
let client = client.clone();
|
||||
tokio::spawn(async move { fetch_cert(client, url, cert).await })
|
||||
});
|
||||
let mut stdin = BufReader::new(stdin()).lines();
|
||||
for cert in updates {
|
||||
if let Ok(Some((cert, update))) = cert.await? {
|
||||
if prohibit_key_update && cert.public_key() != update.public_key() {
|
||||
debug!(?update, "skipping cert due to key change");
|
||||
continue;
|
||||
}
|
||||
if interactive {
|
||||
println!("certificate update: {}", cert.key_id());
|
||||
println!(
|
||||
@ -181,8 +147,9 @@ async fn fetch(
|
||||
update.valid_before()
|
||||
);
|
||||
println!("update? : (y/n)");
|
||||
let yes = stdin.next_line().await?;
|
||||
if !matches!(yes, Some(line) if line.starts_with(['y', 'Y'])) {
|
||||
let mut yes = String::with_capacity(3);
|
||||
stdin().read_line(&mut yes)?;
|
||||
if !yes.starts_with(['y', 'Y']) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -197,40 +164,6 @@ async fn fetch(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn renew(
|
||||
RenewCommandArgs {
|
||||
files,
|
||||
ca_key,
|
||||
execute,
|
||||
}: RenewCommandArgs,
|
||||
) -> anyhow::Result<()> {
|
||||
for file in files.iter() {
|
||||
let cert = load_cert(&file).await?;
|
||||
if let Some(cert) = cert {
|
||||
let command = renew_command(
|
||||
&cert,
|
||||
ca_key
|
||||
.as_deref()
|
||||
.map(|path| path.to_str())
|
||||
.flatten()
|
||||
.unwrap_or("ca"),
|
||||
file.to_str(),
|
||||
);
|
||||
println!("{}", command);
|
||||
if execute {
|
||||
process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&command)
|
||||
.spawn()
|
||||
.with_context(|| format!("{command}"))?;
|
||||
}
|
||||
} else {
|
||||
bail!("{file:?} doesn't exist");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(client, current))]
|
||||
async fn fetch_cert(
|
||||
client: Client,
|
35
src/main.rs
Normal file
35
src/main.rs
Normal file
@ -0,0 +1,35 @@
|
||||
use api::ApiArgs;
|
||||
use clap::Parser;
|
||||
#[cfg(feature = "client")]
|
||||
use client::ClientCommand;
|
||||
|
||||
mod api;
|
||||
mod certs;
|
||||
#[cfg(feature = "client")]
|
||||
mod client;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! env_key {
|
||||
( $var:expr ) => {
|
||||
concat!("SSH_CD_", $var)
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
enum Command {
|
||||
Server(ApiArgs),
|
||||
#[cfg(feature = "client")]
|
||||
Client(ClientCommand),
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
match Command::parse() {
|
||||
Command::Server(args) => api::run(args).await?,
|
||||
#[cfg(feature = "client")]
|
||||
Command::Client(args) => client::run(args).await?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user