Attempt to implement and test setting static ips from instance metadata

This commit is contained in:
Kaare Hoff Skovgaard 2025-07-07 00:06:55 +02:00
parent dd1cfa79e7
commit 47dbb7cdd3
Signed by: khs
GPG key ID: C7D890804F01E9F0
16 changed files with 258 additions and 59 deletions

View file

@ -1 +1 @@
flake-profile-2-link
flake-profile-4-link

View file

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

View file

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

8
flake.lock generated
View file

@ -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"
},

View file

@ -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
'';

View file

@ -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

View file

@ -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;
};
};
};
}

View file

@ -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"

2
rust/Cargo.lock generated
View file

@ -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",

View file

@ -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 = {

View file

@ -8,3 +8,53 @@ pub fn string(str: &str) -> String {
pub fn to_string<T: ?Sized + Serialize>(value: &T) -> anyhow::Result<String> {
serde_yml::to_string(value).context("Could not serialize to yaml")
}
pub fn from_str<D: for<'de> serde::Deserialize<'de>>(s: &str) -> anyhow::Result<D> {
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::<String>(),
));
if lines.len() > error_line {
result.push_str(&format!("{}: {}\n", error_line + 1, lines[error_line]));
}
result
}
}

View file

@ -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(())
}

View file

@ -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 }

View file

@ -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(())
}

View file

@ -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<InstanceNetworkConfigConfig>,
}
#[derive(Debug, Deserialize)]
pub struct InstanceNetworkConfigConfig {
pub name: String,
pub subnets: Vec<InstanceNetworkConfigConfigSubnet>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub enum InstanceNetworkConfigConfigSubnet {
#[serde(rename = "static")]
Static {
ipv6: Option<bool>,
ipv4: Option<bool>,
address: String,
gateway: String,
},
#[serde(rename = "dhcp")]
Dhcp {},
}

View file

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