200 lines
8.5 KiB
Markdown
200 lines
8.5 KiB
Markdown
# Flatcar on Proxmox: what is left for you
|
|
|
|
The OpenTofu side is done: `nodes.<hv>.flatcar` in `terraform.tfvars` now creates
|
|
Flatcar VMs (see the Flatcar section in `opentofu/README.md` for how it works).
|
|
The items below need access or decisions I do not have. Nothing in this list
|
|
touches the existing LXCs/VMs.
|
|
|
|
## 1. Enable the Snippets content type on `local` (both nodes)
|
|
|
|
`local` already has `import` enabled (needed for the image), but not `snippets`
|
|
(needed for the Ignition file). On each node, as root:
|
|
|
|
```bash
|
|
pvesm set local --content backup,vztmpl,import,iso,snippets
|
|
```
|
|
|
|
Or in the UI: Datacenter → Storage → local → Edit → Content → tick Snippets.
|
|
|
|
Proxmox does **not** create the snippets directory when you enable the content
|
|
type -- you have to do it yourself, or the upload fails with `tee:
|
|
/var/lib/vz/snippets/<name>-ignition.json: No such file or directory`:
|
|
|
|
```bash
|
|
mkdir -p /var/lib/vz/snippets # confirm the path via `local`'s entry in /etc/pve/storage.cfg
|
|
```
|
|
|
|
## 2. Let the provider SSH to the nodes as root (both nodes)
|
|
|
|
Proxmox has no API for uploading snippets, so bpg copies them over SSH. The
|
|
provider is configured (`providers.tf`, `ssh` block) to log in as `root` with
|
|
the ansible key from Infisical (`SSH_PRIVATE_KEY` / `SSH_PUBLIC_KEY`). On each
|
|
node append the public key to root's authorized_keys:
|
|
|
|
```bash
|
|
# from this machine; the key file is materialized by tofu at ~/.ssh/infra_ansible_key
|
|
ssh-copy-id -i ~/.ssh/infra_ansible_key.pub [email protected] # pve
|
|
ssh-copy-id -i ~/.ssh/infra_ansible_key.pub [email protected] # goose
|
|
```
|
|
|
|
(`~/.ssh/infra_ansible_key.pub` may not exist yet; paste `SSH_PUBLIC_KEY` from
|
|
Infisical into `/root/.ssh/authorized_keys` on the node instead.)
|
|
|
|
Then verify from here:
|
|
|
|
```bash
|
|
ssh -i ~/.ssh/infra_ansible_key [email protected] 'pvesm status'
|
|
```
|
|
|
|
If you would rather not give the provider root, bpg supports a non-root user
|
|
with a narrow sudoers file (`pvesm`, `qm`, `tee /var/lib/vz/snippets/*`). Change
|
|
`username` in the `ssh` blocks accordingly.
|
|
|
|
## 3. Declare a VM
|
|
|
|
Uncomment and adapt the `flatcar = { ... }` example under `nodes.goose` in
|
|
`terraform.tfvars`. Pick an unused vmid and IP. Optional per-VM config goes in
|
|
`butane/<name>.yaml`, referenced with `butane_file = "butane/<name>.yaml"` on
|
|
the VM (`butane/example.yaml` is a starting point; keep `variant: flatcar` and
|
|
`version: 1.0.0`). Inline YAML via `butane = <<-EOT ... EOT` also works.
|
|
|
|
I dry-ran a plan with the example VM declared (nothing applied): it creates
|
|
the image download, the Ignition snippet, the VM and its DNS record, and
|
|
changes nothing else.
|
|
|
|
Then:
|
|
|
|
```bash
|
|
infisical run --path /terraform -- tofu plan -out plan
|
|
infisical run --path /terraform -- tofu apply plan
|
|
```
|
|
|
|
The first apply on a node downloads the 500 MB Flatcar image into `local`
|
|
(`flatcar-stable-4593.2.5.img`), then creates the VM with that image imported
|
|
as its boot disk. Expect the download to take a minute or two.
|
|
|
|
Log in with `ssh ansible@<ip>` or `ssh core@<ip>` using the ansible key.
|
|
|
|
## 4. Things to know
|
|
|
|
- Ignition runs on first boot only. Editing the Butane file updates the snippet on the
|
|
node but does nothing to a running VM. To apply, rebuild it:
|
|
`tofu taint 'module.goose.proxmox_virtual_environment_vm.flatcar["<name>"]'`
|
|
(this destroys the VM's disk; keep data in volumes you can recreate).
|
|
- Ignition snippets are plain text on the node. Do not put secrets in `butane`.
|
|
- Flatcar has no Python, so the existing Ansible plays will not run against it.
|
|
These hosts land in a `flatcar` group in the generated inventory; only
|
|
`raw`/`script` tasks work there. Ignition is the intended replacement.
|
|
- No QEMU guest agent: the module sets `agent.enabled = false` for Flatcar VMs,
|
|
so plans never wait on it. Proxmox will not show the VM's IP in the UI.
|
|
- Bumping `flatcar_image.version` (in `vars.tf` default or tfvars) plus its
|
|
sha512 from the release `.DIGESTS` file downloads a new image for future VMs
|
|
only; existing VMs are not touched (`import_from` is ignored after creation).
|
|
Flatcar itself auto-updates in place via its update engine.
|
|
|
|
## 5. If the VM does not boot
|
|
|
|
The Flatcar docs pass `--args "-machine sata=off"` when creating the VM. I left
|
|
it out: that option is root-only in Proxmox and the API token cannot set it.
|
|
If a Flatcar VM hangs at boot, check the serial console (`qm terminal <vmid>`)
|
|
first; if it looks firmware-related, set that arg once by hand as root
|
|
(`qm set <vmid> --args "-machine sata=off"`) and tell me so I can look for an
|
|
API-safe alternative.
|
|
|
|
## 6. oswl-connector: the headscale side
|
|
|
|
`oswl-connector` (goose, vmid 212, 192.168.7.62) runs tailscaled in a container
|
|
against `https://hs.atlashorizon.net`, advertising `192.168.7.0/24` and itself
|
|
as an exit node. Three things have to happen outside this repo:
|
|
|
|
1. **Create the preauth key and put it in Infisical** as `HEADSCALE_AUTHKEY`
|
|
under `/terraform` (it is read by `secrets.tmpl` as `headscale_authkey`).
|
|
It must be both ephemeral and reusable -- the VM keeps tailscaled's state in
|
|
memory, so it re-registers on every boot:
|
|
|
|
The key also carries the tag that drives route auto-approval (see 2), so
|
|
the VM never has to pass `--advertise-tags` itself:
|
|
|
|
```bash
|
|
headscale preauthkeys create --user <user> --ephemeral --reusable \
|
|
--tags tag:oswl-connector --expiration 8760h
|
|
```
|
|
|
|
Until that secret exists, `tofu plan` fails while rendering
|
|
`butane/oswl_connector.yaml.tftpl` (`templatefile`: no value for
|
|
"headscale_authkey").
|
|
|
|
2. **Route approval is automatic, via the key's tag.** The preauth key applies
|
|
`tag:oswl-connector` as a forced tag at registration, and headscale's
|
|
`autoApprovers` policy approves the subnet route and the exit node for that
|
|
tag. This is what makes the node's ephemerality survivable: it re-registers
|
|
on every boot and after every `tofu taint` rebuild, and each time the routes
|
|
come back approved without anyone touching headscale.
|
|
|
|
The policy this depends on:
|
|
|
|
```json
|
|
{
|
|
"tagOwners": {
|
|
"tag:oswl-connector": ["<user>@"]
|
|
},
|
|
"autoApprovers": {
|
|
"routes": {
|
|
"192.168.7.0/24": ["tag:oswl-connector"]
|
|
},
|
|
"exitNode": ["tag:oswl-connector"]
|
|
}
|
|
}
|
|
```
|
|
|
|
Two things worth knowing if it ever stops approving:
|
|
|
|
- **`autoApprovers` must name the tag, not a user or a group.** Approval by
|
|
username or group silently does not fire for a node whose identity is a
|
|
forced tag ([#2891](https://github.com/juanfont/headscale/issues/2891),
|
|
open as of 0.27.1).
|
|
- **A tagged node is governed by the tag in ACLs, not by its user.** With
|
|
headscale's default allow-all policy that changes nothing, but if you
|
|
later write real ACL rules, `tag:oswl-connector` needs its own grants.
|
|
|
|
To approve by hand (if the policy is ever removed):
|
|
|
|
```bash
|
|
headscale nodes list
|
|
headscale nodes approve-routes -i <id> -r 192.168.7.0/24,0.0.0.0/0,::/0
|
|
```
|
|
|
|
Do not add `--advertise-tags` to `TS_EXTRA_ARGS` to try to set the tag from
|
|
the client side: the key already sets it, and `tailscale up` fails outright
|
|
if the registering user does not own the tag, which takes tailscaled down
|
|
with it.
|
|
|
|
3. **Check the LAN's return path.** The VM has one NIC on VLAN 7, so clients
|
|
reaching 192.168.7.0/24 through the tunnel arrive with a tailnet source
|
|
address. The container runs with `--network host` and tailscaled installs
|
|
its own SNAT rules by default (`--snat-subnet-routes`), so this works
|
|
without touching the router; drop that default only if you add a route for
|
|
the tailnet CIDR on the 192.168.7.0/24 gateway.
|
|
|
|
That SNAT is only real if the netfilter modules are loaded -- the container
|
|
has NET_ADMIN but no SYS_MODULE and no `/lib/modules`, so it cannot load
|
|
them itself. The `modprobe -a` line in `tailscaled.service` loads them on
|
|
the host, where the container picks them up through `--network host`. If
|
|
tailscale ever reports "packet filter not installed" or
|
|
"cannot create the ts-input chain", that line is what to check:
|
|
|
|
```bash
|
|
lsmod | grep -E 'iptable|ip6table|nf_nat|nf_tables'
|
|
docker exec tailscaled iptables -t nat -S | grep ts-postrouting
|
|
```
|
|
|
|
Note the same caveat as the Gitea runner: the auth key ends up in plaintext in
|
|
the rendered Ignition snippet under `/var/lib/vz/snippets` on goose.
|
|
|
|
## 7. Commit
|
|
|
|
Nothing is committed. The `opentofu` submodule has the module changes; commit
|
|
and push it first, then commit the parent repo (which records the new
|
|
submodule pointer plus `providers.tf`, `vars.tf`, `main.tf`,
|
|
`terraform.tfvars`, `secrets.tmpl`, `butane/`, and this file).
|