diff --git a/.direnv/flake-profile b/.direnv/flake-profile new file mode 120000 index 0000000..c7ae5b7 --- /dev/null +++ b/.direnv/flake-profile @@ -0,0 +1 @@ +flake-profile-2-link \ No newline at end of file diff --git a/.direnv/flake-profile-2-link b/.direnv/flake-profile-2-link new file mode 120000 index 0000000..01ece51 --- /dev/null +++ b/.direnv/flake-profile-2-link @@ -0,0 +1 @@ +/nix/store/dqy98711cp1rqz8nj7qpxq6qj6vnyf0j-nix-shell-env \ No newline at end of file diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index ba2a798..e8297b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ result/ .DS_Store +.terraform-cache/*/*/config.tf.json +.terraform-cache/*/*/.terraform +rust/target diff --git a/nix/packages/opentofu-hetzner/terraform.lock.hcl b/.terraform-cache/khs.codes/pre/.terraform.lock.hcl similarity index 100% rename from nix/packages/opentofu-hetzner/terraform.lock.hcl rename to .terraform-cache/khs.codes/pre/.terraform.lock.hcl diff --git a/flake.lock b/flake.lock index d0e7179..1384cb6 100644 --- a/flake.lock +++ b/flake.lock @@ -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", diff --git a/flake.nix b/flake.nix index 5e9eaea..cd3a68a 100644 --- a/flake.nix +++ b/flake.nix @@ -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; + }; }; } diff --git a/nix/checks/rust-audit/default.nix b/nix/checks/rust-audit/default.nix new file mode 100644 index 0000000..14bc9c5 --- /dev/null +++ b/nix/checks/rust-audit/default.nix @@ -0,0 +1,7 @@ +{ + inputs, + pkgs, + lib, + ... +}: +(lib.khscodes.mkRust pkgs "${inputs.self}/rust").checks.rust-audit diff --git a/nix/checks/rust-clippy/default.nix b/nix/checks/rust-clippy/default.nix new file mode 100644 index 0000000..57947fd --- /dev/null +++ b/nix/checks/rust-clippy/default.nix @@ -0,0 +1,7 @@ +{ + inputs, + pkgs, + lib, + ... +}: +(lib.khscodes.mkRust pkgs "${inputs.self}/rust").checks.rust-clippy diff --git a/nix/checks/rust-doc/default.nix b/nix/checks/rust-doc/default.nix new file mode 100644 index 0000000..24d3b90 --- /dev/null +++ b/nix/checks/rust-doc/default.nix @@ -0,0 +1,7 @@ +{ + inputs, + pkgs, + lib, + ... +}: +(lib.khscodes.mkRust pkgs "${inputs.self}/rust").checks.rust-doc diff --git a/nix/checks/rust-fmt/default.nix b/nix/checks/rust-fmt/default.nix new file mode 100644 index 0000000..2cbf89f --- /dev/null +++ b/nix/checks/rust-fmt/default.nix @@ -0,0 +1,7 @@ +{ + inputs, + pkgs, + lib, + ... +}: +(lib.khscodes.mkRust pkgs "${inputs.self}/rust").checks.rust-fmt diff --git a/nix/checks/rust-hakari/default.nix b/nix/checks/rust-hakari/default.nix new file mode 100644 index 0000000..339e04e --- /dev/null +++ b/nix/checks/rust-hakari/default.nix @@ -0,0 +1,7 @@ +{ + inputs, + pkgs, + lib, + ... +}: +(lib.khscodes.mkRust pkgs "${inputs.self}/rust").checks.rust-hakari diff --git a/nix/lib/rust/default.nix b/nix/lib/rust/default.nix new file mode 100644 index 0000000..3333325 --- /dev/null +++ b/nix/lib/rust/default.nix @@ -0,0 +1,14 @@ +{ + inputs, + lib, + ... +}: +{ + mkRust = + pkgs: src: + import src { + inherit lib pkgs; + crane = inputs.crane; + advisory-db = inputs.advisory-db; + }; +} diff --git a/nix/modules/nixos/fqdn/default.nix b/nix/modules/nixos/fqdn/default.nix index 504167c..73cf099 100644 --- a/nix/modules/nixos/fqdn/default.nix +++ b/nix/modules/nixos/fqdn/default.nix @@ -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; }; diff --git a/nix/modules/nixos/terraform-hetzner/default.nix b/nix/modules/nixos/hetzner-instance/default.nix similarity index 51% rename from nix/modules/nixos/terraform-hetzner/default.nix rename to nix/modules/nixos/hetzner-instance/default.nix index 4af48d3..704a2d7 100644 --- a/nix/modules/nixos/terraform-hetzner/default.nix +++ b/nix/modules/nixos/hetzner-instance/default.nix @@ -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" + ]; + }; } ); } diff --git a/nix/modules/nixos/opentofu-openbao/default.nix b/nix/modules/nixos/opentofu-openbao/default.nix new file mode 100644 index 0000000..32e84c8 --- /dev/null +++ b/nix/modules/nixos/opentofu-openbao/default.nix @@ -0,0 +1 @@ +{ pkgs, ... }: { } diff --git a/nix/modules/nixos/provisioning/default.nix b/nix/modules/nixos/provisioning/default.nix new file mode 100644 index 0000000..be59a46 --- /dev/null +++ b/nix/modules/nixos/provisioning/default.nix @@ -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; + }; +} diff --git a/nix/modules/terranix/hcloud/output.nix b/nix/modules/terranix/hcloud/output.nix index 1aacf80..36ae028 100644 --- a/nix/modules/terranix/hcloud/output.nix +++ b/nix/modules/terranix/hcloud/output.nix @@ -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; diff --git a/nix/modules/terranix/openbao/default.nix b/nix/modules/terranix/openbao/default.nix new file mode 100644 index 0000000..adfe0a6 --- /dev/null +++ b/nix/modules/terranix/openbao/default.nix @@ -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; + } + ); + }; +} diff --git a/nix/modules/terranix/openbao/output.nix b/nix/modules/terranix/openbao/output.nix new file mode 100644 index 0000000..9e92755 --- /dev/null +++ b/nix/modules/terranix/openbao/output.nix @@ -0,0 +1,10 @@ +{ khscodesLib, ... }: +{ config, lib, ... }: +let + cfg = config.khscodes.openbao; +in +{ + options.khscodes.openbao = { }; + config = { + }; +} diff --git a/nix/modules/terranix/openbao/ssh_secret_backend_ca.nix b/nix/modules/terranix/openbao/ssh_secret_backend_ca.nix new file mode 100644 index 0000000..4521947 --- /dev/null +++ b/nix/modules/terranix/openbao/ssh_secret_backend_ca.nix @@ -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; + } + ); + }; +} diff --git a/nix/modules/terranix/openbao/vault_mount.nix b/nix/modules/terranix/openbao/vault_mount.nix new file mode 100644 index 0000000..4f4be60 --- /dev/null +++ b/nix/modules/terranix/openbao/vault_mount.nix @@ -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; + } + ); + }; +} diff --git a/nix/packages/bw-opentofu/default.nix b/nix/packages/bw-opentofu/default.nix index fe82516..8e531d7 100644 --- a/nix/packages/bw-opentofu/default.nix +++ b/nix/packages/bw-opentofu/default.nix @@ -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; } diff --git a/nix/packages/find-flake-root/default.nix b/nix/packages/find-flake-root/default.nix new file mode 100644 index 0000000..791d53e --- /dev/null +++ b/nix/packages/find-flake-root/default.nix @@ -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 + ''; +} diff --git a/nix/packages/hetzner-ipv6/default.nix b/nix/packages/hetzner-ipv6/default.nix new file mode 100644 index 0000000..305be7e --- /dev/null +++ b/nix/packages/hetzner-ipv6/default.nix @@ -0,0 +1,6 @@ +{ + lib, + pkgs, + inputs, +}: +(lib.khscodes.mkRust pkgs "${inputs.self}/rust").buildRustPackage "hetzner-ipv6" diff --git a/nix/packages/opentofu-hetzner/default.nix b/nix/packages/opentofu-hetzner/default.nix deleted file mode 100644 index a59f235..0000000 --- a/nix/packages/opentofu-hetzner/default.nix +++ /dev/null @@ -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}" - ''; -} diff --git a/nix/packages/pre-provisioning/default.nix b/nix/packages/pre-provisioning/default.nix new file mode 100644 index 0000000..7d459da --- /dev/null +++ b/nix/packages/pre-provisioning/default.nix @@ -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" + ''; +} diff --git a/nix/shells/default/default.nix b/nix/shells/default/default.nix new file mode 100644 index 0000000..4e45089 --- /dev/null +++ b/nix/shells/default/default.nix @@ -0,0 +1,12 @@ +{ + lib, + pkgs, + inputs, + mkShell, +}: +mkShell { + packages = [ + pkgs.nixd + pkgs.nixfmt-rfc-style + ] ++ (lib.khscodes.mkRust pkgs "${inputs.self}/rust").devDeps; +} diff --git a/nix/systems/x86_64-linux/khs.codes/default.nix b/nix/systems/aarch64-linux/khs.codes/default.nix similarity index 88% rename from nix/systems/x86_64-linux/khs.codes/default.nix rename to nix/systems/aarch64-linux/khs.codes/default.nix index b10a95d..d4c65e4 100644 --- a/nix/systems/x86_64-linux/khs.codes/default.nix +++ b/nix/systems/aarch64-linux/khs.codes/default.nix @@ -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"; diff --git a/rust/.config/hakari.toml b/rust/.config/hakari.toml new file mode 100644 index 0000000..4a94618 --- /dev/null +++ b/rust/.config/hakari.toml @@ -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 diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..ae6237a --- /dev/null +++ b/rust/Cargo.lock @@ -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" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..86a3a55 --- /dev/null +++ b/rust/Cargo.toml @@ -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 } diff --git a/rust/default.nix b/rust/default.nix new file mode 100644 index 0000000..bdf0134 --- /dev/null +++ b/rust/default.nix @@ -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 + ]; +} diff --git a/rust/lib/common/Cargo.toml b/rust/lib/common/Cargo.toml new file mode 100644 index 0000000..9ae42d8 --- /dev/null +++ b/rust/lib/common/Cargo.toml @@ -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" } diff --git a/rust/lib/common/src/base64.rs b/rust/lib/common/src/base64.rs new file mode 100644 index 0000000..1457303 --- /dev/null +++ b/rust/lib/common/src/base64.rs @@ -0,0 +1,5 @@ +use base64::Engine; + +pub fn encode(bytes: &[u8]) -> String { + base64::prelude::BASE64_STANDARD.encode(bytes) +} diff --git a/rust/lib/common/src/bitwarden.rs b/rust/lib/common/src/bitwarden.rs new file mode 100644 index 0000000..82f22ae --- /dev/null +++ b/rust/lib/common/src/bitwarden.rs @@ -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, + #[serde(rename = "folderId")] + pub folder_id: Option, + #[serde(rename = "collectionIds")] + pub collection_ids: Vec, + + pub fields: Option>, + + pub notes: Option, + + #[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, + #[serde(rename = "folderId")] + pub folder_id: Option, + #[serde(rename = "collectionIds")] + pub collection_ids: Vec, + + pub fields: Option>, + + pub notes: Option, + + #[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, + pub password: Option, + pub totp: Option, + pub uris: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct BitwardenEntryLoginUri { + pub uri: Option, + #[serde(rename = "match")] + pub match_type: Option, +} + +#[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, + #[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, +} + +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 { + 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> { + 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> { + 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> { + 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> { + 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> { + 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 { + 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, +} diff --git a/rust/lib/common/src/bitwarden/entry_serde.rs b/rust/lib/common/src/bitwarden/entry_serde.rs new file mode 100644 index 0000000..a92f1af --- /dev/null +++ b/rust/lib/common/src/bitwarden/entry_serde.rs @@ -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(&self, serializer: S) -> Result + 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(deserializer: D) -> Result + 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(self, mut map: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + let mut entry_type: Option = None; + let mut login_data: Option = None; + let mut secure_note_data: Option = None; + let mut card_data: Option = None; + let mut identity_data: Option = 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"), + } + } +} diff --git a/rust/lib/common/src/curl.rs b/rust/lib/common/src/curl.rs new file mode 100644 index 0000000..c9d6c13 --- /dev/null +++ b/rust/lib/common/src/curl.rs @@ -0,0 +1,13 @@ +use crate::proc::Command; + +pub fn read_json_as_string(url: &str) -> anyhow::Result { + 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 { + let mut cmd = Command::new("curl"); + cmd.arg(url); + cmd.try_spawn_to_string() +} diff --git a/rust/lib/common/src/env.rs b/rust/lib/common/src/env.rs new file mode 100644 index 0000000..afd5727 --- /dev/null +++ b/rust/lib/common/src/env.rs @@ -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 { + 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 { + Ok(read_env(var)?.into()) +} + +pub fn read_env_json Deserialize<'de>>(var: &'static str) -> anyhow::Result { + 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") + }) +} diff --git a/rust/lib/common/src/fs.rs b/rust/lib/common/src/fs.rs new file mode 100644 index 0000000..9f855af --- /dev/null +++ b/rust/lib/common/src/fs.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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())) +} diff --git a/rust/lib/common/src/json.rs b/rust/lib/common/src/json.rs new file mode 100644 index 0000000..0f60bf0 --- /dev/null +++ b/rust/lib/common/src/json.rs @@ -0,0 +1,69 @@ +use anyhow::Context; +use serde::{Deserialize, Serialize}; + +pub fn from_str Deserialize<'de>>(s: &str) -> anyhow::Result { + serde_json::from_str(s).map_err(|e| anyhow::format_err!("{e}:\n{}", extract_context(&e, s))) +} + +pub fn to_string(data: &S) -> anyhow::Result { + serde_json::to_string(data).context("Could not serialize data to json") +} + +pub fn to_string_pretty(data: &S) -> anyhow::Result { + serde_json::to_string_pretty(data).context("Could not serialize data to json") +} + +pub fn to_vec(data: &S) -> anyhow::Result> { + serde_json::to_vec(data).context("Could not serialize data to json") +} + +pub fn to_vec_pretty(data: &S) -> anyhow::Result> { + 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::(), + )); + if lines.len() > error_line { + result.push_str(&format!("{}: {}\n", error_line + 1, lines[error_line])); + } + result + } +} diff --git a/rust/lib/common/src/lib.rs b/rust/lib/common/src/lib.rs new file mode 100644 index 0000000..f81039c --- /dev/null +++ b/rust/lib/common/src/lib.rs @@ -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); + } +} diff --git a/rust/lib/common/src/proc.rs b/rust/lib/common/src/proc.rs new file mode 100644 index 0000000..9513c1b --- /dev/null +++ b/rust/lib/common/src/proc.rs @@ -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), + Inherit, +} + +impl Stdin { + fn into_data(self) -> Option> { + 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(_) => "", + } + } +} + +#[derive(Debug)] +pub struct Command { + program: String, + args: Vec, + env: BTreeMap>, + is_sudo: bool, + show_command: bool, + stdin: Stdin, + stderr: Stderr, +} + +impl Command { + pub fn new(program: impl Into) -> 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 { + 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)> { + 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(&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(&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) -> &mut Self { + self.stdin = Stdin::Pipe(Vec::from(stdin.into())); + self + } + + pub fn stdin_bytes(&mut self, stdin: impl Into>) -> &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) -> &mut Self { + self.args.push(arg.into()); + self + } + + pub fn args(&mut self, args: impl IntoIterator) -> &mut Self + where + A: Into, + { + self.args.extend(args.into_iter().map(Into::into)); + self + } + + pub fn env(&mut self, key: impl Into, value: impl Into) -> &mut Self { + let _ = self + .env + .insert(key.into(), Some(EnvData::Insensitive(value.into()))); + self + } + + pub fn env_sensitive(&mut self, key: impl Into, value: impl Into) -> &mut Self { + let _ = self + .env + .insert(key.into(), Some(EnvData::Sensitive(value.into()))); + self + } + + pub fn env_remove(&mut self, key: impl Into) -> &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> { + 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 { + 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 Deserialize<'de>>(&mut self) -> anyhow::Result { + 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 { + 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> { + 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") + ); + } +} diff --git a/rust/lib/common/src/proc/util.rs b/rust/lib/common/src/proc/util.rs new file mode 100644 index 0000000..8ae707b --- /dev/null +++ b/rust/lib/common/src/proc/util.rs @@ -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>(str: A) -> String { + let str = str.as_ref(); + shell_quote::Bash::quote(str) +} diff --git a/rust/lib/common/src/yaml.rs b/rust/lib/common/src/yaml.rs new file mode 100644 index 0000000..8eca4fd --- /dev/null +++ b/rust/lib/common/src/yaml.rs @@ -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(value: &T) -> anyhow::Result { + serde_yml::to_string(value).context("Could not serialize to yaml") +} diff --git a/rust/lib/hakari/.gitattributes b/rust/lib/hakari/.gitattributes new file mode 100644 index 0000000..3e9dba4 --- /dev/null +++ b/rust/lib/hakari/.gitattributes @@ -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 diff --git a/rust/lib/hakari/Cargo.toml b/rust/lib/hakari/Cargo.toml new file mode 100644 index 0000000..57db895 --- /dev/null +++ b/rust/lib/hakari/Cargo.toml @@ -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 diff --git a/rust/lib/hakari/build.rs b/rust/lib/hakari/build.rs new file mode 100644 index 0000000..92518ef --- /dev/null +++ b/rust/lib/hakari/build.rs @@ -0,0 +1,2 @@ +// A build script is required for cargo to consider build dependencies. +fn main() {} diff --git a/rust/lib/hakari/src/lib.rs b/rust/lib/hakari/src/lib.rs new file mode 100644 index 0000000..22489f6 --- /dev/null +++ b/rust/lib/hakari/src/lib.rs @@ -0,0 +1 @@ +// This is a stub lib.rs. diff --git a/rust/program/hetzner-ipv6/Cargo.toml b/rust/program/hetzner-ipv6/Cargo.toml new file mode 100644 index 0000000..242eb0c --- /dev/null +++ b/rust/program/hetzner-ipv6/Cargo.toml @@ -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" } diff --git a/rust/program/hetzner-ipv6/src/main.rs b/rust/program/hetzner-ipv6/src/main.rs new file mode 100644 index 0000000..f85da39 --- /dev/null +++ b/rust/program/hetzner-ipv6/src/main.rs @@ -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(()) +} diff --git a/rust/rust-toolchain.toml b/rust/rust-toolchain.toml new file mode 100644 index 0000000..9fdc9ae --- /dev/null +++ b/rust/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.88.0" +components = ["rustfmt", "clippy", "cargo"] +profile = "minimal"