Lots more updates

Also begin adding rust building capabilities
to be able to write rust binaries for some commands.
This commit is contained in:
Kaare Hoff Skovgaard 2025-07-06 22:37:16 +02:00
parent 624508dd14
commit dd1cfa79e7
Signed by: khs
GPG key ID: C7D890804F01E9F0
52 changed files with 2509 additions and 150 deletions

1
.direnv/flake-profile Symbolic link
View file

@ -0,0 +1 @@
flake-profile-2-link

View file

@ -0,0 +1 @@
/nix/store/dqy98711cp1rqz8nj7qpxq6qj6vnyf0j-nix-shell-env

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

3
.gitignore vendored
View file

@ -1,2 +1,5 @@
result/
.DS_Store
.terraform-cache/*/*/config.tf.json
.terraform-cache/*/*/.terraform
rust/target

54
flake.lock generated
View file

@ -1,5 +1,21 @@
{
"nodes": {
"advisory-db": {
"flake": false,
"locked": {
"lastModified": 1750151065,
"narHash": "sha256-il+CAqChFIB82xP6bO43dWlUVs+NlG7a4g8liIP5HcI=",
"owner": "rustsec",
"repo": "advisory-db",
"rev": "7573f55ba337263f61167dbb0ea926cdc7c8eb5d",
"type": "github"
},
"original": {
"owner": "rustsec",
"repo": "advisory-db",
"type": "github"
}
},
"bats-assert": {
"flake": false,
"locked": {
@ -32,6 +48,21 @@
"type": "github"
}
},
"crane": {
"locked": {
"lastModified": 1751562746,
"narHash": "sha256-smpugNIkmDeicNz301Ll1bD7nFOty97T79m4GUMUczA=",
"owner": "ipetkov",
"repo": "crane",
"rev": "aed2020fd3dc26e1e857d4107a5a67a33ab6c1fd",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"disko": {
"inputs": {
"nixpkgs": [
@ -211,13 +242,36 @@
},
"root": {
"inputs": {
"advisory-db": "advisory-db",
"crane": "crane",
"disko": "disko",
"flake-base": "flake-base",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay",
"terranix": "terranix",
"terranix-hcloud": "terranix-hcloud"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1751769931,
"narHash": "sha256-QR2Rp/41NkA5YxcpvZEKD1S2QE1Pb9U415aK8M/4tJc=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "3ac4f630e375177ea8317e22f5c804156de177e8",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"snowfall-lib": {
"inputs": {
"flake-compat": "flake-compat",

View file

@ -19,6 +19,17 @@
url = "github:terranix/terranix-hcloud";
inputs.nixpkgs.follows = "nixpkgs";
};
crane.url = "github:ipetkov/crane";
advisory-db = {
url = "github:rustsec/advisory-db";
flake = false;
};
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs = {
nixpkgs.follows = "nixpkgs";
};
};
};
outputs =
@ -28,7 +39,7 @@
path:
let
files = builtins.readDir path;
dirs = builtins.filterAttrs (name: kind: kind == "directory") files;
dirs = inputs.nixpkgs.lib.filterAttrs (name: kind: kind == "directory") files;
in
builtins.attrNames dirs;
profileArgs = { inherit self; };
@ -60,6 +71,7 @@
}) profileNames
));
};
overlays = [ inputs.rust-overlay.overlays.default ];
})
// {
terranixModules.cloudflare = import ./nix/modules/terranix/cloudflare {
@ -70,5 +82,9 @@
inherit inputs;
khscodesLib = inputs.self.lib;
};
terranixModules.openbao = import ./nix/modules/terranix/openbao {
inherit inputs;
khscodesLib = inputs.self.lib;
};
};
}

View file

@ -0,0 +1,7 @@
{
inputs,
pkgs,
lib,
...
}:
(lib.khscodes.mkRust pkgs "${inputs.self}/rust").checks.rust-audit

View file

@ -0,0 +1,7 @@
{
inputs,
pkgs,
lib,
...
}:
(lib.khscodes.mkRust pkgs "${inputs.self}/rust").checks.rust-clippy

View file

@ -0,0 +1,7 @@
{
inputs,
pkgs,
lib,
...
}:
(lib.khscodes.mkRust pkgs "${inputs.self}/rust").checks.rust-doc

View file

@ -0,0 +1,7 @@
{
inputs,
pkgs,
lib,
...
}:
(lib.khscodes.mkRust pkgs "${inputs.self}/rust").checks.rust-fmt

View file

@ -0,0 +1,7 @@
{
inputs,
pkgs,
lib,
...
}:
(lib.khscodes.mkRust pkgs "${inputs.self}/rust").checks.rust-hakari

14
nix/lib/rust/default.nix Normal file
View file

@ -0,0 +1,14 @@
{
inputs,
lib,
...
}:
{
mkRust =
pkgs: src:
import src {
inherit lib pkgs;
crane = inputs.crane;
advisory-db = inputs.advisory-db;
};
}

View file

@ -19,8 +19,8 @@ in
domain = if hostname == cfg then null else (lib.strings.removePrefix "${hostname}." cfg);
in
{
networking.hostName = hostname;
networking.domain = domain;
networking.hostName = lib.mkForce hostname;
networking.domain = lib.mkForce domain;
boot.kernel.sysctl = {
"kernel.hostname" = cfg;
};

View file

@ -6,12 +6,8 @@
...
}:
let
cfg = config.khscodes.terraform-hetzner;
cfg = config.khscodes.hetzner-instance;
fqdn = config.khscodes.fqdn;
hostPkgs = import inputs.nixpkgs {
system = pkgs.buildPlatform.system;
overlays = [ inputs.self.overlays.bitwarden-cli ];
};
firewallTcpRules = lib.lists.map (p: {
direction = "in";
protocol = "tcp";
@ -41,7 +37,6 @@ let
};
firewallRules = firewallTcpRules ++ firewallUdpRules ++ firewallIcmpRules ++ cfg.extraFirewallRules;
firewallEnable = config.networking.firewall.enable;
mapRdns = cfg.mapRdns;
tldFromFqdn =
fqdn:
let
@ -53,8 +48,8 @@ let
lib.strings.removePrefix "${builtins.head split}." fqdn;
in
{
options.khscodes.terraform-hetzner = {
enable = lib.mkEnableOption "enables generating a terraform config";
options.khscodes.hetzner-instance = {
enable = lib.mkEnableOption "enables generating a opentofu config";
dnsNames = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "DNS names for the server";
@ -72,7 +67,7 @@ in
"bitwarden"
"vault"
];
description = "Whether to load terraform secrets from Bitwarden or Vault";
description = "Whether to load opentofu secrets from Bitwarden or Vault";
default = "vault";
};
datacenter = lib.mkOption {
@ -146,95 +141,102 @@ in
labels = {
app = fqdn;
};
config = inputs.terranix.lib.terranixConfiguration {
system = pkgs.hostPlatform.system;
modules = [
(
{ config, ... }:
{
imports = [
inputs.self.terranixModules.cloudflare
inputs.self.terranixModules.hcloud
];
config = {
terraform.backend.s3 = {
bucket = "bw-terraform";
key = cfg.bucket.key;
region = "auto";
endpoints = {
s3 = "https://477b394a6a545699445c40953e40f00b.r2.cloudflarestorage.com";
};
use_path_style = true;
skip_credentials_validation = true;
skip_region_validation = true;
skip_metadata_api_check = true;
skip_requesting_account_id = true;
skip_s3_checksum = true;
modules = [
(
{ config, ... }:
{
imports = [
inputs.self.terranixModules.cloudflare
inputs.self.terranixModules.hcloud
];
config = {
terraform.backend.s3 = {
bucket = "bw-terraform";
key = cfg.bucket.key;
region = "auto";
endpoints = {
s3 = "https://477b394a6a545699445c40953e40f00b.r2.cloudflarestorage.com";
};
use_path_style = true;
skip_credentials_validation = true;
skip_region_validation = true;
skip_metadata_api_check = true;
skip_requesting_account_id = true;
skip_s3_checksum = true;
};
khscodes.hcloud.data.ssh_key.khs = {
name = "ca.kaareskovgaard.net";
};
khscodes.hcloud.enable = true;
khscodes.hcloud.server.compute = {
inherit (cfg) server_type datacenter;
inherit labels;
name = fqdn;
initial_image = "debian-12";
rdns = fqdn;
ssh_keys = [ config.khscodes.hcloud.output.data.ssh_key.khs.id ];
};
khscodes.cloudflare = {
khscodes.hcloud.data.ssh_key.khs = {
name = "ca.kaareskovgaard.net";
};
khscodes.hcloud.enable = true;
khscodes.hcloud.server.compute = {
inherit (cfg) server_type datacenter;
inherit labels;
name = fqdn;
initial_image = "debian-12";
rdns = fqdn;
ssh_keys = [ config.khscodes.hcloud.output.data.ssh_key.khs.id ];
};
khscodes.cloudflare = {
enable = true;
dns = {
enable = true;
dns = {
enable = true;
zone_name = tldFromFqdn fqdn;
aRecords = [
{
inherit fqdn;
content = config.khscodes.hcloud.output.server.compute.ipv4_address;
}
];
aaaaRecords = [
{
inherit fqdn;
content = config.khscodes.hcloud.output.server.compute.ipv6_address;
}
];
};
};
resource.hcloud_firewall.fw = lib.mkIf firewallEnable {
inherit labels;
name = fqdn;
apply_to = {
server = config.khscodes.hcloud.output.server.compute.id;
};
rule = firewallRules;
};
output.ipv4_address = {
value = config.khscodes.hcloud.output.server.compute.ipv4_address;
sensitive = false;
};
output.ipv6_address = {
value = config.khscodes.hcloud.output.server.compute.ipv6_address;
sensitive = false;
zone_name = tldFromFqdn fqdn;
aRecords = [
{
inherit fqdn;
content = config.khscodes.hcloud.output.server.compute.ipv4_address;
}
];
aaaaRecords = [
{
inherit fqdn;
content = config.khscodes.hcloud.output.server.compute.ipv6_address;
}
];
};
};
}
)
];
};
resource.hcloud_firewall.fw = lib.mkIf firewallEnable {
inherit labels;
name = fqdn;
apply_to = {
server = config.khscodes.hcloud.output.server.compute.id;
};
rule = firewallRules;
};
output.ipv4_address = {
value = config.khscodes.hcloud.output.server.compute.ipv4_address;
sensitive = false;
};
output.ipv6_address = {
value = config.khscodes.hcloud.output.server.compute.ipv6_address;
sensitive = false;
};
};
}
)
];
in
{
assertions = [
{
assertion = config.khscodes.fqdn != null;
message = "Must set config.khscodes.fqdn when using terraform";
message = "Must set config.khscodes.fqdn when using opentofu";
}
];
khscodes.terraform-hetzner.output = config;
khscodes.provisioning.pre = {
modules = modules;
secretsSource = cfg.secretsSource;
variablesNeeded = [
"TF_VAR_cloudflare_token"
"TF_VAR_cloudflare_email"
"AWS_ACCESS_KEY_ID"
"AWS_SECRET_ACCESS_KEY"
"TF_VAR_hcloud_api_token"
];
};
}
);
}

View file

@ -0,0 +1 @@
{ pkgs, ... }: { }

View file

@ -0,0 +1,63 @@
{
config,
lib,
inputs,
pkgs,
...
}:
let
cfg = config.khscodes.provisioning;
provisioning = {
modules = lib.mkOption {
type = lib.types.listOf lib.types.anything;
description = "Modules used to bring up the needed resources";
default = [ ];
};
secretsSource = lib.mkOption {
type = lib.types.enum [
"vault"
"bitwarden"
];
description = "Where to get the secrets for the provisioning from";
default = "vault";
};
variablesNeeded = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Needed environment variables for the provisioning";
default = [ ];
};
};
in
{
options.khscodes.provisioning = {
pre = provisioning;
post = provisioning;
preConfig = lib.mkOption {
type = lib.types.nullOr lib.types.path;
description = "The generated config for the pre provisioning, if any was specified";
};
postConfig = lib.mkOption {
type = lib.types.nullOr lib.types.path;
description = "The generated config for the post provisioning, if any was specified";
};
};
config = {
khscodes.provisioning.preConfig =
if lib.lists.length cfg.pre.modules > 0 then
inputs.terranix.lib.terranixConfiguration {
system = pkgs.hostPlatform.system;
modules = cfg.pre.modules;
}
else
null;
khscodes.provisioning.postConfig =
if lib.lists.length cfg.post.modules > 0 then
inputs.terranix.lib.terranixConfiguration {
system = pkgs.hostPlatform.system;
modules = cfg.post.modules;
}
else
null;
};
}

View file

@ -1,4 +1,4 @@
{ inputs, khscodesLib }:
{ khscodesLib, ... }:
{ config, lib, ... }:
let
cfg = config.khscodes.hcloud;
@ -52,7 +52,7 @@ in
{
id = "\${ hcloud_server.${sanitizedName}.id }";
ipv4_address = "\${ hcloud_server.${sanitizedName}.ipv4_address }";
ipv6_address = "\${ hcloud_server.${sanitizedName}.ipv4_address }";
ipv6_address = "\${ hcloud_server.${sanitizedName}.ipv6_address }";
}
)
) cfg.server;

View file

@ -0,0 +1,32 @@
{ khscodesLib, inputs }:
{ lib, config, ... }:
let
cfg = config.khscodes.openbao;
modules = [
./output.nix
./vault_mount.nix
];
in
{
options.khscodes.openbao = {
enable = lib.mkEnableOption "Enables the openbao provider";
};
imports = lib.lists.map (m: import m { inherit khscodesLib inputs; }) modules;
config = lib.mkIf cfg.enable {
provider.vault = {
address = "https://auth.kaareskovgaard.net";
};
terraform.required_providers.vault = {
source = "hashicorp/vault";
version = "5.0.0";
};
resource.vault_mount = lib.mapAttrs' (
name: value: {
name = khscodesLib.sanitize-terraform-name name;
value = value;
}
);
};
}

View file

@ -0,0 +1,10 @@
{ khscodesLib, ... }:
{ config, lib, ... }:
let
cfg = config.khscodes.openbao;
in
{
options.khscodes.openbao = { };
config = {
};
}

View file

@ -0,0 +1,45 @@
{ khscodesLib, ... }:
{ lib, config, ... }:
let
cfg = config.khscodes.openbao;
in
{
options.khscodes.openbao = {
vault_ssh_secret_backend_ca = lib.mkOption {
type = lib.types.attrsOf (
khscodesLib.mkSubmodule {
options = {
backend = lib.mkOption {
type = lib.types.str;
description = "Path of the backend mount";
};
generate_signing_key = lib.mkOption {
type = lib.types.bool;
description = "Generate a signing key on the server";
};
key_type = lib.mkOption {
type = lib.types.str;
description = "The type of the signing key to use/generate";
};
};
description = "vault_ssh_secret_backend_ca";
}
);
};
};
config = lib.mkIf cfg.enable {
provider.vault = {
address = "https://auth.kaareskovgaard.net";
};
terraform.required_providers.vault = {
source = "hashicorp/vault";
version = "5.0.0";
};
resource.vault_ssh_secret_backend_ca = lib.mapAttrs' (
name: value: {
name = khscodesLib.sanitize-terraform-name name;
value = value;
}
);
};
}

View file

@ -0,0 +1,52 @@
{ khscodesLib, ... }:
{ lib, config, ... }:
let
cfg = config.khscodes.openbao;
in
{
options.khscodes.openbao = {
vault_mount = lib.mkOption {
type = lib.types.attrsOf (
khscodesLib.mkSubmodule {
options = {
type = lib.mkOption {
type = lib.types.str;
description = "Type of mount";
};
path = lib.mkOption {
type = lib.types.str;
description = "Path of the mount";
default = null;
};
default_lease_ttl_seconds = lib.mkOption {
type = lib.types.int;
description = "Default lease ttl in seconds";
default = null;
};
max_lease_ttl_seconds = lib.mkOption {
type = lib.types.int;
description = "Max lease ttl in seconds";
default = null;
};
};
description = "vault_mount";
}
);
};
};
config = lib.mkIf cfg.enable {
provider.vault = {
address = "https://auth.kaareskovgaard.net";
};
terraform.required_providers.vault = {
source = "hashicorp/vault";
version = "5.0.0";
};
resource.vault_mount = lib.mapAttrs' (
name: value: {
name = khscodesLib.sanitize-terraform-name name;
value = value;
}
);
};
}

View file

@ -1,51 +1,52 @@
{ pkgs, lib, ... }:
let
opentofu = pkgs.opentofu;
bw-opentofu = lib.khscodes.mkBwEnv {
inherit pkgs;
name = "bw-opentofu";
items = {
"KHS Openstack" = {
TF_VAR_openstack_username = "login.username";
TF_VAR_openstack_password = "login.password";
TF_VAR_openstack_tenant_name = "Project Name";
TF_VAR_openstack_auth_url = "Auth URL";
TF_VAR_openstack_endpoint_type = "Interface";
TF_VAR_openstack_region = "Region Name";
};
"Cloudflare" = {
TF_VAR_cloudflare_token = "DNS API Token";
TF_VAR_cloudflare_email = "login.username";
AWS_ACCESS_KEY_ID = "BW Terraform access key id";
AWS_SECRET_ACCESS_KEY = "BW Terraform secret access key";
};
"Hetzner Cloud" = {
TF_VAR_hcloud_api_token = "Terraform API Token";
};
# TODO: We should figure out a way of passing the secrets map at runtime instead of build time.
# for now this map just needs to include every secret we could need, which also makes the reading of secrets take way longer than
# needed.
secrets = {
"KHS Openstack" = {
TF_VAR_openstack_username = "login.username";
TF_VAR_openstack_password = "login.password";
TF_VAR_openstack_tenant_name = "Project Name";
TF_VAR_openstack_auth_url = "Auth URL";
TF_VAR_openstack_endpoint_type = "Interface";
TF_VAR_openstack_region = "Region Name";
};
exe = lib.getExe opentofu;
"Cloudflare" = {
TF_VAR_cloudflare_token = "DNS API Token";
TF_VAR_cloudflare_email = "login.username";
AWS_ACCESS_KEY_ID = "BW Terraform access key id";
AWS_SECRET_ACCESS_KEY = "BW Terraform secret access key";
};
"Hetzner Cloud" = {
TF_VAR_hcloud_api_token = "Terraform API Token";
};
};
wrappedScript = pkgs.writeShellApplication {
name = "bw-opentofu-wrapped";
runtimeInputs = [
pkgs.uutils-coreutils-noprefix
pkgs.bitwarden-cli
pkgs.khscodes.find-flake-root
opentofu
];
text = ''
fqdn="$1"
config="$2"
phase="$3"
flakeRoot="$(find-flake-root)"
dir="$flakeRoot/.terraform-cache/$fqdn/$phase"
mkdir -p "$dir"
cat "''${config}" > "$dir/config.tf.json"
tofu -chdir="$dir" init
tofu -chdir="$dir" apply
'';
};
in
pkgs.writeShellApplication {
lib.khscodes.mkBwEnv {
inherit pkgs;
name = "bw-opentofu";
runtimeInputs = [
bw-opentofu
pkgs.uutils-coreutils-noprefix
pkgs.bitwarden-cli
];
text = ''
fqdn="$1"
config="$2"
lockHcl="$3"
dir="$(mktemp -d --tmpdir -t "terraform-hetzher-''${fqdn}.XXXXXXXXXX")"
cp "$lockHcl" "$dir/.terraform.lock.hcl"
cp "''${config}" "$dir/config.tf.json"
if [ "''${BW_SESSION:-}" == "" ]; then
BW_SESSION="$(bw unlock --raw)"
export BW_SESSION
trap "bw lock" EXIT
fi
bw-opentofu -chdir="$dir" init
bw-opentofu -chdir="$dir" apply
'';
items = secrets;
exe = lib.getExe wrappedScript;
}

View file

@ -0,0 +1,16 @@
{ pkgs, ... }:
pkgs.writeShellApplication {
name = "find-flake-root";
runtimeInputs = [ pkgs.uutils-coreutils-noprefix ];
text = ''
while [[ ! -f "$(pwd)/flake.nix" ]]; do
if [[ "$(pwd)" == "/" ]]; then
echo "Could not find flake root" 1>&2
exit 1
fi
cd ..
done
pwd
exit 0
'';
}

View file

@ -0,0 +1,6 @@
{
lib,
pkgs,
inputs,
}:
(lib.khscodes.mkRust pkgs "${inputs.self}/rust").buildRustPackage "hetzner-ipv6"

View file

@ -1,16 +0,0 @@
{
inputs,
pkgs,
}:
pkgs.writeShellApplication {
name = "opentofu-hetzner";
runtimeInputs = [
pkgs.nix
pkgs.khscodes.bw-opentofu
];
text = ''
hostname="$1"
config="$(nix build --no-link --print-out-paths '${inputs.self}#nixosConfigurations."'"$hostname"'".config.khscodes.terraform-hetzner.output')"
bw-opentofu "$hostname" "$config" "${./terraform.lock.hcl}"
'';
}

View file

@ -0,0 +1,27 @@
{
inputs,
pkgs,
}:
pkgs.writeShellApplication {
name = "pre-provisioning";
runtimeInputs = [
pkgs.nix
pkgs.khscodes.bw-opentofu
];
# TODO: Use secret source and required secrets to set up the correct env variables
text = ''
hostname="$1"
baseAttr='${inputs.self}#nixosConfigurations."'"$hostname"'".config.khscodes.provisioning'
config="$(nix eval --raw "''${baseAttr}.preConfig")"
secretsSource="$(nix eval --raw "''${baseAttr}.pre.secretsSource")"
if [[ "$config" == "null" ]]; then
echo "No preprovisioning needed"
exit 0
fi
if [[ "$secretsSource" == "vault" ]]; then
>&2 echo "Provisioning using vault is not yet implemented"
exit 1
fi
bw-opentofu "$hostname" "$config" "pre"
'';
}

View file

@ -0,0 +1,12 @@
{
lib,
pkgs,
inputs,
mkShell,
}:
mkShell {
packages = [
pkgs.nixd
pkgs.nixfmt-rfc-style
] ++ (lib.khscodes.mkRust pkgs "${inputs.self}/rust").devDeps;
}

View file

@ -4,7 +4,7 @@
}:
{
imports = [ "${inputs.self}/nix/profiles/hetzner-server.nix" ];
khscodes.terraform-hetzner = {
khscodes.hetzner-instance = {
enable = true;
mapRdns = true;
server_type = "cax11";

27
rust/.config/hakari.toml Normal file
View file

@ -0,0 +1,27 @@
# This file contains settings for `cargo hakari`.
# See https://docs.rs/cargo-hakari/latest/cargo_hakari/config for a full list of options.
hakari-package = "hakari"
# Format version for hakari's output. Version 4 requires cargo-hakari 0.9.22 or above.
dep-format-version = "4"
# Setting workspace.resolver = "2" or higher in the root Cargo.toml is HIGHLY recommended.
# Hakari works much better with the v2 resolver. (The v2 and v3 resolvers are identical from
# hakari's perspective, so you're welcome to set either.)
#
# For more about the new feature resolver, see:
# https://blog.rust-lang.org/2021/03/25/Rust-1.51.0.html#cargos-new-feature-resolver
resolver = "2"
# Add triples corresponding to platforms commonly used by developers here.
# https://doc.rust-lang.org/rustc/platform-support.html
platforms = [
# "x86_64-unknown-linux-gnu",
# "x86_64-apple-darwin",
# "aarch64-apple-darwin",
# "x86_64-pc-windows-msvc",
]
# Write out exact versions rather than a semver range. (Defaults to false.)
exact-versions = true

513
rust/Cargo.lock generated Normal file
View file

@ -0,0 +1,513 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "0.6.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "clap"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "common"
version = "1.0.0"
dependencies = [
"anyhow",
"base64",
"env_logger",
"hakari",
"log",
"serde",
"serde_json",
"serde_repr",
"serde_yml",
"shell-quote",
]
[[package]]
name = "env_filter"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "hakari"
version = "0.1.0"
dependencies = [
"anstream",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "hashbrown"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hetzner-ipv6"
version = "1.0.0"
dependencies = [
"anyhow",
"clap",
"common",
"hakari",
"log",
"serde",
]
[[package]]
name = "indexmap"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jiff"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde",
]
[[package]]
name = "jiff-static"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "libyml"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980"
dependencies = [
"anyhow",
"version_check",
]
[[package]]
name = "log"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "memchr"
version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "once_cell_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
[[package]]
name = "portable-atomic"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
dependencies = [
"portable-atomic",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_yml"
version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd"
dependencies = [
"indexmap",
"itoa",
"libyml",
"memchr",
"ryu",
"serde",
"version_check",
]
[[package]]
name = "shell-quote"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb502615975ae2365825521fa1529ca7648fd03ce0b0746604e0683856ecd7e4"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"

32
rust/Cargo.toml Normal file
View file

@ -0,0 +1,32 @@
[workspace]
resolver = "3"
members = ["lib/*", "program/*"]
[workspace.package]
version = "0.1.0"
[workspace.metadata.crane]
name = "nix-machines"
[workspace.dependencies]
anyhow = { version = "1.0.98", default-features = false, features = ["std"] }
base64 = { version = "0.22.1", default-features = false, features = ["std"] }
clap = { version = "4.5.39", default-features = false, features = [
"color",
"error-context",
"help",
"std",
"suggestions",
"usage",
"derive",
] }
log = { version = "0.4.27", default-features = false, features = ["std"] }
serde = { version = "1.0.219", default-features = false, features = [
"derive",
"std",
] }
serde_json = { version = "1.0.140", default-features = false, features = [
"std",
] }
serde_repr = { version = "0.1.20", default-features = false }
serde_yml = { version = "0.0.12", default-features = false }

94
rust/default.nix Normal file
View file

@ -0,0 +1,94 @@
{
pkgs,
lib,
crane,
advisory-db,
}:
let
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
src = craneLib.cleanCargoSource ./.;
commonArgs = {
inherit src;
strictDeps = true;
buildInputs = [ ];
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
individualCrateArgs = commonArgs // {
inherit cargoArtifacts;
inherit (craneLib.crateNameFromCargoToml { inherit src; }) version;
doCheck = false;
};
fileSetForCrate =
crate:
lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
./Cargo.lock
./Cargo.toml
(craneLib.fileset.commonCargoSources ./lib/common)
(craneLib.fileset.commonCargoSources ./program/${crate})
];
};
in
{
buildRustPackage =
crateName:
craneLib.buildPackage (
individualCrateArgs
// {
pname = crateName;
cargoExtraArgs = "-p ${crateName}";
src = fileSetForCrate crateName;
}
);
checks = {
rust-clippy = craneLib.cargoClippy (
commonArgs
// {
inherit cargoArtifacts;
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
}
);
rust-doc = craneLib.cargoDoc (
commonArgs
// {
inherit cargoArtifacts;
}
);
rust-fmt = craneLib.cargoFmt (
commonArgs
// {
inherit cargoArtifacts;
}
);
# Not used currently, as I have some other formatter changing toml formatting
# not sure where it comes from, but I need to find out and decide on a formatter
rust-toml-fmt = craneLib.taploFmt {
src = lib.sources.sourceFilesBySuffices src [ ".toml" ];
};
rust-audit = craneLib.cargoAudit {
inherit src advisory-db;
};
rust-hakari = craneLib.mkCargoDerivation {
inherit src;
pname = "rust-hakari";
cargoArtifacts = null;
doInstallCargoArtifacts = false;
buildPhaseCargoCommand = ''
cargo hakari generate --diff # workspace-hack Cargo.toml is up-to-date
cargo hakari manage-deps --dry-run # all workspace crates depend on workspace-hack
cargo hakari verify
'';
nativeBuildInputs = [
pkgs.cargo-hakari
];
};
};
devDeps = [
pkgs.cargo-hakari
rustToolchain
];
}

View file

@ -0,0 +1,18 @@
[package]
name = "common"
edition = "2024"
version = "1.0.0"
[dependencies]
anyhow = { workspace = true }
base64 = { workspace = true }
env_logger = "0.11.8"
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_repr.workspace = true
serde_yml = { workspace = true }
shell-quote = { version = "0.7.2", default-features = false, features = [
"bash",
] }
hakari = { version = "0.1", path = "../hakari" }

View file

@ -0,0 +1,5 @@
use base64::Engine;
pub fn encode(bytes: &[u8]) -> String {
base64::prelude::BASE64_STANDARD.encode(bytes)
}

View file

@ -0,0 +1,358 @@
use serde::{Deserialize, Serialize};
use crate::proc;
mod entry_serde;
#[derive(Debug, Deserialize)]
pub struct BitwardenEntry {
pub id: String,
pub name: String,
#[serde(rename = "organizationId")]
pub organization_id: Option<String>,
#[serde(rename = "folderId")]
pub folder_id: Option<String>,
#[serde(rename = "collectionIds")]
pub collection_ids: Vec<String>,
pub fields: Option<Vec<BitwardenEntryField>>,
pub notes: Option<String>,
#[serde(flatten)]
pub data: BitwardenEntryTypeData,
}
impl BitwardenEntry {
pub fn id(&self) -> &str {
&self.id
}
pub fn name(&self) -> &str {
&self.name
}
}
impl BitwardenEntry {
pub fn into_command_entry(&self) -> CommandBitwardenEntry {
CommandBitwardenEntry {
name: self.name.clone(),
organization_id: self.organization_id.clone(),
folder_id: self.folder_id.clone(),
collection_ids: self.collection_ids.clone(),
fields: self.fields.clone(),
notes: self.notes.clone(),
data: self.data.clone(),
}
}
}
#[derive(Debug, Serialize, Clone, PartialEq)]
pub struct CommandBitwardenEntry {
pub name: String,
#[serde(rename = "organizationId")]
pub organization_id: Option<String>,
#[serde(rename = "folderId")]
pub folder_id: Option<String>,
#[serde(rename = "collectionIds")]
pub collection_ids: Vec<String>,
pub fields: Option<Vec<BitwardenEntryField>>,
pub notes: Option<String>,
#[serde(flatten)]
pub data: BitwardenEntryTypeData,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BitwardenEntryTypeData {
Login(BitwardenEntryTypeLogin),
SecureNote(BitwardenEntryTypeSecureNote),
Card(BitwardenEntryTypeCard),
Identity(BitwardenEntryTypeIdentity),
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct BitwardenEntryTypeLogin {
pub username: Option<String>,
pub password: Option<String>,
pub totp: Option<String>,
pub uris: Option<Vec<BitwardenEntryLoginUri>>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct BitwardenEntryLoginUri {
pub uri: Option<String>,
#[serde(rename = "match")]
pub match_type: Option<BitwardenEntryUriMatchType>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct BitwardenEntryTypeSecureNote {}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct BitwardenEntryTypeCard {}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct BitwardenEntryTypeIdentity {}
#[derive(
Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Clone, Copy, PartialEq,
)]
#[repr(u8)]
pub enum BitwardenEntryFieldType {
Text = 0,
Hidden = 1,
Boolean = 2,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct BitwardenEntryField {
pub name: String,
pub value: Option<String>,
#[serde(rename = "type")]
pub field_type: BitwardenEntryFieldType,
}
#[derive(Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)]
#[repr(u8)]
pub enum BitwardenTwoStepLoginMethod {
Authenticator = 0,
Email = 1,
YubiKey = 3,
}
#[derive(Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Clone, PartialEq)]
#[repr(u8)]
pub enum BitwardenEntryUriMatchType {
Domain = 0,
Host = 1,
StartsWith = 2,
Exact = 3,
RegularExpression = 4,
Never = 5,
}
#[derive(Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)]
#[repr(u8)]
pub enum BitwardenOrganizationUserType {
Owner = 0,
Admin = 1,
User = 2,
Manager = 3,
Custom = 4,
}
#[derive(Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)]
#[repr(i8)]
pub enum BitwardenOrganizationUserStatus {
Invited = 0,
Accepted = 1,
Confirmed = 2,
Revoked = -1,
}
pub struct BitwardenSession {
session_id: Option<String>,
}
impl BitwardenSession {
fn bw_command(&self) -> proc::Command {
let mut cmd = proc::Command::new("bw");
if let Some(id) = self.session_id.as_deref() {
cmd.env_sensitive("BW_SESSION", id);
}
cmd
}
pub fn sync(&self) -> anyhow::Result<()> {
log::info!("Syncing Bitwarden...");
let _ = self.bw_command().arg("sync").try_spawn_to_string()?;
Ok(())
}
pub fn new_or_authenticate(
username: Option<&str>,
bw_unlock_purpose: &str,
) -> anyhow::Result<Self> {
let bw = BitwardenSession::new_if_authenticated(username, bw_unlock_purpose, true)?;
if let Some(bw) = bw {
bw.sync()?;
Ok(bw)
} else {
log::info!("User is not logged in to bitwarden, initiating login...");
proc::Command::new("bw")
.arg("login")
.stdin_inherit()
.stderr_inherit()
.try_spawn_to_string()?;
// Just logged in, no point in syncing
let bw = BitwardenSession::new_if_authenticated(username, bw_unlock_purpose, false)?;
if let Some(bw) = bw {
Ok(bw)
} else {
Err(anyhow::format_err!(
"Still not logged in to bitwarden, exiting..."
))
}
}
}
fn new_if_authenticated(
username: Option<&str>,
bw_unlock_purpose: &str,
sync: bool,
) -> anyhow::Result<Option<Self>> {
let status: BitwardenAuthenticationStatus = proc::Command::new("bw")
.args(["--nointeraction", "status"])
.try_spawn_to_json()?;
let Some(user) = status.user() else {
return Ok(None);
};
if let Some(username) = username {
if user.user_email != username {
return Err(anyhow::format_err!(
"Authenticated user in bitwarden does not match the expected user of {}, was {}",
username,
user.user_email
));
}
}
let is_unlocked: bool = matches!(status, BitwardenAuthenticationStatus::Unlocked(_));
if sync && !is_unlocked {
log::info!("Syncing Bitwarden...");
let _ = proc::Command::new("bw").arg("sync").try_spawn_to_string()?;
}
log::info!("Unlocking bitwarden...");
let session_id = if is_unlocked {
None
} else {
Some(
proc::Command::new("bitwarden-unlock")
.args(["--purpose", bw_unlock_purpose])
.try_spawn_to_string()?,
)
};
Ok(Some(Self { session_id }))
}
pub fn list_items(&self) -> anyhow::Result<Vec<BitwardenEntry>> {
log::info!("Listing bitwarden items...");
self.bw_command()
// Pretty format for better error messages during json decoding issues
.args(["--pretty", "list", "items"])
.try_spawn_to_json()
}
pub fn get_item(&self, name: &str) -> anyhow::Result<Option<BitwardenEntry>> {
let mut items = self.list_items()?;
let Some(idx) = items
.iter()
.enumerate()
.find_map(|(idx, e)| if e.name() == name { Some(idx) } else { None })
else {
return Ok(None);
};
let item = items.swap_remove(idx);
Ok(Some(item))
}
pub fn get_attachment(&self, entry: &BitwardenEntry, name: &str) -> anyhow::Result<Vec<u8>> {
self.bw_command()
.args(["get", "attachment", name, "--itemid"])
.arg(entry.id.as_str())
.arg("--raw")
.try_spawn_to_bytes()
}
pub fn list_own_items(&self) -> anyhow::Result<Vec<BitwardenEntry>> {
let mut items = self.list_items()?;
items.retain(|i| i.organization_id.as_ref().is_none_or(|o| o.is_empty()));
Ok(items)
}
pub fn create_item(&self, item: &CommandBitwardenEntry) -> anyhow::Result<BitwardenEntry> {
log::info!("Creating bitwarden entry {name}", name = item.name);
self.bw_command()
.args(["create", "item"])
.stdin_json_base64(item)?
.try_spawn_to_json()
}
pub fn update_item(
&self,
to_update: &BitwardenEntry,
update_with: &CommandBitwardenEntry,
) -> anyhow::Result<()> {
log::info!(
"Updating bitwarden entry {name}, with id {id}",
id = to_update.id,
name = update_with.name
);
let _output = self
.bw_command()
.args(["edit", "item", &to_update.id])
.stdin_json_base64(update_with)?
.try_spawn_to_string()?;
Ok(())
}
pub fn delete_item(&self, to_delete: &BitwardenEntry) -> anyhow::Result<()> {
log::info!(
"Deleting bitwarden entry {name}, with id: {id}",
id = to_delete.id,
name = to_delete.name
);
let _output = self
.bw_command()
.args(["delete", "item", &to_delete.id])
.try_spawn_to_string()?;
Ok(())
}
}
impl Drop for BitwardenSession {
fn drop(&mut self) {
log::info!("Locking bitwarden session...");
if self.session_id.is_some() {
if let Err(e) = self
.bw_command()
.args(["--nointeraction", "lock"])
.try_spawn_to_string()
{
log::error!("Could not lock bitwarden session: {e}");
}
}
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "status")]
enum BitwardenAuthenticationStatus {
#[serde(rename = "unlocked")]
Unlocked(BitwardenAuthenticationUser),
#[serde(rename = "locked")]
Locked(BitwardenAuthenticationUser),
#[serde(rename = "unauthenticated")]
Unauthenticated,
}
impl BitwardenAuthenticationStatus {
pub fn user(&self) -> Option<&BitwardenAuthenticationUser> {
match self {
Self::Locked(user) | Self::Unlocked(user) => Some(user),
Self::Unauthenticated => None,
}
}
}
#[derive(Debug, Deserialize)]
struct BitwardenAuthenticationUser {
#[serde(rename = "userEmail")]
user_email: String,
#[serde(rename = "userId")]
#[allow(dead_code)]
user_id: String,
}

View file

@ -0,0 +1,162 @@
use serde::{Deserialize, Serialize, ser::SerializeMap};
use crate::bitwarden::{
BitwardenEntryTypeCard, BitwardenEntryTypeData, BitwardenEntryTypeIdentity,
BitwardenEntryTypeLogin, BitwardenEntryTypeSecureNote,
};
impl Serialize for super::BitwardenEntryTypeData {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut map = serializer.serialize_map(Some(2))?;
match self {
Self::Login(login) => {
map.serialize_entry("type", &BitwardenEntryType::Login)?;
map.serialize_entry("login", login)?;
}
Self::Card(card) => {
map.serialize_entry("type", &BitwardenEntryType::Card)?;
map.serialize_entry("card", &card)?;
}
Self::SecureNote(secure_note) => {
map.serialize_entry("type", &BitwardenEntryType::SecureNote)?;
map.serialize_entry("secureNote", &secure_note)?;
}
Self::Identity(identity) => {
map.serialize_entry("type", &BitwardenEntryType::Identity)?;
map.serialize_entry("identity", &identity)?;
}
}
map.end()
}
}
impl<'de> Deserialize<'de> for BitwardenEntryTypeData {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_struct(
"BitwardenEntryTypeData",
&["type", "login", "card", "secureNote", "identity"],
DeserializeVisitor,
)
}
}
struct DeserializeVisitor;
impl<'de> serde::de::Visitor<'de> for DeserializeVisitor {
type Value = BitwardenEntryTypeData;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an object with type and tagged type property")
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: serde::de::MapAccess<'de>,
{
let mut entry_type: Option<BitwardenEntryType> = None;
let mut login_data: Option<BitwardenEntryTypeLogin> = None;
let mut secure_note_data: Option<BitwardenEntryTypeSecureNote> = None;
let mut card_data: Option<BitwardenEntryTypeCard> = None;
let mut identity_data: Option<BitwardenEntryTypeIdentity> = None;
while let Some(key) = map.next_key::<&str>()? {
match key {
"type" => {
if entry_type.is_some() {
return Err(serde::de::Error::duplicate_field("type"));
}
entry_type = Some(map.next_value()?);
}
"login" => {
if login_data.is_some() {
return Err(serde::de::Error::duplicate_field("login"));
}
login_data = Some(map.next_value()?);
}
"card" => {
if card_data.is_some() {
return Err(serde::de::Error::duplicate_field("card"));
}
card_data = Some(map.next_value()?);
}
"identity" => {
if identity_data.is_some() {
return Err(serde::de::Error::duplicate_field("identity"));
}
identity_data = Some(map.next_value()?);
}
"secureNote" => {
if secure_note_data.is_some() {
return Err(serde::de::Error::duplicate_field("secureNote"));
}
secure_note_data = Some(map.next_value()?);
}
_ => {}
}
}
match entry_type {
Some(BitwardenEntryType::Login) => {
let login = login_data.ok_or(serde::de::Error::missing_field("login"))?;
Ok(BitwardenEntryTypeData::Login(login))
}
Some(BitwardenEntryType::Card) => {
let card = card_data.ok_or(serde::de::Error::missing_field("card"))?;
Ok(BitwardenEntryTypeData::Card(card))
}
Some(BitwardenEntryType::SecureNote) => {
let secure_note =
secure_note_data.ok_or(serde::de::Error::missing_field("secure_note"))?;
Ok(BitwardenEntryTypeData::SecureNote(secure_note))
}
Some(BitwardenEntryType::Identity) => {
let identity = identity_data.ok_or(serde::de::Error::missing_field("identity"))?;
Ok(BitwardenEntryTypeData::Identity(identity))
}
None => Err(serde::de::Error::missing_field("type")),
}
}
}
#[derive(Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)]
#[repr(u8)]
enum BitwardenEntryType {
Login = 1,
SecureNote = 2,
Card = 3,
Identity = 4,
}
#[cfg(test)]
mod tests {
use crate::json;
use super::*;
#[test]
pub fn test_simple_serializing() {
let d = BitwardenEntryTypeData::Login(BitwardenEntryTypeLogin {
username: None,
password: None,
totp: None,
uris: None,
});
let json = json::to_string(&d).unwrap();
assert_eq!(
json,
r#"{"type":1,"login":{"username":null,"password":null,"totp":null,"uris":null}}"#
);
match json::from_str(&json).unwrap() {
BitwardenEntryTypeData::Login(BitwardenEntryTypeLogin {
username: None,
password: None,
totp: None,
uris: None,
}) => {}
_ => panic!("Could not deserialize json to itself"),
}
}
}

View file

@ -0,0 +1,13 @@
use crate::proc::Command;
pub fn read_json_as_string(url: &str) -> anyhow::Result<String> {
let mut cmd = Command::new("curl");
cmd.args(["-H", "Accept: application/json", url]);
cmd.try_spawn_to_string()
}
pub fn read_text_as_string(url: &str) -> anyhow::Result<String> {
let mut cmd = Command::new("curl");
cmd.arg(url);
cmd.try_spawn_to_string()
}

View file

@ -0,0 +1,23 @@
use std::path::PathBuf;
use anyhow::Context;
use serde::Deserialize;
use crate::json;
pub fn read_env(var: &'static str) -> anyhow::Result<String> {
log::trace!("read_env: {var}");
std::env::var(var)
.map_err(|e| anyhow::format_err!("Could not read {var} environment variable: {e}"))
}
pub fn read_path_env(var: &'static str) -> anyhow::Result<PathBuf> {
Ok(read_env(var)?.into())
}
pub fn read_env_json<T: for<'de> Deserialize<'de>>(var: &'static str) -> anyhow::Result<T> {
let json_text = read_env(var)?;
json::from_str(&json_text).with_context(|| {
format!("Could not parse contents of env var {var} into the correct json format")
})
}

156
rust/lib/common/src/fs.rs Normal file
View file

@ -0,0 +1,156 @@
use std::{
fs::{Permissions, ReadDir},
io::{ErrorKind, Write},
path::Path,
};
use anyhow::Context;
use crate::proc;
pub fn is_dir(p: &Path) -> anyhow::Result<bool> {
log::trace!("is_dir: {}", p.display());
Ok(match std::fs::metadata(p) {
Ok(m) if m.is_dir() => true,
Ok(_) => false,
Err(e) if e.kind() == ErrorKind::NotFound => false,
Err(e) => return Err(e).with_context(|| format!("Could not stat {p:?}")),
})
}
pub fn is_file(p: &Path) -> anyhow::Result<bool> {
log::trace!("is_file: {}", p.display());
Ok(match std::fs::metadata(p) {
Ok(m) if m.is_file() => true,
Ok(_) => false,
Err(e) if e.kind() == ErrorKind::NotFound => false,
Err(e) => return Err(e).with_context(|| format!("Could not stat {p:?}")),
})
}
pub fn is_file_with_permissions(p: &Path, permissions: Permissions) -> anyhow::Result<bool> {
log::trace!("is_file_with_permissions: {}", p.display());
Ok(match std::fs::metadata(p) {
Ok(m) if m.is_file() => {
log::trace!("Verifying {:?} == {:?}", m.permissions(), permissions);
m.permissions() == permissions
}
Ok(_) => false,
Err(e) if e.kind() == ErrorKind::NotFound => false,
Err(e) => return Err(e).with_context(|| format!("Could not stat {p:?}")),
})
}
pub fn create_dir(p: &Path) -> anyhow::Result<()> {
log::info!("Creating directory : {}", p.display());
std::fs::create_dir(p).with_context(|| format!("Could not create directory {p:?}"))
}
pub fn write_file_string(p: &Path, contents: &str, permissions: Permissions) -> anyhow::Result<()> {
log::info!("Writing contents to {}", p.display());
let mut file = std::fs::File::create(p).with_context(|| {
format!(
"Could not create (or open existing) file at {}",
p.display()
)
})?;
file.set_permissions(permissions)
.with_context(|| format!("Could not set permissions on file {}", p.display()))?;
file.write_all(contents.as_bytes())
.with_context(|| format!("Could not write to file {}", p.display()))?;
Ok(())
}
pub fn root_create_dir(p: &Path) -> anyhow::Result<()> {
let mut cmd = proc::Command::new("mkdir");
cmd.arg(p.display().to_string());
let _ = cmd.sudo().try_spawn_to_string()?;
Ok(())
}
pub fn remove_file(path: &Path) -> anyhow::Result<()> {
log::info!("Deleting file {}", path.display());
std::fs::remove_file(path).with_context(|| format!("Could not delete file {}", path.display()))
}
pub fn root_create_dir_recursive(p: &Path) -> anyhow::Result<()> {
let mut cmd = proc::Command::new("mkdir");
cmd.arg("-p");
cmd.arg(p.display().to_string());
let _ = cmd.sudo().try_spawn_to_string()?;
Ok(())
}
pub fn create_dir_recursive(p: &Path) -> anyhow::Result<()> {
let mut cmd = proc::Command::new("mkdir");
cmd.arg("-p");
cmd.arg(p.display().to_string());
let _ = cmd.try_spawn_to_string()?;
Ok(())
}
pub fn remove_dir_recursive(p: &Path) -> anyhow::Result<()> {
std::fs::remove_dir_all(p)
.with_context(|| format!("Could not remove directory {}", p.display()))
}
pub fn list_dir(p: &Path) -> anyhow::Result<ReadDir> {
std::fs::read_dir(p).with_context(|| format!("Could not read directory {}", p.display()))
}
pub fn set_permissions(p: &Path, permissions: Permissions) -> anyhow::Result<()> {
log::trace!("set_permissions: {}", p.display());
std::fs::set_permissions(p, permissions.clone()).with_context(|| {
format!(
"Could not set permissions on {} to {permissions:?}",
p.display()
)
})
}
pub fn metadata(p: &Path) -> anyhow::Result<std::fs::Metadata> {
log::trace!("get_metadata: {}", p.display());
std::fs::metadata(p).with_context(|| format!("Could not get metadata for {}", p.display()))
}
#[cfg(target_family = "unix")]
pub fn user_only_dir_permissions() -> Permissions {
use std::os::unix::fs::PermissionsExt;
PermissionsExt::from_mode(0o040700)
}
#[cfg(target_family = "unix")]
pub fn user_only_file_permissions() -> Permissions {
use std::os::unix::fs::PermissionsExt;
PermissionsExt::from_mode(0o100600)
}
pub fn read_to_string(path: &Path) -> anyhow::Result<String> {
log::trace!("read_file: {}", path.display());
std::fs::read_to_string(path)
.with_context(|| format!("Could not read file: {}", path.display()))
}
#[cfg(target_family = "unix")]
pub fn create_link(from: &Path, to: &Path) -> anyhow::Result<()> {
std::os::unix::fs::symlink(to, from).with_context(|| {
format!(
"Could not create symbolic link from {from} to {to}",
from = from.display(),
to = to.display()
)
})
}
pub fn rename(from: &Path, to: &Path) -> anyhow::Result<()> {
std::fs::rename(from, to)
.with_context(|| format!("Could not rename {} to {}", from.display(), to.display()))
}

View file

@ -0,0 +1,69 @@
use anyhow::Context;
use serde::{Deserialize, Serialize};
pub fn from_str<D: for<'de> Deserialize<'de>>(s: &str) -> anyhow::Result<D> {
serde_json::from_str(s).map_err(|e| anyhow::format_err!("{e}:\n{}", extract_context(&e, s)))
}
pub fn to_string<S: Serialize>(data: &S) -> anyhow::Result<String> {
serde_json::to_string(data).context("Could not serialize data to json")
}
pub fn to_string_pretty<S: Serialize>(data: &S) -> anyhow::Result<String> {
serde_json::to_string_pretty(data).context("Could not serialize data to json")
}
pub fn to_vec<S: Serialize>(data: &S) -> anyhow::Result<Vec<u8>> {
serde_json::to_vec(data).context("Could not serialize data to json")
}
pub fn to_vec_pretty<S: Serialize>(data: &S) -> anyhow::Result<Vec<u8>> {
serde_json::to_vec_pretty(data).context("Could not serialize data to json")
}
pub fn string(v: &str) -> String {
serde_json::to_string(v).expect("Could not encode json string")
}
fn extract_context(serde_error: &serde_json::Error, s: &str) -> String {
let lines: Vec<_> = s.lines().collect();
if lines.len() == 1 {
let (col_begin, highlight) = if serde_error.column() > 30 {
(serde_error.column() - 30, 30)
} else {
(1, serde_error.column())
};
let col_end = if lines[0].len() + 31 < serde_error.column() {
lines[0].len() + 1
} else {
serde_error.column() + 30
};
let mut line: String = lines[0]
.chars()
.skip(col_begin - 1)
.take(col_end - col_begin)
.collect();
line.push('\n');
line.extend(std::iter::repeat_n(' ', highlight - 1));
line.push('^');
line
} else {
let error_line = serde_error.line();
let mut result = String::new();
if error_line > 1 {
result.push_str(&format!("{}: {}\n", error_line - 1, lines[error_line - 2]));
}
result.push_str(&format!("{}: {}\n", error_line, lines[error_line - 1]));
result.push_str(&format!(
"{} {}\n",
" ".repeat(error_line.to_string().len()),
std::iter::repeat_n(' ', serde_error.column() - 1)
.chain(['^'].into_iter())
.collect::<String>(),
));
if lines.len() > error_line {
result.push_str(&format!("{}: {}\n", error_line + 1, lines[error_line]));
}
result
}
}

View file

@ -0,0 +1,40 @@
use std::str::FromStr;
use log::LevelFilter;
pub mod base64;
pub mod bitwarden;
pub mod curl;
pub mod env;
pub mod fs;
pub mod json;
pub mod proc;
pub mod yaml;
pub fn read_level_filter() -> LevelFilter {
let env = env::read_env("LOGLEVEL").unwrap_or(String::from("INFO"));
let env_upper = env.to_uppercase();
let level = match env_upper.as_str() {
"VERBOSE" => "TRACE",
"WARNING" => "WARN",
l => l,
};
log::LevelFilter::from_str(level).unwrap_or(log::LevelFilter::Info)
}
pub fn entrypoint(m: impl FnOnce() -> anyhow::Result<()>) {
env_logger::builder()
.filter_level(read_level_filter())
.format_timestamp(None)
.format_module_path(false)
.format_file(false)
.format_source_path(false)
.format_target(false)
.try_init()
.expect("Could not set logger");
let res = m();
if let Err(err) = res {
log::error!("{err:#}");
std::process::exit(1);
}
}

355
rust/lib/common/src/proc.rs Normal file
View file

@ -0,0 +1,355 @@
use std::{
collections::BTreeMap,
io::Write as _,
ops::Deref,
process::{Child, ExitStatus, Stdio},
};
use anyhow::Context;
use log::Level;
use serde::{Deserialize, Serialize};
use crate::{json, proc::util::command_to_string};
mod util;
#[derive(Debug)]
enum Stdin {
Null,
Pipe(Vec<u8>),
Inherit,
}
impl Stdin {
fn into_data(self) -> Option<Vec<u8>> {
match self {
Self::Pipe(d) => Some(d),
_ => None,
}
}
fn as_std_stdio(&self) -> Stdio {
match self {
Self::Inherit => Stdio::inherit(),
Self::Pipe(_) => Stdio::piped(),
Self::Null => Stdio::null(),
}
}
}
#[derive(Debug)]
enum Stderr {
Pipe,
Inherit,
}
impl Stderr {
fn as_std_stdio(&self) -> Stdio {
match self {
Self::Inherit => Stdio::inherit(),
Self::Pipe => Stdio::piped(),
}
}
}
#[derive(Debug)]
pub enum EnvData {
Insensitive(String),
Sensitive(String),
}
impl EnvData {
const fn as_str(&self) -> &str {
match self {
Self::Insensitive(d) | Self::Sensitive(d) => d.as_str(),
}
}
fn as_potentially_redacted_str(&self) -> &str {
match self {
Self::Insensitive(s) => s.as_str(),
Self::Sensitive(_) => "<REDACTED>",
}
}
}
#[derive(Debug)]
pub struct Command {
program: String,
args: Vec<String>,
env: BTreeMap<String, Option<EnvData>>,
is_sudo: bool,
show_command: bool,
stdin: Stdin,
stderr: Stderr,
}
impl Command {
pub fn new(program: impl Into<String>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
env: BTreeMap::new(),
is_sudo: false,
show_command: false,
stdin: Stdin::Null,
stderr: Stderr::Pipe,
}
}
pub fn get_program(&self) -> &str {
if self.is_sudo { "sudo" } else { &self.program }
}
pub fn get_args(&self) -> impl Iterator<Item = &str> {
let prefix_vec = if self.is_sudo {
Vec::from([self.program.as_str()])
} else {
Vec::new()
};
prefix_vec
.into_iter()
.chain(self.args.iter().map(Deref::deref))
}
pub fn get_envs(&self) -> impl Iterator<Item = (&str, Option<&EnvData>)> {
self.env.iter().map(|(k, v)| (k.as_str(), v.as_ref()))
}
/// Sudo will automatically set stdin and stderr to inherit, to allow the user to enter the sudo password
pub fn sudo(&mut self) -> &mut Self {
self.is_sudo = true;
self.stdin = Stdin::Inherit;
self.stderr = Stderr::Inherit;
self
}
pub fn announce(&mut self) -> &mut Self {
self.show_command = true;
self
}
pub fn stdin_json<S: Serialize>(&mut self, stdin: &S) -> anyhow::Result<&mut Self> {
self.stdin = Stdin::Pipe(json::to_vec(stdin).context("Could not convert stdin to json")?);
Ok(self)
}
pub fn stdin_json_base64<S: Serialize>(&mut self, stdin: &S) -> anyhow::Result<&mut Self> {
let json = json::to_string(stdin).context("Could not convert stdin to json")?;
let base64 = crate::base64::encode(json.as_bytes());
self.stdin = Stdin::Pipe(base64.into());
Ok(self)
}
pub fn stdin_string(&mut self, stdin: impl Into<String>) -> &mut Self {
self.stdin = Stdin::Pipe(Vec::from(stdin.into()));
self
}
pub fn stdin_bytes(&mut self, stdin: impl Into<Vec<u8>>) -> &mut Self {
self.stdin = Stdin::Pipe(stdin.into());
self
}
pub fn stdin_inherit(&mut self) -> &mut Self {
self.stdin = Stdin::Inherit;
self
}
pub fn stderr_inherit(&mut self) -> &mut Self {
self.stderr = Stderr::Inherit;
self
}
pub fn arg(&mut self, arg: impl Into<String>) -> &mut Self {
self.args.push(arg.into());
self
}
pub fn args<A>(&mut self, args: impl IntoIterator<Item = A>) -> &mut Self
where
A: Into<String>,
{
self.args.extend(args.into_iter().map(Into::into));
self
}
pub fn env(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
let _ = self
.env
.insert(key.into(), Some(EnvData::Insensitive(value.into())));
self
}
pub fn env_sensitive(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
let _ = self
.env
.insert(key.into(), Some(EnvData::Sensitive(value.into())));
self
}
pub fn env_remove(&mut self, key: impl Into<String>) -> &mut Self {
self.env.insert(key.into(), None);
self
}
pub fn env_clear(&mut self) -> &mut Self {
self.env.clear();
for k in std::env::args() {
self.env.insert(k, None);
}
self
}
fn as_command(&self) -> std::process::Command {
let is_sudo = self.is_sudo;
let show_command = self.show_command;
let mut cmd = if self.is_sudo {
let mut cmd = std::process::Command::new("sudo");
cmd.arg(&self.program);
cmd
} else {
std::process::Command::new(&self.program)
};
cmd.args(self.args.iter());
for (k, v) in self.env.iter() {
if let Some(v) = v {
cmd.env(k, v.as_str());
} else {
cmd.env_remove(k);
}
}
if !self.env.contains_key("LOGEVEL") {
if let Ok(loglevel) = std::env::var("LOGLEVEL") {
match loglevel.to_ascii_lowercase().as_str() {
"trace" | "debug" => cmd.env("LOGLEVEL", "debug"),
"verbose" => cmd.env("LOGLEVEL", "verbose"),
"info" => cmd.env("LOGLEVEL", "info"),
"warning" | "warn" => cmd.env("LOGLEVEL", "warning"),
"error" => cmd.env("LOGLEVEL", "error"),
_ => cmd.env_remove("LOGLEVEL"),
};
}
}
if is_sudo {
log::info!("[SUDO] Running command {}", command_to_string(self));
} else {
let level = if show_command {
Level::Info
} else {
Level::Trace
};
log::log!(level, "Running command {}", command_to_string(self));
}
cmd
}
/// WARNING: Spawning a command consumes all of its stdin, as such spawning it twice may not yield the same result
pub fn try_spawn_to_bytes(&mut self) -> anyhow::Result<Vec<u8>> {
let mut cmd = self.as_command();
let mut child = cmd
.stderr(self.stderr.as_std_stdio())
.stdin(self.stdin.as_std_stdio())
.stdout(Stdio::piped())
.spawn()
.with_context(|| format!("Could not spawn command: {}", command_to_string(self)))?;
let mut stdin = Stdin::Null;
std::mem::swap(&mut self.stdin, &mut stdin);
let join_handle = if let Some(data) = stdin.into_data() {
let mut stdin_pipe = child.stdin.take().expect("Child has no stdin");
Some(std::thread::spawn(move || {
stdin_pipe
.write_all(data.as_slice())
.expect("Could not write to child");
}))
} else {
None
};
let output = wait_with_output(child, || command_to_string(self));
if let Some(join_handle) = join_handle {
join_handle
.join()
.map_err(|e| anyhow::format_err!("Thread sending stdin panicked: {e:?}"))?;
}
output
}
/// WARNING: Spawning a command consumes all of its stdin, as such spawning it twice may not yield the same result
/// Using try_spawn_to_string will trim a single trailing newline, if you don't want this, use to_bytes and convert the string manually.
pub fn try_spawn_to_string(&mut self) -> anyhow::Result<String> {
let output = self.try_spawn_to_bytes()?;
let mut output: String = output.try_into().map_err(|_| {
anyhow::format_err!(
"Command {} didn't produce valid utf-8 output",
command_to_string(self)
)
})?;
if output.ends_with("\n") {
output.truncate(output.len() - 1);
}
Ok(output)
}
/// WARNING: Spawning a command consumes all of its stdin, as such spawning it twice may not yield the same result
pub fn try_spawn_to_json<D: for<'de> Deserialize<'de>>(&mut self) -> anyhow::Result<D> {
let output = self.try_spawn_to_string()?;
json::from_str(&output).with_context(|| {
format!(
"Could not parse output of {} as json",
command_to_string(self)
)
})
}
/// WARNING: Spawning a command consumes all of its stdin, as such spawning it twice may not yield the same result
pub fn try_spawn_stdout_inherit(&mut self) -> anyhow::Result<ExitStatus> {
let mut cmd = self.as_command();
cmd.stdout(Stdio::inherit());
cmd.status()
.with_context(|| format!("Could not spawn command: {}", command_to_string(self)))
}
}
fn wait_with_output(child: Child, cmd_str: impl Fn() -> String) -> anyhow::Result<Vec<u8>> {
let output = child
.wait_with_output()
.with_context(|| format!("Could not wait for output of commnad: {}", cmd_str()))?;
if !output.status.success() {
return Err(anyhow::format_err!(
"Command {}, exited unexpectedly: {:?}. With stderr: {}",
cmd_str(),
output.status,
String::from_utf8_lossy(&output.stderr),
));
}
Ok(output.stdout)
}
#[cfg(test)]
mod tests {
use super::Command;
#[test]
fn test_spawn() {
let mut echo = Command::new("echo");
echo.args(["Hello", "World"]);
assert_eq!(
"Hello World",
echo.try_spawn_to_string()
.expect("Should be able to echo Hello World")
);
}
#[test]
fn test_spawn_stdin() {
let mut rev = Command::new("rev");
assert_eq!(
"dlroW olleH",
rev.stdin_string("Hello World".to_string())
.try_spawn_to_string()
.expect("Should be able to rev Hello World")
);
}
}

View file

@ -0,0 +1,31 @@
use super::Command;
use std::borrow::Cow;
use shell_quote::Quote;
pub fn command_to_string(cmd: &Command) -> String {
let program = escape_cli(cmd.get_program());
let env: Vec<_> = cmd
.get_envs()
.map(|(key, value)| match value {
None => key.into(),
Some(value) => Cow::Owned(format!(
"{key}={value} ",
value = escape_cli(value.as_potentially_redacted_str())
)),
})
.collect();
let args: Vec<_> = cmd.get_args().map(escape_cli).collect();
let env = env.join("");
let args = args.join(" ");
format!(
"{env_marker}{env}{program}{arg_separator}{args}",
env_marker = if env.is_empty() { "" } else { "env " },
arg_separator = if args.is_empty() { "" } else { " " }
)
}
fn escape_cli<A: AsRef<str>>(str: A) -> String {
let str = str.as_ref();
shell_quote::Bash::quote(str)
}

View file

@ -0,0 +1,10 @@
use anyhow::Context;
use serde::Serialize;
pub fn string(str: &str) -> String {
serde_yml::to_string(str).expect("Should be able to encode string")
}
pub fn to_string<T: ?Sized + Serialize>(value: &T) -> anyhow::Result<String> {
serde_yml::to_string(value).context("Could not serialize to yaml")
}

4
rust/lib/hakari/.gitattributes vendored Normal file
View file

@ -0,0 +1,4 @@
# Avoid putting conflict markers in the generated Cargo.toml file, since their presence breaks
# Cargo.
# Also do not check out the file as CRLF on Windows, as that's what hakari needs.
Cargo.toml merge=binary -crlf

View file

@ -0,0 +1,25 @@
# This file is generated by `cargo hakari`.
# To regenerate, run:
# cargo hakari generate
[package]
name = "hakari"
version = "0.1.0"
edition = "2021"
description = "workspace-hack package, managed by hakari"
# You can choose to publish this crate: see https://docs.rs/cargo-hakari/latest/cargo_hakari/publishing.
publish = false
# The parts of the file between the BEGIN HAKARI SECTION and END HAKARI SECTION comments
# are managed by hakari.
### BEGIN HAKARI SECTION
[dependencies]
anstream = { version = "0.6.19" }
[build-dependencies]
proc-macro2 = { version = "1.0.95" }
quote = { version = "1.0.40" }
syn = { version = "2.0.104", features = ["full"] }
### END HAKARI SECTION

2
rust/lib/hakari/build.rs Normal file
View file

@ -0,0 +1,2 @@
// A build script is required for cargo to consider build dependencies.
fn main() {}

View file

@ -0,0 +1 @@
// This is a stub lib.rs.

View file

@ -0,0 +1,13 @@
[package]
name = "hetzner-ipv6"
edition = "2024"
version = "1.0.0"
metadata.crane.name = "hetzner-ipv6"
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true }
common = { path = "../../lib/common" }
log = { workspace = true }
serde = { workspace = true }
hakari = { version = "0.1", path = "../../lib/hakari" }

View file

@ -0,0 +1,28 @@
use clap::{Parser, Subcommand};
fn main() {
common::entrypoint(program);
}
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Args {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
/// Configures the ipv6 address using instance metadata and iproute2
Configure,
}
fn program() -> anyhow::Result<()> {
let args = Args::parse();
match args.command {
Commands::Configure => configure(),
}
}
fn configure() -> anyhow::Result<()> {
Ok(())
}

4
rust/rust-toolchain.toml Normal file
View file

@ -0,0 +1,4 @@
[toolchain]
channel = "1.88.0"
components = ["rustfmt", "clippy", "cargo"]
profile = "minimal"