From 47dbb7cdd3ac436fda34bad116d8c9862348cc9b Mon Sep 17 00:00:00 2001 From: Kaare Hoff Skovgaard Date: Mon, 7 Jul 2025 00:06:55 +0200 Subject: [PATCH] Attempt to implement and test setting static ips from instance metadata --- .direnv/flake-profile | 2 +- .direnv/flake-profile-2-link | 1 - .direnv/flake-profile-4-link | 1 + flake.lock | 8 +-- nix/checks/hetzner-sets-ipv6/default.nix | 53 ++++++++++---- .../hetzner-sets-ipv6/root/metadata.yml | 12 ++++ nix/modules/nixos/hetzner/default.nix | 34 +++++++-- .../default.nix | 2 +- rust/Cargo.lock | 2 +- rust/default.nix | 15 +++- rust/lib/common/src/yaml.rs | 50 +++++++++++++ rust/program/hetzner-ipv6/src/main.rs | 28 -------- .../Cargo.toml | 4 +- rust/program/hetzner-static-ip/src/main.rs | 71 +++++++++++++++++++ .../program/hetzner-static-ip/src/metadata.rs | 32 +++++++++ rust/rust-toolchain.toml | 2 +- 16 files changed, 258 insertions(+), 59 deletions(-) delete mode 120000 .direnv/flake-profile-2-link create mode 120000 .direnv/flake-profile-4-link create mode 100644 nix/checks/hetzner-sets-ipv6/root/metadata.yml rename nix/packages/{hetzner-ipv6 => hetzner-static-ip}/default.nix (81%) delete mode 100644 rust/program/hetzner-ipv6/src/main.rs rename rust/program/{hetzner-ipv6 => hetzner-static-ip}/Cargo.toml (79%) create mode 100644 rust/program/hetzner-static-ip/src/main.rs create mode 100644 rust/program/hetzner-static-ip/src/metadata.rs diff --git a/.direnv/flake-profile b/.direnv/flake-profile index c7ae5b7..e289079 120000 --- a/.direnv/flake-profile +++ b/.direnv/flake-profile @@ -1 +1 @@ -flake-profile-2-link \ No newline at end of file +flake-profile-4-link \ No newline at end of file diff --git a/.direnv/flake-profile-2-link b/.direnv/flake-profile-2-link deleted file mode 120000 index 01ece51..0000000 --- a/.direnv/flake-profile-2-link +++ /dev/null @@ -1 +0,0 @@ -/nix/store/dqy98711cp1rqz8nj7qpxq6qj6vnyf0j-nix-shell-env \ No newline at end of file diff --git a/.direnv/flake-profile-4-link b/.direnv/flake-profile-4-link new file mode 120000 index 0000000..bff5d5f --- /dev/null +++ b/.direnv/flake-profile-4-link @@ -0,0 +1 @@ +/nix/store/k5vgwymjcra0rv45n3vza2myawy6w48z-nix-shell-env \ No newline at end of file diff --git a/flake.lock b/flake.lock index 1384cb6..813e058 100644 --- a/flake.lock +++ b/flake.lock @@ -92,11 +92,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1751720970, - "narHash": "sha256-Fe8yQfmjlgNSrkBU/5FcYBQVsOFyfxe73C1zfsHhXDU=", + "lastModified": 1751834884, + "narHash": "sha256-LUggV7UgPbnUkDHpbqZc25jmgTOqQfW/C1JPOLgkQAk=", "ref": "refs/heads/main", - "rev": "b3ddb341d8bfe6fb5f618dfee1f720a3deeee47d", - "revCount": 10, + "rev": "05c74cc4e6e44913663d72b78222ffc855f0c834", + "revCount": 11, "type": "git", "url": "https://khs.codes/nix/flake-base" }, diff --git a/nix/checks/hetzner-sets-ipv6/default.nix b/nix/checks/hetzner-sets-ipv6/default.nix index 3c4b332..68ce8ce 100644 --- a/nix/checks/hetzner-sets-ipv6/default.nix +++ b/nix/checks/hetzner-sets-ipv6/default.nix @@ -1,19 +1,48 @@ { inputs, pkgs, ... }: +let + sharedModule = { + # Since it's common for CI not to have $DISPLAY available, explicitly disable graphics support + virtualisation.graphics = false; + }; +in pkgs.nixosTest { - name = "hetzner-will-boot"; - nodes.machine = - { ... }: - { - imports = [ inputs.self.nixosModules.default ]; - khscodes.hetzner = { - enable = true; - ipv6-addr = "dead:beef:cafe::1"; + name = "hetzner-sets-ipv6"; + nodes = { + machine = + { ... }: + { + imports = [ + inputs.self.nixosModules.default + sharedModule + ]; + khscodes.hetzner = { + enable = true; + metadataApiUri = "http://metadata/metadata.yml"; + }; + system.stateVersion = "25.05"; }; - system.stateVersion = "25.05"; - }; + metadata = + { ... }: + { + imports = [ sharedModule ]; + services.nginx = { + enable = true; + virtualHosts = { + "metafata" = { + root = ./root; + }; + }; + }; + networking.firewall.allowedTCPPorts = [ 80 ]; + system.stateVersion = "25.05"; + }; + }; testScript = '' - machine.start(allow_reboot = True) - machine.wait_for_unit("multi-user.target") + metadata.start() + metadata.wait_for_unit("nginx.service") + metadata.wait_for_open_port(80) + machine.start() + machine.wait_for_unit("hetzner-static-ip.service") ipv6 = machine.succeed("ip addr") assert "dead:beef:cafe::1" in ipv6 ''; diff --git a/nix/checks/hetzner-sets-ipv6/root/metadata.yml b/nix/checks/hetzner-sets-ipv6/root/metadata.yml new file mode 100644 index 0000000..781316a --- /dev/null +++ b/nix/checks/hetzner-sets-ipv6/root/metadata.yml @@ -0,0 +1,12 @@ +--- +network-config: + config: + - name: eth0 + subnets: + - ipv4: true + type: dhcp + - address: dead:beef:cafe::1/64 + gateway: fe80::1 + ipv6: true + type: static + type: physical diff --git a/nix/modules/nixos/hetzner/default.nix b/nix/modules/nixos/hetzner/default.nix index 5d7b4ee..e2f033c 100644 --- a/nix/modules/nixos/hetzner/default.nix +++ b/nix/modules/nixos/hetzner/default.nix @@ -1,6 +1,7 @@ { config, lib, + pkgs, system, ... }: @@ -10,16 +11,16 @@ in { options.khscodes.hetzner = { enable = lib.mkEnableOption "Enables the machine as a hetzner machine"; - ipv6-addr = lib.mkOption { - type = lib.types.nullOr lib.types.str; - description = "IPv6 address of the server, for now detecting this from the server itself is not supported"; - default = null; - }; diskName = lib.mkOption { type = lib.types.str; default = "nixos"; description = "Name of the root disk device"; }; + metadataApiUri = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Sets the metadata API url that the server will contact to gather metadata information from. Should probably only be used for testing"; + }; }; config = lib.mkIf cfg.enable { @@ -48,10 +49,29 @@ in networkConfig = { DHCP = "ipv4"; }; - routes = [ { Gateway = "fe80::1"; } ]; linkConfig.RequiredForOnline = "routable"; - address = lib.mkIf (cfg.ipv6-addr != null) [ cfg.ipv6-addr ]; }; }; + + systemd.services.hetzner-static-ip = { + enable = true; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = '' + ${lib.getExe pkgs.khscodes.hetzner-static-ip} configure + ''; + }; + environment = + { + PATH = lib.mkForce ""; + } + // lib.attrsets.optionalAttrs (cfg.metadataApiUri != null) { + INSTANCE_API_URI = cfg.metadataApiUri; + }; + }; }; } diff --git a/nix/packages/hetzner-ipv6/default.nix b/nix/packages/hetzner-static-ip/default.nix similarity index 81% rename from nix/packages/hetzner-ipv6/default.nix rename to nix/packages/hetzner-static-ip/default.nix index 305be7e..1a67c93 100644 --- a/nix/packages/hetzner-ipv6/default.nix +++ b/nix/packages/hetzner-static-ip/default.nix @@ -3,4 +3,4 @@ pkgs, inputs, }: -(lib.khscodes.mkRust pkgs "${inputs.self}/rust").buildRustPackage "hetzner-ipv6" +(lib.khscodes.mkRust pkgs "${inputs.self}/rust").buildRustPackage "hetzner-static-ip" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ae6237a..6a46319 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -187,7 +187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "hetzner-ipv6" +name = "hetzner-static-ip" version = "1.0.0" dependencies = [ "anyhow", diff --git a/rust/default.nix b/rust/default.nix index bdf0134..3a4e54c 100644 --- a/rust/default.nix +++ b/rust/default.nix @@ -26,7 +26,7 @@ let fileset = lib.fileset.unions [ ./Cargo.lock ./Cargo.toml - (craneLib.fileset.commonCargoSources ./lib/common) + (craneLib.fileset.commonCargoSources ./lib) (craneLib.fileset.commonCargoSources ./program/${crate}) ]; }; @@ -40,6 +40,19 @@ in pname = crateName; cargoExtraArgs = "-p ${crateName}"; src = fileSetForCrate crateName; + nativeBuildInputs = [ pkgs.makeWrapper ]; + postFixup = '' + wrapProgram $out/bin/${crateName} --set PATH "${ + lib.makeBinPath [ + pkgs.curl + pkgs.uutils-coreutils-noprefix + pkgs.iproute2 + ] + }" + ''; + meta = { + mainProgram = crateName; + }; } ); checks = { diff --git a/rust/lib/common/src/yaml.rs b/rust/lib/common/src/yaml.rs index 8eca4fd..1a2cb5a 100644 --- a/rust/lib/common/src/yaml.rs +++ b/rust/lib/common/src/yaml.rs @@ -8,3 +8,53 @@ pub fn string(str: &str) -> String { pub fn to_string(value: &T) -> anyhow::Result { serde_yml::to_string(value).context("Could not serialize to yaml") } + +pub fn from_str serde::Deserialize<'de>>(s: &str) -> anyhow::Result { + serde_yml::from_str(s).map_err(|e| anyhow::format_err!("{e}:\n{}", extract_context(&e, s))) +} + +fn extract_context(serde_error: &serde_yml::Error, s: &str) -> String { + let Some(location) = serde_error.location() else { + return String::from("Error provided no location information, could not extract context"); + }; + let lines: Vec<_> = s.lines().collect(); + if lines.len() == 1 { + let (col_begin, highlight) = if location.column() > 30 { + (location.column() - 30, 30) + } else { + (1, location.column()) + }; + let col_end = if lines[0].len() + 31 < location.column() { + lines[0].len() + 1 + } else { + location.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 = location.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(' ', location.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/program/hetzner-ipv6/src/main.rs b/rust/program/hetzner-ipv6/src/main.rs deleted file mode 100644 index f85da39..0000000 --- a/rust/program/hetzner-ipv6/src/main.rs +++ /dev/null @@ -1,28 +0,0 @@ -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/program/hetzner-ipv6/Cargo.toml b/rust/program/hetzner-static-ip/Cargo.toml similarity index 79% rename from rust/program/hetzner-ipv6/Cargo.toml rename to rust/program/hetzner-static-ip/Cargo.toml index 242eb0c..fd6a4d1 100644 --- a/rust/program/hetzner-ipv6/Cargo.toml +++ b/rust/program/hetzner-static-ip/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "hetzner-ipv6" +name = "hetzner-static-ip" edition = "2024" version = "1.0.0" -metadata.crane.name = "hetzner-ipv6" +metadata.crane.name = "hetzner-static-ip" [dependencies] anyhow = { workspace = true } diff --git a/rust/program/hetzner-static-ip/src/main.rs b/rust/program/hetzner-static-ip/src/main.rs new file mode 100644 index 0000000..3962c23 --- /dev/null +++ b/rust/program/hetzner-static-ip/src/main.rs @@ -0,0 +1,71 @@ +use anyhow::Context as _; +use clap::{Parser, Subcommand}; + +use crate::metadata::Instance; + +mod metadata; + +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<()> { + let metadata_api = common::env::read_env("INSTANCE_API_URI") + .unwrap_or(String::from("http://169.254.169.254/hetzner/v1/metadata")); + let metadata = common::curl::read_text_as_string(&metadata_api)?; + let metadata: Instance = common::yaml::from_str(&metadata) + .context("Could not parse instance metadata into expected format")?; + for m in metadata.network_config.config { + for subnet in m.subnets { + match subnet { + metadata::InstanceNetworkConfigConfigSubnet::Static { + ipv6, + ipv4, + address, + gateway, + } => { + let mut cmd = common::proc::Command::new("ip"); + if ipv6.is_some_and(|v| v) { + cmd.arg("-6"); + } + if ipv4.is_some_and(|v| v) { + cmd.arg("-4"); + } + cmd.args(["addr", "add", &address, "dev", &m.name]); + cmd.try_spawn_to_string()?; + let mut cmd = common::proc::Command::new("ip"); + if ipv6.is_some_and(|v| v) { + cmd.arg("-6"); + } + if ipv4.is_some_and(|v| v) { + cmd.arg("-4"); + } + cmd.args(["route", "add", "default", "via", &gateway, "dev", &m.name]); + cmd.try_spawn_to_string()?; + } + metadata::InstanceNetworkConfigConfigSubnet::Dhcp {} => continue, + } + } + } + Ok(()) +} diff --git a/rust/program/hetzner-static-ip/src/metadata.rs b/rust/program/hetzner-static-ip/src/metadata.rs new file mode 100644 index 0000000..d914a37 --- /dev/null +++ b/rust/program/hetzner-static-ip/src/metadata.rs @@ -0,0 +1,32 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Instance { + #[serde(rename = "network-config")] + pub network_config: InstanceNetworkConfig, +} + +#[derive(Debug, Deserialize)] +pub struct InstanceNetworkConfig { + pub config: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct InstanceNetworkConfigConfig { + pub name: String, + pub subnets: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum InstanceNetworkConfigConfigSubnet { + #[serde(rename = "static")] + Static { + ipv6: Option, + ipv4: Option, + address: String, + gateway: String, + }, + #[serde(rename = "dhcp")] + Dhcp {}, +} diff --git a/rust/rust-toolchain.toml b/rust/rust-toolchain.toml index 9fdc9ae..b907df2 100644 --- a/rust/rust-toolchain.toml +++ b/rust/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] channel = "1.88.0" -components = ["rustfmt", "clippy", "cargo"] +components = ["rustfmt", "clippy", "cargo", "rust-src"] profile = "minimal"