Compare commits

..

14 Commits

Author SHA1 Message Date
dbca99308e flake.lock: Update
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/5e4c2ada4fcd54b99d56d7bd62f384511a7e2593' (2023-10-11)
  → 'github:NixOS/nixpkgs/842d9d80cfd4560648c785f8a4e6f3b096790e19' (2024-01-17)
• Updated input 'utils':
    'github:numtide/flake-utils/ff7b65b44d01cf9ba6a71320833626af21126384' (2023-09-12)
  → 'github:numtide/flake-utils/d465f4819400de7c8d874d50b982301f28a84605' (2024-02-28)
2024-12-16 08:42:16 +01:00
e8830e812b fix: pkg-config 2024-12-16 08:42:16 +01:00
7509d63582
chore: update
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-11-12 10:35:48 +01:00
c37d40389a
flake.lock: Update
Flake lock file updates:

• Updated input 'naersk':
    'github:nmattia/naersk/abca1fb7a6cfdd355231fc220c3d0302dbb4369a' (2023-07-05)
  → 'github:nmattia/naersk/aeb58d5e8faead8980a807c840232697982d47b9' (2023-10-27)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/3c7487575d9445185249a159046cc02ff364bff8' (2023-07-06)
  → 'github:NixOS/nixpkgs/5e4c2ada4fcd54b99d56d7bd62f384511a7e2593' (2023-10-11)
• Updated input 'utils':
    'github:numtide/flake-utils/dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7' (2023-06-25)
  → 'github:numtide/flake-utils/ff7b65b44d01cf9ba6a71320833626af21126384' (2023-09-12)
2023-11-12 10:35:27 +01:00
9f6a5e03c9
feat(complete): add command name
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-12 22:52:59 +02:00
675dd4faf6
feat(renew): display seconds
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-07-12 15:15:25 +02:00
6cb7ce4a78
feat(completions): fix cmd
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-07-12 08:43:49 +02:00
b8505790f2
generate shell completions
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-07-12 08:35:29 +02:00
d4c579c4c8
update ssh-key
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-10 15:44:25 +02:00
9d405a6324
feat(home-manager): create timer unit
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-10 08:58:51 +02:00
1183ba0d73
chore: cleanup
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-09 20:22:44 +02:00
df85bad9a4
feat(ci): added woodpecker
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-07-09 20:16:20 +02:00
cbb99138a9
feat(renew-cmd): added 2023-07-09 20:03:20 +02:00
591858ef05 Merge pull request 'Separate crates for server and client binaries' (#1) from split_components into master
Reviewed-on: #1
2023-07-09 19:27:21 +02:00
13 changed files with 516 additions and 417 deletions

5
.woodpecker.yml Normal file
View File

@ -0,0 +1,5 @@
pipeline:
test:
image: rust
commands:
- cargo test

691
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -17,13 +17,14 @@ 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-pre.0", features = ["ed25519", "p256", "p384", "rsa", "serde"] }
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"

View File

@ -1,9 +1,11 @@
use anyhow::bail;
use anyhow::{bail, Context};
use axum_extra::routing::TypedPath;
use clap::{Parser, Subcommand};
use clap::{CommandFactory, Parser, Subcommand, ValueHint};
use clap_complete_command::Shell;
use reqwest::{Client, StatusCode};
use ssh_key::Certificate;
use std::path::PathBuf;
use std::process;
use std::time::{Duration, SystemTime};
use tokio::fs;
use tokio::io::{stdin, AsyncBufReadExt, BufReader};
@ -16,7 +18,7 @@ use ssh_cert_dist_common::*;
#[derive(Parser)]
pub struct ClientArgs {
/// Url for the API endpoint
#[clap(short = 'a', long = "api-endpoint", env = env_key!("API"))]
#[clap(short = 'a', long = "api-endpoint",value_hint = ValueHint::Url, env = env_key!("API"))]
api: Url,
/// Require interaction before writing certificates
#[clap(short = 'i', long = "interactive", env = env_key!("INTERACTIVE"))]
@ -29,7 +31,7 @@ pub struct FetchArgs {
args: ClientArgs,
#[clap(short = 'k', long = "key-update", env = env_key!("KEY_UPDATE"))]
prohibit_key_update: bool,
#[clap(short = 'c', long = "cert-dir", env = env_key!("CERT_DIR"))]
#[clap(short = 'c', long = "cert-dir",value_hint = ValueHint::DirPath, 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"))]
@ -41,11 +43,25 @@ pub struct UploadArgs {
#[clap(flatten)]
args: ClientArgs,
/// Certificates to be uploaded
#[clap(env = env_key!("FILES"))]
#[clap(value_hint = ValueHint::FilePath, 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")]
pub struct ClientCommand {
#[clap(subcommand)]
cmd: ClientCommands,
@ -55,12 +71,23 @@ 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(())
}
}
}
@ -170,6 +197,40 @@ 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,

View File

@ -11,13 +11,15 @@ 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-pre.0", features = ["ed25519", "p256", "p384", "rsa"] }
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"

View File

@ -1,6 +1,8 @@
mod certs;
mod renew;
mod routes;
mod util;
pub use certs::*;
pub use renew::*;
pub use routes::*;

49
common/src/renew.rs Normal file
View File

@ -0,0 +1,49 @@
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
}

18
flake.lock generated
View File

@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1688534083,
"narHash": "sha256-/bI5vsioXscQTsx+Hk9X5HfweeNZz/6kVKsbdqfwW7g=",
"lastModified": 1698420672,
"narHash": "sha256-/TdeHMPRjjdJub7p7+w55vyABrsJlt5QkznPYy55vKA=",
"owner": "nmattia",
"repo": "naersk",
"rev": "abca1fb7a6cfdd355231fc220c3d0302dbb4369a",
"rev": "aeb58d5e8faead8980a807c840232697982d47b9",
"type": "github"
},
"original": {
@ -22,11 +22,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1688679045,
"narHash": "sha256-t3xGEfYIwhaLTPU8FLtN/pLPytNeDwbLI6a7XFFBlGo=",
"lastModified": 1705496572,
"narHash": "sha256-rPIe9G5EBLXdBdn9ilGc0nq082lzQd0xGGe092R/5QE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3c7487575d9445185249a159046cc02ff364bff8",
"rev": "842d9d80cfd4560648c785f8a4e6f3b096790e19",
"type": "github"
},
"original": {
@ -61,11 +61,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1687709756,
"narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=",
"lastModified": 1709126324,
"narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7",
"rev": "d465f4819400de7c8d874d50b982301f28a84605",
"type": "github"
},
"original": {

View File

@ -123,17 +123,31 @@
];
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" =
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;
};
};

View File

@ -11,8 +11,8 @@ in
Unit.Description = "ssh-cert-dist service for ${path}";
Service = {
Environment = "RUST_LOG=debug";
ExecStart = toString (pkgs.writeShellApplication {
name = "ssh-cert-dist-${options.name}";
ExecStart = "${pkgs.writeShellApplication {
name = "sshcd";
runtimeInputs = [ cfg.package ];
text = ''
${optionalString options.fetch ''
@ -22,11 +22,24 @@ in
sshcd 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;
};

View File

@ -13,6 +13,11 @@
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 {

View File

@ -27,7 +27,7 @@ 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-pre.0", features = ["ed25519", "p256", "p384", "rsa"] }
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" }
@ -35,7 +35,6 @@ 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" }
shell-escape = "0.1.5"
[dev-dependencies]
tempfile = "3.3.0"

View File

@ -6,23 +6,21 @@ use std::fmt::Debug;
use std::net::SocketAddr;
use std::path::{self, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::SystemTime;
use anyhow::Context;
use axum::body;
use axum::extract::rejection::QueryRejection;
use axum::extract::{Query, State};
use chrono::Duration;
use shell_escape::escape;
use ssh_cert_dist_common::*;
use axum::{http::StatusCode, response::IntoResponse, Json, Router};
use axum_extra::routing::RouterExt;
use clap::{Args, Parser};
use jwt_compact::alg::{Hs256, Hs256Key};
use jwt_compact::{AlgorithmExt};
use jwt_compact::AlgorithmExt;
use rand::{thread_rng, Rng};
use serde::{Deserialize, Serialize};
use ssh_key::{Certificate, Fingerprint, PublicKey};
@ -31,9 +29,10 @@ use tower::ServiceBuilder;
use tower_http::{trace::TraceLayer, ServiceBuilderExt};
use tracing::{debug, info, trace};
use self::extract::{CertificateBody, SignatureBody, JWTAuthenticated, JWTString};
use self::extract::{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,
@ -309,40 +308,6 @@ struct CertInfo {
impl From<&Certificate> for CertInfo {
fn from(cert: &Certificate) -> Self {
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_key {host_key} -I {} -n {} -z {} -V {:#x}:{:#x} {opts} {}.pub",
escape(cert.key_id().into()),
escape(cert.valid_principals().join(",").into()),
cert.serial() + 1,
cert.valid_after(),
expiry_date.as_secs(),
escape(cert.key_id().into())
);
CertInfo {
principals: cert.valid_principals().to_vec(),
ca: cert.signature_key().clone().into(),
@ -351,7 +316,7 @@ impl From<&Certificate> for CertInfo {
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: renew_command(cert, "./ca", None),
}
}
}
@ -387,7 +352,6 @@ impl From<Query<PostCertsQuery>> for JWTString {
}
}
/// POST with signed challenge
async fn post_certs_identifier(
PostCertInfo { identifier }: PostCertInfo,
@ -511,7 +475,8 @@ mod tests {
user_key,
unix_time(SystemTime::now()),
unix_time(SystemTime::now() + validity),
);
)
.unwrap();
builder
.valid_principal("git")