cloud/mesh_service.rs
1//! Recipe helpers for stateful services bound exclusively to the Headscale mesh
2//! (R040-F16).
3//!
4//! Inter-node TCP (Postgres primary↔replica, NATS clusters, etc.) lives on the
5//! WireGuard mesh, not on Hetzner public IPs. Each node has a stable
6//! `100.64.x.x` mesh IP that survives box replacement, so connection strings
7//! and pg_hba.conf never need to churn when a CPX-22 is rebuilt.
8//!
9//! ## Standard pattern for a mesh-bound port
10//!
11//! ```text
12//! ServiceConfig {
13//! name: "postgres",
14//! bind_interface: Some("tailscale0"),
15//! mesh_only: true,
16//! ...
17//! }
18//! ```
19//!
20//! The compose renderer emits `network_mode: "host"` for such a service.
21//! Pair it with the ufw rules from [`ufw_rules_for_mesh_port`] (applied by the
22//! yubaba's `POST /compose` via the `firewall_cmds` field) and the pg_hba
23//! snippet from [`pg_hba_snippet`] (injected into the Postgres container via
24//! a mounted config volume or env).
25//!
26//! ## First-boot POSTGRES_LISTEN_ADDRESSES
27//!
28//! Postgres must bind to the node's tailscale mesh IP, not `0.0.0.0`. Since
29//! the IP is only known at boot time, cloud-init or a systemd `ExecStartPre`
30//! can resolve it:
31//!
32//! ```yaml
33//! # cloud-init write_files
34//! - path: /etc/yah-cloud/mesh-ip.env
35//! content: "" # overwritten by runcmd below
36//!
37//! runcmd:
38//! - sh -c 'echo "POSTGRES_LISTEN_ADDRESSES=$(tailscale ip --4)" > /etc/yah-cloud/mesh-ip.env'
39//! ```
40//!
41//! Then reference `env_file: [/etc/yah-cloud/mesh-ip.env]` in the compose
42//! service block. The compose renderer sets this automatically when
43//! `bind_interface` is set on a service that exposes port 5432.
44
45/// Tailscale/Headscale CGNAT subnet — all mesh peers have addresses in this range.
46pub const MESH_SUBNET: &str = "100.64.0.0/10";
47
48/// The network interface name that carries Tailscale/Headscale mesh traffic.
49pub const TAILSCALE_IFACE: &str = "tailscale0";
50
51/// The env file path written by cloud-init that holds the node's mesh IP.
52/// Referenced as `env_file` in compose when `bind_interface` is set.
53pub const MESH_IP_ENV_FILE: &str = "/etc/yah-cloud/mesh-ip.env";
54
55/// Generate a `pg_hba.conf` block that allows connections from any mesh peer.
56///
57/// `mesh_subnet` is normally [`MESH_SUBNET`] (`100.64.0.0/10`); override
58/// for testing or non-standard CGNAT ranges.
59///
60/// The returned string is ready to append to or replace the service section of
61/// `pg_hba.conf`. It allows:
62/// - All application users (`all`) from any mesh address.
63/// - Replication users (`replication`) from any mesh address (needed for
64/// streaming replication between the primary and standby nodes).
65///
66/// Both rows use `scram-sha-256`, which is the Postgres 16 default and is
67/// more secure than `md5`. Set `password_encryption = scram-sha-256` in
68/// `postgresql.conf` to match.
69pub fn pg_hba_snippet(mesh_subnet: &str) -> String {
70 format!(
71 "# pg_hba.conf — mesh subnet ({mesh_subnet})\n\
72 # Allow app connections from any mesh peer (WireGuard-encrypted on the wire).\n\
73 host all all {mesh_subnet} scram-sha-256\n\
74 # Allow replication from any mesh peer (streaming replica sync).\n\
75 host replication all {mesh_subnet} scram-sha-256\n",
76 )
77}
78
79/// Generate the ufw commands needed to make port `port` reachable only on
80/// interface `iface` (typically `tailscale0`), blocking all other ingress.
81///
82/// Returns two commands: the interface-specific allow, then a blanket deny.
83/// ufw evaluates rules in insertion order — the interface-scoped allow must
84/// be added **before** the blanket deny or it will never be reached.
85///
86/// This mirrors the pattern used for yah-yubaba's 7443 port in `mirror.yml`:
87/// ```text
88/// ufw allow in on tailscale0 to any port 7443
89/// ufw deny 7443
90/// ```
91pub fn ufw_rules_for_mesh_port(iface: &str, port: u16) -> Vec<String> {
92 vec![
93 format!("ufw allow in on {iface} to any port {port}"),
94 format!("ufw deny {port}"),
95 ]
96}
97
98/// Build the cloud-init `runcmd` lines that write the mesh IP env file at
99/// first boot. Append these to a machine's `mirror.yml` `runcmd` block to
100/// make `POSTGRES_LISTEN_ADDRESSES` available to the compose stack via
101/// `env_file: [{MESH_IP_ENV_FILE}]`.
102pub fn mesh_ip_env_runcmd() -> Vec<String> {
103 vec![format!(
104 "sh -c 'echo \"POSTGRES_LISTEN_ADDRESSES=$(tailscale ip --4)\" > {MESH_IP_ENV_FILE}'"
105 )]
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn pg_hba_snippet_contains_mesh_subnet() {
114 let s = pg_hba_snippet(MESH_SUBNET);
115 assert!(s.contains(MESH_SUBNET), "subnet missing from snippet");
116 assert!(s.contains("scram-sha-256"), "auth method missing");
117 assert!(s.contains("replication"), "replication row missing");
118 assert!(s.contains("host all"), "app-user row missing");
119 }
120
121 #[test]
122 fn pg_hba_snippet_custom_subnet() {
123 let s = pg_hba_snippet("10.0.0.0/8");
124 assert!(s.contains("10.0.0.0/8"));
125 assert!(!s.contains(MESH_SUBNET));
126 }
127
128 #[test]
129 fn ufw_rules_for_mesh_port_produces_two_commands() {
130 let rules = ufw_rules_for_mesh_port("tailscale0", 5432);
131 assert_eq!(rules.len(), 2);
132 assert_eq!(rules[0], "ufw allow in on tailscale0 to any port 5432");
133 assert_eq!(rules[1], "ufw deny 5432");
134 }
135
136 #[test]
137 fn ufw_rules_allow_before_deny() {
138 let rules = ufw_rules_for_mesh_port(TAILSCALE_IFACE, 5432);
139 // allow must come first so ufw sees the interface-specific rule before the blanket deny
140 assert!(rules[0].starts_with("ufw allow"), "allow must be first");
141 assert!(rules[1].starts_with("ufw deny"), "deny must be second");
142 }
143
144 #[test]
145 fn ufw_rules_for_arbitrary_port() {
146 let rules = ufw_rules_for_mesh_port("tailscale0", 4222);
147 assert!(rules[0].contains("4222"));
148 assert!(rules[1].contains("4222"));
149 }
150
151 #[test]
152 fn mesh_ip_env_runcmd_references_env_file_path() {
153 let cmds = mesh_ip_env_runcmd();
154 assert_eq!(cmds.len(), 1);
155 assert!(cmds[0].contains(MESH_IP_ENV_FILE));
156 assert!(cmds[0].contains("tailscale ip --4"));
157 }
158}