6b7245e6
feat: a tenant publishes a VM's port on its host
a73x 2026-08-06 09:12
Commit message
docs/assumptions.md
| Old | New | ||
|---|---|---|---|
| @@ -347,3 +347,20 @@ roll that would exercise it has not happened. The failure it guards against is | |||
| 347 | severe and quiet — refusing would read every live guest as lost and boot a | 347 | severe and quiet — refusing would read every live guest as lost and boot a |
| 348 | second hypervisor onto its disk — so the first roll should watch for a VM going | 348 | second hypervisor onto its disk — so the first roll should watch for a VM going |
| 349 | from running to creating rather than trusting the tests. | 349 | from running to creating rather than trusting the tests. |
| 350 | |||
| 351 | ### 30000–32767 belongs to eitri on every host | ||
| 352 | |||
| 353 | Host ports for published guest ports are allocated from this range, and nothing | ||
| 354 | else on the host is assumed to want it. Underpins allocating a port without | ||
| 355 | asking the host what is free. | ||
| 356 | **Proven** in code: a bind that loses the port is reported failed with the OS | ||
| 357 | error and retried on every converge. | ||
| 358 | |||
| 359 | ### A published port may be bound on every interface | ||
| 360 | |||
| 361 | The listener binds 0.0.0.0—every interface, IPv4 only. An exposure is | ||
| 362 | reachable on any IPv4 address the host has, and on none of its IPv6 ones. | ||
| 363 | Underpins there being one exposure rather than one per network. | ||
| 364 | **Proven** in code; the posture is deliberate—see the auth answer in | ||
| 365 | [faq.md](faq.md). A host whose networks are not equally trusted gets more | ||
| 366 | reach than it asked for. | ||
docs/faq.md
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,18 @@ install [Tailscale](https://tailscale.com) (or WireGuard) inside it—it is | |||
| 12 | a normal Linux machine. A public gateway is on the | 12 | a normal Linux machine. A public gateway is on the |
| 13 | [roadmap](../ROADMAP.md). | 13 | [roadmap](../ROADMAP.md). |
| 14 | 14 | ||
| 15 | ## Is an exposed port authenticated? | ||
| 16 | |||
| 17 | No. The SSH [jump gate](ssh-access.md) authenticates every connection against | ||
| 18 | your tenant's CA. A published port has nothing in front of it: whoever can | ||
| 19 | reach the host on that port reaches the service, exactly as if the service were | ||
| 20 | running on the host itself. | ||
| 21 | |||
| 22 | That is the LAN trust posture, deliberately. The listener binds every interface | ||
| 23 | on the host, IPv4 only. Publish what you are content to serve to everything | ||
| 24 | that can reach it; put anything else behind the gate, or behind the service's | ||
| 25 | own authentication. | ||
| 26 | |||
| 15 | ## VMs boot and SSH works, but have no outbound network—why? | 27 | ## VMs boot and SSH works, but have no outbound network—why? |
| 16 | 28 | ||
| 17 | Docker. Installing (or starting) Docker on a host sets the kernel's iptables | 29 | Docker. Installing (or starting) Docker on a host sets the kernel's iptables |
docs/openapi.json
| Old | New | ||
|---|---|---|---|
| @@ -103,6 +103,17 @@ | |||
| 103 | ], | 103 | ], |
| 104 | "type": "object" | 104 | "type": "object" |
| 105 | }, | 105 | }, |
| 106 | "CreateExposureRequest": { | ||
| 107 | "properties": { | ||
| 108 | "guest_port": { | ||
| 109 | "type": "integer" | ||
| 110 | }, | ||
| 111 | "host_port": { | ||
| 112 | "type": "integer" | ||
| 113 | } | ||
| 114 | }, | ||
| 115 | "type": "object" | ||
| 116 | }, | ||
| 106 | "CreateVMRequest": { | 117 | "CreateVMRequest": { |
| 107 | "properties": { | 118 | "properties": { |
| 108 | "cloud_init": { | 119 | "cloud_init": { |
| @@ -220,6 +231,58 @@ | |||
| 220 | ], | 231 | ], |
| 221 | "type": "object" | 232 | "type": "object" |
| 222 | }, | 233 | }, |
| 234 | "Exposure": { | ||
| 235 | "properties": { | ||
| 236 | "created_at": { | ||
| 237 | "format": "date-time", | ||
| 238 | "type": "string" | ||
| 239 | }, | ||
| 240 | "guest_port": { | ||
| 241 | "type": "integer" | ||
| 242 | }, | ||
| 243 | "host_addr": { | ||
| 244 | "type": "string" | ||
| 245 | }, | ||
| 246 | "host_id": { | ||
| 247 | "type": "string" | ||
| 248 | }, | ||
| 249 | "host_port": { | ||
| 250 | "type": "integer" | ||
| 251 | }, | ||
| 252 | "id": { | ||
| 253 | "type": "string" | ||
| 254 | }, | ||
| 255 | "protocol": { | ||
| 256 | "type": "string" | ||
| 257 | }, | ||
| 258 | "reason": { | ||
| 259 | "type": "string" | ||
| 260 | }, | ||
| 261 | "scope": { | ||
| 262 | "type": "string" | ||
| 263 | }, | ||
| 264 | "state": { | ||
| 265 | "type": "string" | ||
| 266 | }, | ||
| 267 | "vm_id": { | ||
| 268 | "type": "string" | ||
| 269 | } | ||
| 270 | }, | ||
| 271 | "required": [ | ||
| 272 | "created_at", | ||
| 273 | "guest_port", | ||
| 274 | "host_addr", | ||
| 275 | "host_id", | ||
| 276 | "host_port", | ||
| 277 | "id", | ||
| 278 | "protocol", | ||
| 279 | "reason", | ||
| 280 | "scope", | ||
| 281 | "state", | ||
| 282 | "vm_id" | ||
| 283 | ], | ||
| 284 | "type": "object" | ||
| 285 | }, | ||
| 223 | "Host": { | 286 | "Host": { |
| 224 | "properties": { | 287 | "properties": { |
| 225 | "agent_update_available": { | 288 | "agent_update_available": { |
| @@ -306,6 +369,9 @@ | |||
| 306 | "status": { | 369 | "status": { |
| 307 | "type": "string" | 370 | "type": "string" |
| 308 | }, | 371 | }, |
| 372 | "uplink_addr": { | ||
| 373 | "type": "string" | ||
| 374 | }, | ||
| 309 | "virt": { | 375 | "virt": { |
| 310 | "type": "string" | 376 | "type": "string" |
| 311 | } | 377 | } |
| @@ -331,6 +397,7 @@ | |||
| 331 | "sessions", | 397 | "sessions", |
| 332 | "stale", | 398 | "stale", |
| 333 | "status", | 399 | "status", |
| 400 | "uplink_addr", | ||
| 334 | "virt" | 401 | "virt" |
| 335 | ], | 402 | ], |
| 336 | "type": "object" | 403 | "type": "object" |
| @@ -802,6 +869,41 @@ | |||
| 802 | "summary": "Live fleet state stream (Server-Sent Events); each 'state' event carries a StateSnapshot." | 869 | "summary": "Live fleet state stream (Server-Sent Events); each 'state' event carries a StateSnapshot." |
| 803 | } | 870 | } |
| 804 | }, | 871 | }, |
| 872 | "/api/v1/exposures/{id}": { | ||
| 873 | "delete": { | ||
| 874 | "parameters": [ | ||
| 875 | { | ||
| 876 | "in": "path", | ||
| 877 | "name": "id", | ||
| 878 | "required": true, | ||
| 879 | "schema": { | ||
| 880 | "type": "string" | ||
| 881 | } | ||
| 882 | } | ||
| 883 | ], | ||
| 884 | "responses": { | ||
| 885 | "204": { | ||
| 886 | "description": "success" | ||
| 887 | }, | ||
| 888 | "default": { | ||
| 889 | "content": { | ||
| 890 | "text/plain": { | ||
| 891 | "schema": { | ||
| 892 | "type": "string" | ||
| 893 | } | ||
| 894 | } | ||
| 895 | }, | ||
| 896 | "description": "error (plain text)" | ||
| 897 | } | ||
| 898 | }, | ||
| 899 | "security": [ | ||
| 900 | { | ||
| 901 | "patToken": [] | ||
| 902 | } | ||
| 903 | ], | ||
| 904 | "summary": "Revoke an exposure; its host closes the listener on the next converge." | ||
| 905 | } | ||
| 906 | }, | ||
| 805 | "/api/v1/hosts": { | 907 | "/api/v1/hosts": { |
| 806 | "get": { | 908 | "get": { |
| 807 | "responses": { | 909 | "responses": { |
| @@ -1681,6 +1783,101 @@ | |||
| 1681 | "summary": "One VM's lifecycle timeline (audit rows carrying its vm_id), newest first; survives the VM row being reaped." | 1783 | "summary": "One VM's lifecycle timeline (audit rows carrying its vm_id), newest first; survives the VM row being reaped." |
| 1682 | } | 1784 | } |
| 1683 | }, | 1785 | }, |
| 1786 | "/api/v1/vms/{id}/exposures": { | ||
| 1787 | "get": { | ||
| 1788 | "parameters": [ | ||
| 1789 | { | ||
| 1790 | "in": "path", | ||
| 1791 | "name": "id", | ||
| 1792 | "required": true, | ||
| 1793 | "schema": { | ||
| 1794 | "type": "string" | ||
| 1795 | } | ||
| 1796 | } | ||
| 1797 | ], | ||
| 1798 | "responses": { | ||
| 1799 | "200": { | ||
| 1800 | "content": { | ||
| 1801 | "application/json": { | ||
| 1802 | "schema": { | ||
| 1803 | "items": { | ||
| 1804 | "$ref": "#/components/schemas/Exposure" | ||
| 1805 | }, | ||
| 1806 | "type": "array" | ||
| 1807 | } | ||
| 1808 | } | ||
| 1809 | }, | ||
| 1810 | "description": "success" | ||
| 1811 | }, | ||
| 1812 | "default": { | ||
| 1813 | "content": { | ||
| 1814 | "text/plain": { | ||
| 1815 | "schema": { | ||
| 1816 | "type": "string" | ||
| 1817 | } | ||
| 1818 | } | ||
| 1819 | }, | ||
| 1820 | "description": "error (plain text)" | ||
| 1821 | } | ||
| 1822 | }, | ||
| 1823 | "security": [ | ||
| 1824 | { | ||
| 1825 | "patToken": [] | ||
| 1826 | } | ||
| 1827 | ], | ||
| 1828 | "summary": "List the VM's published ports, with the host address to dial and each listener's reported state." | ||
| 1829 | }, | ||
| 1830 | "post": { | ||
| 1831 | "parameters": [ | ||
| 1832 | { | ||
| 1833 | "in": "path", | ||
| 1834 | "name": "id", | ||
| 1835 | "required": true, | ||
| 1836 | "schema": { | ||
| 1837 | "type": "string" | ||
| 1838 | } | ||
| 1839 | } | ||
| 1840 | ], | ||
| 1841 | "requestBody": { | ||
| 1842 | "content": { | ||
| 1843 | "application/json": { | ||
| 1844 | "schema": { | ||
| 1845 | "$ref": "#/components/schemas/CreateExposureRequest" | ||
| 1846 | } | ||
| 1847 | } | ||
| 1848 | }, | ||
| 1849 | "required": true | ||
| 1850 | }, | ||
| 1851 | "responses": { | ||
| 1852 | "201": { | ||
| 1853 | "content": { | ||
| 1854 | "application/json": { | ||
| 1855 | "schema": { | ||
| 1856 | "$ref": "#/components/schemas/Exposure" | ||
| 1857 | } | ||
| 1858 | } | ||
| 1859 | }, | ||
| 1860 | "description": "success" | ||
| 1861 | }, | ||
| 1862 | "default": { | ||
| 1863 | "content": { | ||
| 1864 | "text/plain": { | ||
| 1865 | "schema": { | ||
| 1866 | "type": "string" | ||
| 1867 | } | ||
| 1868 | } | ||
| 1869 | }, | ||
| 1870 | "description": "error (plain text)" | ||
| 1871 | } | ||
| 1872 | }, | ||
| 1873 | "security": [ | ||
| 1874 | { | ||
| 1875 | "patToken": [] | ||
| 1876 | } | ||
| 1877 | ], | ||
| 1878 | "summary": "Publish a guest TCP port on the VM's host. Omit host_port to allocate one from the reserved range 30000-32767; a named port must be \u003e= 1024 and is honored or refused." | ||
| 1879 | } | ||
| 1880 | }, | ||
| 1684 | "/api/v1/vms/{id}/restore": { | 1881 | "/api/v1/vms/{id}/restore": { |
| 1685 | "post": { | 1882 | "post": { |
| 1686 | "parameters": [ | 1883 | "parameters": [ |
docs/quickstart.md
| Old | New | ||
|---|---|---|---|
| @@ -135,6 +135,29 @@ eitri ssh <vm-name> | |||
| 135 | eitri ssh <vm-name> uptime | 135 | eitri ssh <vm-name> uptime |
| 136 | ``` | 136 | ``` |
| 137 | 137 | ||
| 138 | ### Expose a service | ||
| 139 | |||
| 140 | The gate reaches a guest over SSH. Anything else a guest serves needs a | ||
| 141 | published port: the fleet binds one on the VM's host and pipes it to the guest. | ||
| 142 | |||
| 143 | Open the VM's page in the console, find **Exposures**, and enter the port your | ||
| 144 | service listens on inside the guest—8080, say. Leave the host port blank and | ||
| 145 | eitri allocates one from 30000–32767, the range it reserves on every host. The | ||
| 146 | row then reads: | ||
| 147 | |||
| 148 | guest :8080 → 192.168.0.190:30080 ● active | ||
| 149 | |||
| 150 | That address is the host's. Anything that can reach the host on that port | ||
| 151 | reaches the service—there is no authentication in front of a published port, so | ||
| 152 | publish what you are content to serve to everything on that network, and leave | ||
| 153 | the rest to the gate. | ||
| 154 | |||
| 155 | `active` means the host's listener is bound. Whether anything answers on the | ||
| 156 | guest's 8080 is the guest's business. | ||
| 157 | |||
| 158 | Remove the row to take the port down. Deleting the VM takes its exposures with | ||
| 159 | it. | ||
| 160 | |||
| 138 | ## Self-hosting | 161 | ## Self-hosting |
| 139 | 162 | ||
| 140 | Run the whole control plane yourself—everything below is your own box. | 163 | Run the whole control plane yourself—everything below is your own box. |
docs/shape.html
| Old | New | ||
|---|---|---|---|
| @@ -172,6 +172,14 @@ | |||
| 172 | "imports": [] | 172 | "imports": [] |
| 173 | }, | 173 | }, |
| 174 | { | 174 | { |
| 175 | "importPath": "internal/agent/exposeproxy", | ||
| 176 | "plane": "data", | ||
| 177 | "synopsis": "Package exposeproxy publishes guest ports on their host.", | ||
| 178 | "imports": [ | ||
| 179 | "internal/pb" | ||
| 180 | ] | ||
| 181 | }, | ||
| 182 | { | ||
| 175 | "importPath": "internal/agent/hostinfo", | 183 | "importPath": "internal/agent/hostinfo", |
| 176 | "plane": "data", | 184 | "plane": "data", |
| 177 | "synopsis": "Package hostinfo gathers best-effort facts and live metrics about the fleet host the agent runs on.", | 185 | "synopsis": "Package hostinfo gathers best-effort facts and live metrics about the fleet host the agent runs on.", |
| @@ -232,6 +240,7 @@ | |||
| 232 | "internal/agent/bootstrap", | 240 | "internal/agent/bootstrap", |
| 233 | "internal/agent/cloudhv", | 241 | "internal/agent/cloudhv", |
| 234 | "internal/agent/enrollclient", | 242 | "internal/agent/enrollclient", |
| 243 | "internal/agent/exposeproxy", | ||
| 235 | "internal/agent/hostinfo", | 244 | "internal/agent/hostinfo", |
| 236 | "internal/agent/imagecache", | 245 | "internal/agent/imagecache", |
| 237 | "internal/agent/netenv", | 246 | "internal/agent/netenv", |
docs/shape.json
| Old | New | ||
|---|---|---|---|
| @@ -121,6 +121,14 @@ | |||
| 121 | "imports": [] | 121 | "imports": [] |
| 122 | }, | 122 | }, |
| 123 | { | 123 | { |
| 124 | "importPath": "internal/agent/exposeproxy", | ||
| 125 | "plane": "data", | ||
| 126 | "synopsis": "Package exposeproxy publishes guest ports on their host.", | ||
| 127 | "imports": [ | ||
| 128 | "internal/pb" | ||
| 129 | ] | ||
| 130 | }, | ||
| 131 | { | ||
| 124 | "importPath": "internal/agent/hostinfo", | 132 | "importPath": "internal/agent/hostinfo", |
| 125 | "plane": "data", | 133 | "plane": "data", |
| 126 | "synopsis": "Package hostinfo gathers best-effort facts and live metrics about the fleet host the agent runs on.", | 134 | "synopsis": "Package hostinfo gathers best-effort facts and live metrics about the fleet host the agent runs on.", |
| @@ -181,6 +189,7 @@ | |||
| 181 | "internal/agent/bootstrap", | 189 | "internal/agent/bootstrap", |
| 182 | "internal/agent/cloudhv", | 190 | "internal/agent/cloudhv", |
| 183 | "internal/agent/enrollclient", | 191 | "internal/agent/enrollclient", |
| 192 | "internal/agent/exposeproxy", | ||
| 184 | "internal/agent/hostinfo", | 193 | "internal/agent/hostinfo", |
| 185 | "internal/agent/imagecache", | 194 | "internal/agent/imagecache", |
| 186 | "internal/agent/netenv", | 195 | "internal/agent/netenv", |
internal/agent/exposeproxy/exposeproxy.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,205 @@ | |||
| 1 | // Package exposeproxy publishes guest ports on their host. A Manager owns one | ||
| 2 | // listener and one goroutine per exposure id — the same manager shape as the | ||
| 3 | // serial pumps — and Converge drives that set toward the exposures the fleet | ||
| 4 | // says this host should be serving: a new id binds, a vanished id closes, a | ||
| 5 | // changed spec closes and rebinds. | ||
| 6 | // | ||
| 7 | // The listener binds 0.0.0.0. All interfaces is the point: the LAN address is | ||
| 8 | // the feature, the tailnet address is how an operator reaches it from | ||
| 9 | // elsewhere, and the guest-side bridge seeing it is the same trust domain the | ||
| 10 | // LAN posture already accepts. There is no authentication in front of a | ||
| 11 | // published port — reaching the host is reaching the service. | ||
| 12 | // | ||
| 13 | // Each accepted connection asks for the VM's address AT THAT MOMENT and dials | ||
| 14 | // the guest fresh. That per-connection lookup is what makes an exposure created | ||
| 15 | // before its guest has booted simply work: the listener binds now, and | ||
| 16 | // connections start succeeding when the guest does. | ||
| 17 | // | ||
| 18 | // Nothing here is persisted. Listeners are rebuilt from the first snapshot | ||
| 19 | // after the agent starts, the same way the consoles are; connections in flight | ||
| 20 | // across an agent restart drop, and reconnecting works. | ||
| 21 | package exposeproxy | ||
| 22 | |||
| 23 | import ( | ||
| 24 | "fmt" | ||
| 25 | "io" | ||
| 26 | "log/slog" | ||
| 27 | "net" | ||
| 28 | "strconv" | ||
| 29 | "sync" | ||
| 30 | |||
| 31 | "github.com/a73x/eitri/internal/pb" | ||
| 32 | ) | ||
| 33 | |||
| 34 | // Manager runs one listener per active exposure. | ||
| 35 | type Manager struct { | ||
| 36 | // addr answers a VM's current guest address, or "" when this host does not | ||
| 37 | // know one. Injected so the package stays a leaf: it never learns what a VM | ||
| 38 | // is, only where one is right now. It is called concurrently from connection | ||
| 39 | // goroutines; it must be safe for concurrent use. | ||
| 40 | addr func(vmID string) string | ||
| 41 | |||
| 42 | mu sync.Mutex | ||
| 43 | live map[string]*exposure | ||
| 44 | } | ||
| 45 | |||
| 46 | // NewManager returns a Manager that resolves each connection's destination | ||
| 47 | // through addr. | ||
| 48 | func NewManager(addr func(vmID string) string) *Manager { | ||
| 49 | return &Manager{addr: addr, live: map[string]*exposure{}} | ||
| 50 | } | ||
| 51 | |||
| 52 | // exposure is one published port's running state: the spec key it was bound | ||
| 53 | // for, its listener (nil when the bind failed), and what the OS said if it did. | ||
| 54 | type exposure struct { | ||
| 55 | key string | ||
| 56 | ln net.Listener | ||
| 57 | reason string | ||
| 58 | } | ||
| 59 | |||
| 60 | // close tears down the listener, which is what ends its accept goroutine. | ||
| 61 | func (e *exposure) close() { | ||
| 62 | if e.ln != nil { | ||
| 63 | e.ln.Close() | ||
| 64 | e.ln = nil | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | // specKey renders everything about a desired exposure that a bound listener | ||
| 69 | // depends on. A change to any of it is a close and a rebind rather than an | ||
| 70 | // edit — the listener's own address is part of what changed. | ||
| 71 | func specKey(d *pb.ExposureDesired) string { | ||
| 72 | return fmt.Sprintf("%s/%d/%d/%s", d.GetVmId(), d.GetGuestPort(), d.GetHostPort(), d.GetProtocol()) | ||
| 73 | } | ||
| 74 | |||
| 75 | // Converge drives the running listeners toward desired and reports what each | ||
| 76 | // exposure is doing, in desired order. It is level-triggered: every call | ||
| 77 | // re-examines the whole set, so an exposure whose listener is gone — a failed | ||
| 78 | // bind, or an accept loop that died — is retried here and heals the moment the | ||
| 79 | // port frees. | ||
| 80 | // | ||
| 81 | // "active" means the listener is bound. Eitri owns the host half of the pipe; | ||
| 82 | // whether anything answers inside the guest is the guest's half, and this does | ||
| 83 | // not pretend otherwise. | ||
| 84 | func (m *Manager) Converge(desired []*pb.ExposureDesired) []*pb.ExposureActual { | ||
| 85 | m.mu.Lock() | ||
| 86 | defer m.mu.Unlock() | ||
| 87 | |||
| 88 | want := make(map[string]*pb.ExposureDesired, len(desired)) | ||
| 89 | for _, d := range desired { | ||
| 90 | want[d.GetId()] = d | ||
| 91 | } | ||
| 92 | for id, ex := range m.live { | ||
| 93 | if d, ok := want[id]; ok && ex.key == specKey(d) { | ||
| 94 | continue | ||
| 95 | } | ||
| 96 | ex.close() | ||
| 97 | delete(m.live, id) | ||
| 98 | } | ||
| 99 | |||
| 100 | out := make([]*pb.ExposureActual, 0, len(desired)) | ||
| 101 | for _, d := range desired { | ||
| 102 | ex, ok := m.live[d.GetId()] | ||
| 103 | if !ok { | ||
| 104 | ex = &exposure{key: specKey(d)} | ||
| 105 | m.live[d.GetId()] = ex | ||
| 106 | } | ||
| 107 | if ex.ln == nil { | ||
| 108 | ln, err := net.Listen("tcp", net.JoinHostPort("0.0.0.0", strconv.Itoa(int(d.GetHostPort())))) | ||
| 109 | if err != nil { | ||
| 110 | ex.reason = err.Error() | ||
| 111 | out = append(out, &pb.ExposureActual{Id: d.GetId(), State: "failed", Reason: ex.reason}) | ||
| 112 | continue | ||
| 113 | } | ||
| 114 | ex.ln, ex.reason = ln, "" | ||
| 115 | go m.accept(d.GetId(), ln, d.GetVmId(), d.GetGuestPort()) | ||
| 116 | } | ||
| 117 | out = append(out, &pb.ExposureActual{Id: d.GetId(), State: "active"}) | ||
| 118 | } | ||
| 119 | return out | ||
| 120 | } | ||
| 121 | |||
| 122 | // StopAll closes every listener (agent shutdown, tests). | ||
| 123 | func (m *Manager) StopAll() { | ||
| 124 | m.mu.Lock() | ||
| 125 | defer m.mu.Unlock() | ||
| 126 | for id, ex := range m.live { | ||
| 127 | ex.close() | ||
| 128 | delete(m.live, id) | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | // accept serves one exposure's listener until it ends: a converge dropped it, | ||
| 133 | // StopAll closed it, or the listener itself broke. That third exit is why this | ||
| 134 | // gives the exposure back its listener-less state instead of just returning — | ||
| 135 | // an accept loop that died leaves a port bound that nothing is serving, and a | ||
| 136 | // caller would hang in the backlog while Converge went on reporting "active". | ||
| 137 | // Releasing the listener and recording why makes the next converge rebind it. | ||
| 138 | // | ||
| 139 | // The pointer-identity guard is what keeps the normal exits quiet: on a | ||
| 140 | // close-driven exit the entry is already gone, already nil, or already holds a | ||
| 141 | // newer listener, so only the loop that owns the current listener writes here. | ||
| 142 | func (m *Manager) accept(id string, ln net.Listener, vmID string, guestPort uint32) { | ||
| 143 | for { | ||
| 144 | conn, err := ln.Accept() | ||
| 145 | if err != nil { | ||
| 146 | m.mu.Lock() | ||
| 147 | if ex, ok := m.live[id]; ok && ex.ln == ln { | ||
| 148 | ex.close() | ||
| 149 | ex.reason = "accept: " + err.Error() | ||
| 150 | // The reason travels in the next report, which nobody watching | ||
| 151 | // the host sees; fd exhaustion and a broken listener belong in | ||
| 152 | // the agent's log too. | ||
| 153 | slog.Warn("exposure accept loop died", "exposure", id, "err", err) | ||
| 154 | } | ||
| 155 | m.mu.Unlock() | ||
| 156 | return | ||
| 157 | } | ||
| 158 | go m.pipe(conn, vmID, guestPort) | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | // pipe connects one accepted connection to the guest. No address (a guest still | ||
| 163 | // leasing) or a guest that will not answer closes immediately: the host half of | ||
| 164 | // the pipe exists, the guest half does not, and waiting would only hold the | ||
| 165 | // caller open on a promise nothing is keeping. | ||
| 166 | func (m *Manager) pipe(client net.Conn, vmID string, guestPort uint32) { | ||
| 167 | ip := m.addr(vmID) | ||
| 168 | if ip == "" { | ||
| 169 | client.Close() | ||
| 170 | return | ||
| 171 | } | ||
| 172 | guest, err := net.Dial("tcp", net.JoinHostPort(ip, strconv.Itoa(int(guestPort)))) | ||
| 173 | if err != nil { | ||
| 174 | client.Close() | ||
| 175 | return | ||
| 176 | } | ||
| 177 | splice(client, guest) | ||
| 178 | } | ||
| 179 | |||
| 180 | // splice copies in both directions until each ends, closing both connections | ||
| 181 | // once they have. | ||
| 182 | func splice(a, b net.Conn) { | ||
| 183 | defer a.Close() | ||
| 184 | defer b.Close() | ||
| 185 | var wg sync.WaitGroup | ||
| 186 | wg.Add(2) | ||
| 187 | go func() { defer wg.Done(); copyThenCloseWrite(b, a) }() | ||
| 188 | go func() { defer wg.Done(); copyThenCloseWrite(a, b) }() | ||
| 189 | wg.Wait() | ||
| 190 | } | ||
| 191 | |||
| 192 | // copyThenCloseWrite copies src into dst and then shuts down dst's WRITE half | ||
| 193 | // only, so the peer sees a clean EOF while the opposite direction keeps | ||
| 194 | // flowing. Half-close is what makes a protocol that signals end-of-request by | ||
| 195 | // closing its write side work through the proxy instead of hanging. A | ||
| 196 | // connection with no half-close is closed outright — consumer-side interface, | ||
| 197 | // so nothing here depends on the concrete net type. | ||
| 198 | func copyThenCloseWrite(dst, src net.Conn) { | ||
| 199 | _, _ = io.Copy(dst, src) | ||
| 200 | if cw, ok := dst.(interface{ CloseWrite() error }); ok { | ||
| 201 | _ = cw.CloseWrite() | ||
| 202 | return | ||
| 203 | } | ||
| 204 | _ = dst.Close() | ||
| 205 | } | ||
internal/agent/exposeproxy/exposeproxy_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,335 @@ | |||
| 1 | package exposeproxy | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "io" | ||
| 5 | "net" | ||
| 6 | "strconv" | ||
| 7 | "testing" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "github.com/a73x/eitri/internal/pb" | ||
| 11 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // fakeGuest is a loopback listener standing in for a service inside a guest. | ||
| 16 | // It echoes everything it reads back, prefixed, so a test can prove bytes went | ||
| 17 | // both ways through the proxy. | ||
| 18 | type fakeGuest struct { | ||
| 19 | ln net.Listener | ||
| 20 | addr string | ||
| 21 | port uint32 | ||
| 22 | } | ||
| 23 | |||
| 24 | func newFakeGuest(t *testing.T) *fakeGuest { | ||
| 25 | t.Helper() | ||
| 26 | ln, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 27 | require.NoError(t, err) | ||
| 28 | t.Cleanup(func() { ln.Close() }) | ||
| 29 | g := &fakeGuest{ln: ln, addr: "127.0.0.1", port: portOf(t, ln.Addr())} | ||
| 30 | go func() { | ||
| 31 | for { | ||
| 32 | c, err := ln.Accept() | ||
| 33 | if err != nil { | ||
| 34 | return | ||
| 35 | } | ||
| 36 | go func() { | ||
| 37 | defer c.Close() | ||
| 38 | buf := make([]byte, 64) | ||
| 39 | n, err := c.Read(buf) | ||
| 40 | if n > 0 { | ||
| 41 | c.Write(append([]byte("echo:"), buf[:n]...)) //nolint:errcheck | ||
| 42 | } | ||
| 43 | if err != nil { | ||
| 44 | return | ||
| 45 | } | ||
| 46 | }() | ||
| 47 | } | ||
| 48 | }() | ||
| 49 | return g | ||
| 50 | } | ||
| 51 | |||
| 52 | func portOf(t *testing.T, a net.Addr) uint32 { | ||
| 53 | t.Helper() | ||
| 54 | ta, ok := a.(*net.TCPAddr) | ||
| 55 | require.True(t, ok) | ||
| 56 | return uint32(ta.Port) | ||
| 57 | } | ||
| 58 | |||
| 59 | // newTestManager builds a Manager whose address answer is a fixed map. | ||
| 60 | func newTestManager(t *testing.T, addrs map[string]string) *Manager { | ||
| 61 | t.Helper() | ||
| 62 | m := NewManager(func(vmID string) string { return addrs[vmID] }) | ||
| 63 | t.Cleanup(m.StopAll) | ||
| 64 | return m | ||
| 65 | } | ||
| 66 | |||
| 67 | // boundPort reads the port the manager's listener for id actually bound. Host | ||
| 68 | // port 0 in a desired spec lets the OS pick, which is how these tests avoid | ||
| 69 | // racing for a fixed number. | ||
| 70 | func boundPort(t *testing.T, m *Manager, id string) string { | ||
| 71 | t.Helper() | ||
| 72 | m.mu.Lock() | ||
| 73 | defer m.mu.Unlock() | ||
| 74 | ex, ok := m.live[id] | ||
| 75 | require.True(t, ok, "no listener for %q", id) | ||
| 76 | require.NotNil(t, ex.ln, "listener for %q is not bound", id) | ||
| 77 | return ex.ln.Addr().String() | ||
| 78 | } | ||
| 79 | |||
| 80 | func desired(id, vmID string, guestPort, hostPort uint32) *pb.ExposureDesired { | ||
| 81 | return &pb.ExposureDesired{Id: id, VmId: vmID, GuestPort: guestPort, HostPort: hostPort, Protocol: "tcp"} | ||
| 82 | } | ||
| 83 | |||
| 84 | // speak dials addr, sends msg, and returns what came back. | ||
| 85 | func speak(t *testing.T, addr, msg string) string { | ||
| 86 | t.Helper() | ||
| 87 | c, err := net.Dial("tcp", addr) | ||
| 88 | require.NoError(t, err) | ||
| 89 | defer c.Close() | ||
| 90 | require.NoError(t, c.SetDeadline(time.Now().Add(5*time.Second))) | ||
| 91 | _, err = c.Write([]byte(msg)) | ||
| 92 | require.NoError(t, err) | ||
| 93 | out, err := io.ReadAll(c) | ||
| 94 | require.NoError(t, err) | ||
| 95 | return string(out) | ||
| 96 | } | ||
| 97 | |||
| 98 | func TestConvergeBindsAndPipesToTheGuest(t *testing.T) { | ||
| 99 | g := newFakeGuest(t) | ||
| 100 | m := newTestManager(t, map[string]string{"vm1": g.addr}) | ||
| 101 | |||
| 102 | got := m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", g.port, 0)}) | ||
| 103 | require.Len(t, got, 1) | ||
| 104 | assert.Equal(t, "e1", got[0].GetId()) | ||
| 105 | assert.Equal(t, "active", got[0].GetState()) | ||
| 106 | |||
| 107 | assert.Equal(t, "echo:hello", speak(t, boundPort(t, m, "e1"), "hello")) | ||
| 108 | } | ||
| 109 | |||
| 110 | func TestConvergeIsIdempotent(t *testing.T) { | ||
| 111 | g := newFakeGuest(t) | ||
| 112 | m := newTestManager(t, map[string]string{"vm1": g.addr}) | ||
| 113 | |||
| 114 | m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", g.port, 0)}) | ||
| 115 | addr := boundPort(t, m, "e1") | ||
| 116 | m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", g.port, 0)}) | ||
| 117 | |||
| 118 | assert.Equal(t, addr, boundPort(t, m, "e1"), "an unchanged spec keeps its listener") | ||
| 119 | assert.Equal(t, "echo:hi", speak(t, addr, "hi")) | ||
| 120 | } | ||
| 121 | |||
| 122 | func TestConvergeClosesAVanishedExposure(t *testing.T) { | ||
| 123 | g := newFakeGuest(t) | ||
| 124 | m := newTestManager(t, map[string]string{"vm1": g.addr}) | ||
| 125 | m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", g.port, 0)}) | ||
| 126 | addr := boundPort(t, m, "e1") | ||
| 127 | |||
| 128 | got := m.Converge(nil) | ||
| 129 | assert.Empty(t, got) | ||
| 130 | |||
| 131 | _, err := net.Dial("tcp", addr) | ||
| 132 | assert.Error(t, err, "a revoked exposure stops answering") | ||
| 133 | } | ||
| 134 | |||
| 135 | func TestConvergeRebindsAChangedSpec(t *testing.T) { | ||
| 136 | first := newFakeGuest(t) | ||
| 137 | second := newFakeGuest(t) | ||
| 138 | m := newTestManager(t, map[string]string{"vm1": first.addr}) | ||
| 139 | |||
| 140 | m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", first.port, 0)}) | ||
| 141 | oldAddr := boundPort(t, m, "e1") | ||
| 142 | |||
| 143 | m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", second.port, 0)}) | ||
| 144 | newAddr := boundPort(t, m, "e1") | ||
| 145 | assert.NotEqual(t, oldAddr, newAddr, "a changed spec is a close and a rebind") | ||
| 146 | |||
| 147 | // The new listener reaches the new guest port. | ||
| 148 | assert.Equal(t, "echo:x", speak(t, newAddr, "x")) | ||
| 149 | } | ||
| 150 | |||
| 151 | func TestConvergeReportsABindFailureAndHealsWhenThePortFrees(t *testing.T) { | ||
| 152 | g := newFakeGuest(t) | ||
| 153 | m := newTestManager(t, map[string]string{"vm1": g.addr}) | ||
| 154 | |||
| 155 | // Squat on a host port, then ask for exactly it. | ||
| 156 | squatter, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 157 | require.NoError(t, err) | ||
| 158 | held := portOf(t, squatter.Addr()) | ||
| 159 | |||
| 160 | got := m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", g.port, held)}) | ||
| 161 | require.Len(t, got, 1) | ||
| 162 | assert.Equal(t, "failed", got[0].GetState()) | ||
| 163 | assert.NotEmpty(t, got[0].GetReason(), "the report carries what the OS said") | ||
| 164 | |||
| 165 | // The squatter exits; the very next converge binds. A bind-time refusal is | ||
| 166 | // not an error path, it is a level to converge on. | ||
| 167 | require.NoError(t, squatter.Close()) | ||
| 168 | var state string | ||
| 169 | for i := 0; i < 20; i++ { | ||
| 170 | got = m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", g.port, held)}) | ||
| 171 | state = got[0].GetState() | ||
| 172 | if state == "active" { | ||
| 173 | break | ||
| 174 | } | ||
| 175 | time.Sleep(50 * time.Millisecond) | ||
| 176 | } | ||
| 177 | assert.Equal(t, "active", state) | ||
| 178 | assert.Equal(t, "echo:back", speak(t, net.JoinHostPort("127.0.0.1", strconv.Itoa(int(held))), "back")) | ||
| 179 | } | ||
| 180 | |||
| 181 | func TestConvergeRebindsAListenerWhoseAcceptLoopDied(t *testing.T) { | ||
| 182 | g := newFakeGuest(t) | ||
| 183 | m := newTestManager(t, map[string]string{"vm1": g.addr}) | ||
| 184 | m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", g.port, 0)}) | ||
| 185 | |||
| 186 | // Break the listener behind the manager's back, the way fd exhaustion would | ||
| 187 | // end an accept loop: the manager never asked for this and still believes | ||
| 188 | // the exposure is served. | ||
| 189 | m.mu.Lock() | ||
| 190 | old := m.live["e1"].ln | ||
| 191 | require.NotNil(t, old) | ||
| 192 | require.NoError(t, old.Close()) | ||
| 193 | m.mu.Unlock() | ||
| 194 | |||
| 195 | // The loop hands the listener back and records why it left. Both are read | ||
| 196 | // under the lock the loop writes them under, which is what makes the reason | ||
| 197 | // deterministic rather than a sampled guess. | ||
| 198 | var reason string | ||
| 199 | for i := 0; i < 200; i++ { | ||
| 200 | m.mu.Lock() | ||
| 201 | ex := m.live["e1"] | ||
| 202 | gone, r := ex.ln == nil, ex.reason | ||
| 203 | m.mu.Unlock() | ||
| 204 | if gone { | ||
| 205 | reason = r | ||
| 206 | break | ||
| 207 | } | ||
| 208 | time.Sleep(10 * time.Millisecond) | ||
| 209 | } | ||
| 210 | assert.Contains(t, reason, "accept:", "a dead loop must not leave a listener nothing is serving") | ||
| 211 | |||
| 212 | // The bind-failure level-trigger is the whole healing mechanism: the next | ||
| 213 | // converge sees no listener and binds one. This exposure asked for host port | ||
| 214 | // 0, so that rebind cannot fail and no converge ever reports "failed" — the | ||
| 215 | // interim report is only observable when the fresh bind is also refused, | ||
| 216 | // which is the case TestConvergeReportsABindFailure... already covers. | ||
| 217 | got := m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", g.port, 0)}) | ||
| 218 | require.Len(t, got, 1) | ||
| 219 | assert.Equal(t, "active", got[0].GetState()) | ||
| 220 | |||
| 221 | m.mu.Lock() | ||
| 222 | rebound := m.live["e1"].ln | ||
| 223 | m.mu.Unlock() | ||
| 224 | // Compared as pointers, not with NotEqual: a deep-equality walk would read | ||
| 225 | // the live listener's internals while its accept loop is using them. | ||
| 226 | assert.True(t, rebound != old, "a dead listener is rebound, not believed") | ||
| 227 | assert.Equal(t, "echo:again", speak(t, boundPort(t, m, "e1"), "again")) | ||
| 228 | } | ||
| 229 | |||
| 230 | func TestConnectionClosesWhenTheGuestHasNoAddress(t *testing.T) { | ||
| 231 | m := newTestManager(t, map[string]string{}) // the guest is still leasing | ||
| 232 | m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", 8080, 0)}) | ||
| 233 | |||
| 234 | c, err := net.Dial("tcp", boundPort(t, m, "e1")) | ||
| 235 | require.NoError(t, err) | ||
| 236 | defer c.Close() | ||
| 237 | require.NoError(t, c.SetDeadline(time.Now().Add(5*time.Second))) | ||
| 238 | out, err := io.ReadAll(c) | ||
| 239 | require.NoError(t, err) | ||
| 240 | assert.Empty(t, out, "no address yet means close, not wait") | ||
| 241 | } | ||
| 242 | |||
| 243 | func TestSpliceIsHalfCloseAware(t *testing.T) { | ||
| 244 | // A guest that answers only after the client has finished speaking — the | ||
| 245 | // shape every request/response protocol over a half-closed write takes. | ||
| 246 | ln, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 247 | require.NoError(t, err) | ||
| 248 | t.Cleanup(func() { ln.Close() }) | ||
| 249 | go func() { | ||
| 250 | c, err := ln.Accept() | ||
| 251 | if err != nil { | ||
| 252 | return | ||
| 253 | } | ||
| 254 | defer c.Close() | ||
| 255 | body, _ := io.ReadAll(c) // blocks until the client's write half closes | ||
| 256 | c.Write(append([]byte("saw:"), body...)) | ||
| 257 | }() | ||
| 258 | |||
| 259 | m := newTestManager(t, map[string]string{"vm1": "127.0.0.1"}) | ||
| 260 | m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", portOf(t, ln.Addr()), 0)}) | ||
| 261 | |||
| 262 | c, err := net.Dial("tcp", boundPort(t, m, "e1")) | ||
| 263 | require.NoError(t, err) | ||
| 264 | defer c.Close() | ||
| 265 | require.NoError(t, c.SetDeadline(time.Now().Add(5*time.Second))) | ||
| 266 | _, err = c.Write([]byte("request")) | ||
| 267 | require.NoError(t, err) | ||
| 268 | require.NoError(t, c.(*net.TCPConn).CloseWrite()) | ||
| 269 | |||
| 270 | out, err := io.ReadAll(c) | ||
| 271 | require.NoError(t, err) | ||
| 272 | assert.Equal(t, "saw:request", string(out)) | ||
| 273 | } | ||
| 274 | |||
| 275 | func TestRevokeDrainsRatherThanCuts(t *testing.T) { | ||
| 276 | // A guest that echoes for as long as the client keeps speaking, so one | ||
| 277 | // connection can carry two exchanges either side of the revocation. | ||
| 278 | ln, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 279 | require.NoError(t, err) | ||
| 280 | t.Cleanup(func() { ln.Close() }) | ||
| 281 | go func() { | ||
| 282 | for { | ||
| 283 | c, err := ln.Accept() | ||
| 284 | if err != nil { | ||
| 285 | return | ||
| 286 | } | ||
| 287 | go func() { | ||
| 288 | defer c.Close() | ||
| 289 | _, _ = io.Copy(c, c) | ||
| 290 | }() | ||
| 291 | } | ||
| 292 | }() | ||
| 293 | |||
| 294 | m := newTestManager(t, map[string]string{"vm1": "127.0.0.1"}) | ||
| 295 | m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", portOf(t, ln.Addr()), 0)}) | ||
| 296 | addr := boundPort(t, m, "e1") | ||
| 297 | |||
| 298 | c, err := net.Dial("tcp", addr) | ||
| 299 | require.NoError(t, err) | ||
| 300 | defer c.Close() | ||
| 301 | require.NoError(t, c.SetDeadline(time.Now().Add(5*time.Second))) | ||
| 302 | exchange := func(msg string) string { | ||
| 303 | t.Helper() | ||
| 304 | _, err := c.Write([]byte(msg)) | ||
| 305 | require.NoError(t, err) | ||
| 306 | buf := make([]byte, len(msg)) | ||
| 307 | _, err = io.ReadFull(c, buf) | ||
| 308 | require.NoError(t, err) | ||
| 309 | return string(buf) | ||
| 310 | } | ||
| 311 | require.Equal(t, "one", exchange("one"), "the connection is spliced through before anything is revoked") | ||
| 312 | |||
| 313 | m.Converge(nil) | ||
| 314 | _, err = net.Dial("tcp", addr) | ||
| 315 | require.Error(t, err, "a revoked exposure stops answering new dials") | ||
| 316 | |||
| 317 | assert.Equal(t, "two", exchange("two"), "closing a listener drains: only new dials die") | ||
| 318 | } | ||
| 319 | |||
| 320 | func TestStopAllClosesEveryListener(t *testing.T) { | ||
| 321 | g := newFakeGuest(t) | ||
| 322 | m := NewManager(func(string) string { return g.addr }) | ||
| 323 | m.Converge([]*pb.ExposureDesired{ | ||
| 324 | desired("e1", "vm1", g.port, 0), | ||
| 325 | desired("e2", "vm1", g.port, 0), | ||
| 326 | }) | ||
| 327 | a1, a2 := boundPort(t, m, "e1"), boundPort(t, m, "e2") | ||
| 328 | |||
| 329 | m.StopAll() | ||
| 330 | |||
| 331 | for _, addr := range []string{a1, a2} { | ||
| 332 | _, err := net.Dial("tcp", addr) | ||
| 333 | assert.Error(t, err, "listener %s survived StopAll", addr) | ||
| 334 | } | ||
| 335 | } | ||
internal/agent/hostinfo/hostinfo.go
| Old | New | ||
|---|---|---|---|
| @@ -7,6 +7,8 @@ package hostinfo | |||
| 7 | import ( | 7 | import ( |
| 8 | "context" | 8 | "context" |
| 9 | "encoding/binary" | 9 | "encoding/binary" |
| 10 | "net" | ||
| 11 | "net/netip" | ||
| 10 | "os" | 12 | "os" |
| 11 | "runtime" | 13 | "runtime" |
| 12 | "strconv" | 14 | "strconv" |
| @@ -67,6 +69,45 @@ func Metrics(stateDir string) *pb.HostMetrics { return readMetrics(stateDir) } | |||
| 67 | // per-platform. | 69 | // per-platform. |
| 68 | func BootID() string { return readBootID() } | 70 | func BootID() string { return readBootID() } |
| 69 | 71 | ||
| 72 | // UplinkAddr reports the address this host presents on the network it reaches | ||
| 73 | // the control plane over — the address to dial for a port published on this | ||
| 74 | // host. serverAddr is the control plane's own host:port. | ||
| 75 | // | ||
| 76 | // It asks the kernel's routing table rather than picking an interface off a | ||
| 77 | // list: a UDP "connection" sends no packet, but it binds the socket to the | ||
| 78 | // source address the route to serverAddr would actually use, which is the one | ||
| 79 | // answer that stays right on a host with several addresses. | ||
| 80 | // | ||
| 81 | // Empty means "no answer": the route could not be resolved, or it resolves to | ||
| 82 | // an address nothing off this host can dial. The fleet then keeps whatever it | ||
| 83 | // already knows rather than recording somewhere unreachable. | ||
| 84 | func UplinkAddr(serverAddr string) string { | ||
| 85 | conn, err := net.Dial("udp", serverAddr) | ||
| 86 | if err != nil { | ||
| 87 | return "" | ||
| 88 | } | ||
| 89 | defer conn.Close() | ||
| 90 | return usableSourceAddr(conn.LocalAddr()) | ||
| 91 | } | ||
| 92 | |||
| 93 | // usableSourceAddr reduces a socket's local address to the address an operator | ||
| 94 | // could dial, or "" when it is one nothing off this host can reach. | ||
| 95 | func usableSourceAddr(a net.Addr) string { | ||
| 96 | ua, ok := a.(*net.UDPAddr) | ||
| 97 | if !ok { | ||
| 98 | return "" | ||
| 99 | } | ||
| 100 | addr, ok := netip.AddrFromSlice(ua.IP) | ||
| 101 | if !ok { | ||
| 102 | return "" | ||
| 103 | } | ||
| 104 | addr = addr.Unmap() | ||
| 105 | if addr.IsUnspecified() || addr.IsLoopback() || addr.IsLinkLocalUnicast() { | ||
| 106 | return "" | ||
| 107 | } | ||
| 108 | return addr.String() | ||
| 109 | } | ||
| 110 | |||
| 70 | // Capacity returns the host's TOTAL capacity: total disk at stateDir, total | 111 | // Capacity returns the host's TOTAL capacity: total disk at stateDir, total |
| 71 | // memory, and CPU count. The server computes allocated/available by subtracting | 112 | // memory, and CPU count. The server computes allocated/available by subtracting |
| 72 | // the sum of live VM specs, so these must be totals, not free space. | 113 | // the sum of live VM specs, so these must be totals, not free space. |
internal/agent/hostinfo/hostinfo_test.go
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,7 @@ package hostinfo | |||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | "encoding/hex" | 5 | "encoding/hex" |
| 6 | "net" | ||
| 6 | "os" | 7 | "os" |
| 7 | "path/filepath" | 8 | "path/filepath" |
| 8 | "slices" | 9 | "slices" |
| @@ -237,3 +238,38 @@ func TestBootIDMissingFile(t *testing.T) { | |||
| 237 | // TestMetricsComputes lives in hostinfo_linux_test.go: it stubs | 238 | // TestMetricsComputes lives in hostinfo_linux_test.go: it stubs |
| 238 | // sysinfoFn/statfsFn with syscall.Sysinfo_t/Statfs_t literals, which only | 239 | // sysinfoFn/statfsFn with syscall.Sysinfo_t/Statfs_t literals, which only |
| 239 | // exist on Linux. | 240 | // exist on Linux. |
| 241 | |||
| 242 | func TestUsableSourceAddr(t *testing.T) { | ||
| 243 | for _, tc := range []struct { | ||
| 244 | name string | ||
| 245 | addr net.Addr | ||
| 246 | want string | ||
| 247 | }{ | ||
| 248 | {"routable v4", &net.UDPAddr{IP: net.ParseIP("192.168.0.190")}, "192.168.0.190"}, | ||
| 249 | {"routable v6", &net.UDPAddr{IP: net.ParseIP("2001:db8::1")}, "2001:db8::1"}, | ||
| 250 | {"v4-mapped v6 is unmapped", &net.UDPAddr{IP: net.ParseIP("::ffff:10.0.0.7")}, "10.0.0.7"}, | ||
| 251 | {"loopback reaches nobody", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}, ""}, | ||
| 252 | {"unspecified reaches nobody", &net.UDPAddr{IP: net.IPv4zero}, ""}, | ||
| 253 | {"link-local reaches nobody", &net.UDPAddr{IP: net.ParseIP("169.254.1.5")}, ""}, | ||
| 254 | {"not a udp address", &net.TCPAddr{IP: net.ParseIP("192.168.0.190")}, ""}, | ||
| 255 | {"no address at all", &net.UDPAddr{}, ""}, | ||
| 256 | } { | ||
| 257 | t.Run(tc.name, func(t *testing.T) { | ||
| 258 | if got := usableSourceAddr(tc.addr); got != tc.want { | ||
| 259 | t.Errorf("usableSourceAddr(%v) = %q, want %q", tc.addr, got, tc.want) | ||
| 260 | } | ||
| 261 | }) | ||
| 262 | } | ||
| 263 | } | ||
| 264 | |||
| 265 | func TestUplinkAddr(t *testing.T) { | ||
| 266 | // Nowhere to route to: no answer, never a guess. | ||
| 267 | if got := UplinkAddr("not-an-address"); got != "" { | ||
| 268 | t.Errorf("UplinkAddr(garbage) = %q, want empty", got) | ||
| 269 | } | ||
| 270 | // A route that resolves to loopback names an address nothing else can | ||
| 271 | // dial, so the agent says nothing rather than sending it. | ||
| 272 | if got := UplinkAddr("127.0.0.1:9"); got != "" { | ||
| 273 | t.Errorf("UplinkAddr(loopback) = %q, want empty", got) | ||
| 274 | } | ||
| 275 | } | ||
internal/agent/run/cli.go
| Old | New | ||
|---|---|---|---|
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | "flag" | 14 | "flag" |
| 15 | "fmt" | 15 | "fmt" |
| 16 | "log/slog" | 16 | "log/slog" |
| 17 | "net" | ||
| 17 | "os" | 18 | "os" |
| 18 | "os/exec" | 19 | "os/exec" |
| 19 | "os/signal" | 20 | "os/signal" |
| @@ -22,6 +23,7 @@ import ( | |||
| 22 | "time" | 23 | "time" |
| 23 | 24 | ||
| 24 | "github.com/a73x/eitri/internal/agent/enrollclient" | 25 | "github.com/a73x/eitri/internal/agent/enrollclient" |
| 26 | "github.com/a73x/eitri/internal/agent/exposeproxy" | ||
| 25 | "github.com/a73x/eitri/internal/agent/hostinfo" | 27 | "github.com/a73x/eitri/internal/agent/hostinfo" |
| 26 | "github.com/a73x/eitri/internal/agent/imagecache" | 28 | "github.com/a73x/eitri/internal/agent/imagecache" |
| 27 | "github.com/a73x/eitri/internal/agent/reconcile" | 29 | "github.com/a73x/eitri/internal/agent/reconcile" |
| @@ -285,6 +287,22 @@ func serve(st *state.Store, cfg Config) error { | |||
| 285 | // is cancelled (see the note below where pumps are torn down). The process is | 287 | // is cancelled (see the note below where pumps are torn down). The process is |
| 286 | // exiting; the OS reclaims the goroutines. | 288 | // exiting; the OS reclaims the goroutines. |
| 287 | 289 | ||
| 290 | // The proxy publishing this host's guest ports. It is built here, in the | ||
| 291 | // composition root, because it needs one thing from the agent and nothing | ||
| 292 | // from any backend: where a guest is right now. | ||
| 293 | proxy := exposeproxy.NewManager(func(vmID string) string { return guestAddr(st, vmID) }) | ||
| 294 | |||
| 295 | // The control plane's address, resolved once: the uplink address is asked on | ||
| 296 | // every report, and a per-tick resolution would put DNS on the report path, | ||
| 297 | // where a slow resolver stalls the session's writer. Only the route lookup | ||
| 298 | // uses this — the sync connection dials the configured address itself. | ||
| 299 | uplinkVia := id.ServerQUICAddr | ||
| 300 | if ua, err := net.ResolveUDPAddr("udp", uplinkVia); err == nil { | ||
| 301 | uplinkVia = ua.String() | ||
| 302 | } else { | ||
| 303 | slog.Warn("uplink target unresolved; per-report DNS lookups", "addr", uplinkVia, "err", err) | ||
| 304 | } | ||
| 305 | |||
| 288 | client := &syncclient.Client{ | 306 | client := &syncclient.Client{ |
| 289 | Engine: engine, | 307 | Engine: engine, |
| 290 | St: st, | 308 | St: st, |
| @@ -294,6 +312,8 @@ func serve(st *state.Store, cfg Config) error { | |||
| 294 | GuestCIDR: plat.GuestCIDR, | 312 | GuestCIDR: plat.GuestCIDR, |
| 295 | Runner: hostRunner, | 313 | Runner: hostRunner, |
| 296 | Console: pumps, | 314 | Console: pumps, |
| 315 | Exposures: proxy, | ||
| 316 | UplinkAddr: func() string { return hostinfo.UplinkAddr(uplinkVia) }, | ||
| 297 | MaxVCPUs: cfg.MaxVCPUs, | 317 | MaxVCPUs: cfg.MaxVCPUs, |
| 298 | MaxMemMB: cfg.MaxMemMB, | 318 | MaxMemMB: cfg.MaxMemMB, |
| 299 | MaxDiskGB: cfg.MaxDiskGB, | 319 | MaxDiskGB: cfg.MaxDiskGB, |
| @@ -307,7 +327,9 @@ func serve(st *state.Store, cfg Config) error { | |||
| 307 | // Ensure/Stop calls are expected to reach pumps (an unjoined session | 327 | // Ensure/Stop calls are expected to reach pumps (an unjoined session |
| 308 | // worker could in principle still be mid-Engine.Step, but it has nothing | 328 | // worker could in principle still be mid-Engine.Step, but it has nothing |
| 309 | // left to drive once client.Run has returned). Tear down every serial | 329 | // left to drive once client.Run has returned). Tear down every serial |
| 310 | // console pump here, at the very end of agent shutdown. | 330 | // console pump and every published listener here, at the very end of agent |
| 331 | // shutdown — a port this host no longer serves must not stay bound. | ||
| 311 | pumps.StopAll() | 332 | pumps.StopAll() |
| 333 | proxy.StopAll() | ||
| 312 | return nil | 334 | return nil |
| 313 | } | 335 | } |
internal/agent/run/exposures.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,21 @@ | |||
| 1 | package run | ||
| 2 | |||
| 3 | import "github.com/a73x/eitri/internal/agent/state" | ||
| 4 | |||
| 5 | // guestAddr answers a VM's current address from this agent's own records — | ||
| 6 | // exactly the answer the SSH tunnel dials, so a published port and a tunnelled | ||
| 7 | // one can never disagree about where a guest is. Empty means "no answer": the | ||
| 8 | // VM is not on this host, its record cannot be read, or its guest has not been | ||
| 9 | // given an address yet. | ||
| 10 | // | ||
| 11 | // It is asked per connection rather than once at bind, which is what makes an | ||
| 12 | // exposure created while its VM is still imaging simply start working when the | ||
| 13 | // guest comes up. Safe to call concurrently: a *state.Store is immutable after | ||
| 14 | // Open and Get only reads a record file that is written atomically. | ||
| 15 | func guestAddr(st *state.Store, vmID string) string { | ||
| 16 | rec, ok, err := st.Get(vmID) | ||
| 17 | if err != nil || !ok { | ||
| 18 | return "" | ||
| 19 | } | ||
| 20 | return rec.IP | ||
| 21 | } | ||
internal/agent/run/exposures_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,32 @@ | |||
| 1 | package run | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | |||
| 6 | "github.com/a73x/eitri/internal/agent/state" | ||
| 7 | "github.com/stretchr/testify/require" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // TestGuestAddrAnswersFromTheAgentsOwnRecords pins that the proxy dials the | ||
| 11 | // same address the tunnel does — the one reconcile keeps current on each VM's | ||
| 12 | // record — rather than a second, independently drifting answer. | ||
| 13 | func TestGuestAddrAnswersFromTheAgentsOwnRecords(t *testing.T) { | ||
| 14 | st, err := state.Open(t.TempDir()) | ||
| 15 | require.NoError(t, err) | ||
| 16 | |||
| 17 | // A VM this host has never heard of has no address. | ||
| 18 | if got := guestAddr(st, "vm1"); got != "" { | ||
| 19 | t.Errorf("guestAddr(unknown) = %q, want empty", got) | ||
| 20 | } | ||
| 21 | |||
| 22 | require.NoError(t, st.SaveVM(state.Record{Spec: state.VMSpec{VMID: "vm1"}, IP: "10.77.1.5"})) | ||
| 23 | if got := guestAddr(st, "vm1"); got != "10.77.1.5" { | ||
| 24 | t.Errorf("guestAddr(vm1) = %q, want 10.77.1.5", got) | ||
| 25 | } | ||
| 26 | |||
| 27 | // A record with no address yet is "no answer", not an address of "". | ||
| 28 | require.NoError(t, st.SaveVM(state.Record{Spec: state.VMSpec{VMID: "vm2"}})) | ||
| 29 | if got := guestAddr(st, "vm2"); got != "" { | ||
| 30 | t.Errorf("guestAddr(vm2) = %q, want empty", got) | ||
| 31 | } | ||
| 32 | } | ||
internal/agent/syncclient/client.go
| Old | New | ||
|---|---|---|---|
| @@ -42,6 +42,14 @@ type Console interface { | |||
| 42 | Attach(ctx context.Context, vmID string, rw io.ReadWriter, onReady func() error) error | 42 | Attach(ctx context.Context, vmID string, rw io.ReadWriter, onReady func() error) error |
| 43 | } | 43 | } |
| 44 | 44 | ||
| 45 | // Exposures converges this host's published guest ports and reports what each | ||
| 46 | // listener is doing (consumer-owned; the concrete implementation is | ||
| 47 | // *exposeproxy.Manager). A nil Exposures converges nothing and reports nothing | ||
| 48 | // — an agent with no proxy publishes no ports, and says so by saying nothing. | ||
| 49 | type Exposures interface { | ||
| 50 | Converge(desired []*pb.ExposureDesired) []*pb.ExposureActual | ||
| 51 | } | ||
| 52 | |||
| 45 | // DefaultTickInterval is the fallback report/reconcile cadence when | 53 | // DefaultTickInterval is the fallback report/reconcile cadence when |
| 46 | // Client.TickInterval is zero. It is COUPLED to the server's | 54 | // Client.TickInterval is zero. It is COUPLED to the server's |
| 47 | // registry.OnlineWindow (30s): the server marks a host offline after | 55 | // registry.OnlineWindow (30s): the server marks a host offline after |
| @@ -76,6 +84,13 @@ type Client struct { | |||
| 76 | // Console handles server-opened console streams (nil refuses them all). | 84 | // Console handles server-opened console streams (nil refuses them all). |
| 77 | Console Console | 85 | Console Console |
| 78 | 86 | ||
| 87 | // Exposures publishes this host's guest ports (nil publishes none). | ||
| 88 | Exposures Exposures | ||
| 89 | |||
| 90 | // UplinkAddr reports the address this host answers on, asked once per | ||
| 91 | // report like GuestCIDR. Nil, or an empty return, means "no answer". | ||
| 92 | UplinkAddr func() string | ||
| 93 | |||
| 79 | // dialGuest connects to a VM's ssh port; overridable in tests. nil uses the | 94 | // dialGuest connects to a VM's ssh port; overridable in tests. nil uses the |
| 80 | // production dialer, which pins the guest port to 22 — the wire port is | 95 | // production dialer, which pins the guest port to 22 — the wire port is |
| 81 | // validated but never dialed, so a compromised control plane cannot redirect | 96 | // validated but never dialed, so a compromised control plane cannot redirect |
| @@ -195,6 +210,28 @@ func (c *Client) maybeUpgrade(ctx context.Context, up *pb.AgentUpgrade) { | |||
| 195 | }() | 210 | }() |
| 196 | } | 211 | } |
| 197 | 212 | ||
| 213 | // convergeExposures drives this host's listeners toward the snapshot and | ||
| 214 | // returns the rows the report carries. Level-triggered like everything else: | ||
| 215 | // every snapshot re-converges, so a bind that lost its port to a squatting | ||
| 216 | // process is retried on the next tick. | ||
| 217 | func (c *Client) convergeExposures(snap *pb.DesiredStateSnapshot) []*pb.ExposureActual { | ||
| 218 | if c.Exposures == nil { | ||
| 219 | return nil | ||
| 220 | } | ||
| 221 | return c.Exposures.Converge(snap.GetExposures()) | ||
| 222 | } | ||
| 223 | |||
| 224 | // reportExposures converges only against a snapshot the engine ACCEPTED. A | ||
| 225 | // fenced snapshot is one this host has already moved past, and driving | ||
| 226 | // listeners from it would re-open a port the fleet has since revoked — the | ||
| 227 | // same reason the fence path touches nothing else. | ||
| 228 | func (c *Client) reportExposures(snap *pb.DesiredStateSnapshot, rep *pb.ActualStateReport) []*pb.ExposureActual { | ||
| 229 | if rep.GetFenceViolation() { | ||
| 230 | return nil | ||
| 231 | } | ||
| 232 | return c.convergeExposures(snap) | ||
| 233 | } | ||
| 234 | |||
| 198 | // errPermanentAuth marks a credential rejection so Run() backs off long instead | 235 | // errPermanentAuth marks a credential rejection so Run() backs off long instead |
| 199 | // of tight-looping a dead credential. | 236 | // of tight-looping a dead credential. |
| 200 | var errPermanentAuth = errors.New("auth rejected (permanent)") | 237 | var errPermanentAuth = errors.New("auth rejected (permanent)") |
| @@ -361,6 +398,13 @@ func (c *Client) session(ctx context.Context) error { | |||
| 361 | rep := c.Engine.Step(ctx, snap) | 398 | rep := c.Engine.Step(ctx, snap) |
| 362 | rep.Capacity = c.advertisedCapacity(stateDir) | 399 | rep.Capacity = c.advertisedCapacity(stateDir) |
| 363 | rep.Metrics = hostinfo.Metrics(stateDir) | 400 | rep.Metrics = hostinfo.Metrics(stateDir) |
| 401 | rep.Exposures = c.reportExposures(snap, rep) | ||
| 402 | // Polled, not resolved once: a host's address can change under a | ||
| 403 | // live agent (a lease renews on a different address). Empty is "no | ||
| 404 | // answer" and leaves the fleet's record alone. | ||
| 405 | if c.UplinkAddr != nil { | ||
| 406 | rep.HostUplinkAddr = c.UplinkAddr() | ||
| 407 | } | ||
| 364 | // Polled, not resolved once: a host's guest subnet can change while the | 408 | // Polled, not resolved once: a host's guest subnet can change while the |
| 365 | // agent stays connected, and on a platform whose OS owns the network it | 409 | // agent stays connected, and on a platform whose OS owns the network it |
| 366 | // may not be knowable at connect time at all. Empty is "no answer" and | 410 | // may not be knowable at connect time at all. Empty is "no answer" and |
internal/agent/syncclient/exposures_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,58 @@ | |||
| 1 | package syncclient | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | |||
| 6 | "github.com/a73x/eitri/internal/pb" | ||
| 7 | "github.com/stretchr/testify/assert" | ||
| 8 | "github.com/stretchr/testify/require" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // fakeExposures records what it was asked to converge and answers with a fixed | ||
| 12 | // report. | ||
| 13 | type fakeExposures struct { | ||
| 14 | sawIDs []string | ||
| 15 | calls int | ||
| 16 | out []*pb.ExposureActual | ||
| 17 | } | ||
| 18 | |||
| 19 | func (f *fakeExposures) Converge(desired []*pb.ExposureDesired) []*pb.ExposureActual { | ||
| 20 | f.calls++ | ||
| 21 | f.sawIDs = nil | ||
| 22 | for _, d := range desired { | ||
| 23 | f.sawIDs = append(f.sawIDs, d.GetId()) | ||
| 24 | } | ||
| 25 | return f.out | ||
| 26 | } | ||
| 27 | |||
| 28 | func TestConvergeExposuresPassesTheSnapshotThrough(t *testing.T) { | ||
| 29 | fe := &fakeExposures{out: []*pb.ExposureActual{{Id: "e1", State: "active"}}} | ||
| 30 | c := &Client{Exposures: fe} | ||
| 31 | |||
| 32 | got := c.convergeExposures(&pb.DesiredStateSnapshot{ | ||
| 33 | Exposures: []*pb.ExposureDesired{{Id: "e1", VmId: "vm1", GuestPort: 8080, HostPort: 30080}}, | ||
| 34 | }) | ||
| 35 | |||
| 36 | assert.Equal(t, []string{"e1"}, fe.sawIDs) | ||
| 37 | require.Len(t, got, 1) | ||
| 38 | assert.Equal(t, "active", got[0].GetState()) | ||
| 39 | } | ||
| 40 | |||
| 41 | func TestConvergeExposuresWithoutAProxyConvergesNothing(t *testing.T) { | ||
| 42 | c := &Client{} | ||
| 43 | assert.Nil(t, c.convergeExposures(&pb.DesiredStateSnapshot{ | ||
| 44 | Exposures: []*pb.ExposureDesired{{Id: "e1"}}, | ||
| 45 | }), "an agent with no proxy publishes nothing, and says so by saying nothing") | ||
| 46 | } | ||
| 47 | |||
| 48 | func TestConvergeExposuresRefusesAFencedSnapshot(t *testing.T) { | ||
| 49 | fe := &fakeExposures{} | ||
| 50 | c := &Client{Exposures: fe} | ||
| 51 | |||
| 52 | got := c.reportExposures(&pb.DesiredStateSnapshot{ | ||
| 53 | Exposures: []*pb.ExposureDesired{{Id: "e1"}}, | ||
| 54 | }, &pb.ActualStateReport{FenceViolation: true}) | ||
| 55 | |||
| 56 | assert.Zero(t, fe.calls, "a snapshot the engine refused must not drive the listeners") | ||
| 57 | assert.Nil(t, got) | ||
| 58 | } | ||
internal/pb/sync.pb.go
| Old | New | ||
|---|---|---|---|
| @@ -761,9 +761,17 @@ type ActualStateReport struct { | |||
| 761 | // connected (an admin edits the host's network), and on some platforms is not | 761 | // connected (an admin edits the host's network), and on some platforms is not |
| 762 | // knowable at connect time at all. Capacity is in both messages for the same | 762 | // knowable at connect time at all. Capacity is in both messages for the same |
| 763 | // reason: an opening value, then the ongoing truth. | 763 | // reason: an opening value, then the ongoing truth. |
| 764 | GuestCidr string `protobuf:"bytes,8,opt,name=guest_cidr,json=guestCidr,proto3" json:"guest_cidr,omitempty"` | 764 | GuestCidr string `protobuf:"bytes,8,opt,name=guest_cidr,json=guestCidr,proto3" json:"guest_cidr,omitempty"` |
| 765 | unknownFields protoimpl.UnknownFields | 765 | Exposures []*ExposureActual `protobuf:"bytes,9,rep,name=exposures,proto3" json:"exposures,omitempty"` |
| 766 | sizeCache protoimpl.SizeCache | 766 | // The address this host presents on the network it reaches the control |
| 767 | // plane over — the address an operator dials to reach a published guest | ||
| 768 | // port. Empty means "not yet known", never "no address": a host that cannot | ||
| 769 | // answer leaves the fleet's record alone rather than erasing it. In the | ||
| 770 | // report rather than Hello for the same reason guest_cidr is: it can change | ||
| 771 | // while an agent stays connected. | ||
| 772 | HostUplinkAddr string `protobuf:"bytes,10,opt,name=host_uplink_addr,json=hostUplinkAddr,proto3" json:"host_uplink_addr,omitempty"` | ||
| 773 | unknownFields protoimpl.UnknownFields | ||
| 774 | sizeCache protoimpl.SizeCache | ||
| 767 | } | 775 | } |
| 768 | 776 | ||
| 769 | func (x *ActualStateReport) Reset() { | 777 | func (x *ActualStateReport) Reset() { |
| @@ -852,6 +860,20 @@ func (x *ActualStateReport) GetGuestCidr() string { | |||
| 852 | return "" | 860 | return "" |
| 853 | } | 861 | } |
| 854 | 862 | ||
| 863 | func (x *ActualStateReport) GetExposures() []*ExposureActual { | ||
| 864 | if x != nil { | ||
| 865 | return x.Exposures | ||
| 866 | } | ||
| 867 | return nil | ||
| 868 | } | ||
| 869 | |||
| 870 | func (x *ActualStateReport) GetHostUplinkAddr() string { | ||
| 871 | if x != nil { | ||
| 872 | return x.HostUplinkAddr | ||
| 873 | } | ||
| 874 | return "" | ||
| 875 | } | ||
| 876 | |||
| 855 | type VMDesired struct { | 877 | type VMDesired struct { |
| 856 | state protoimpl.MessageState `protogen:"open.v1"` | 878 | state protoimpl.MessageState `protogen:"open.v1"` |
| 857 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` | 879 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` |
| @@ -1013,6 +1035,7 @@ type DesiredStateSnapshot struct { | |||
| 1013 | Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` // agents refuse epoch < highest seen | 1035 | Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` // agents refuse epoch < highest seen |
| 1014 | Vms []*VMDesired `protobuf:"bytes,2,rep,name=vms,proto3" json:"vms,omitempty"` // FULL set for this host, including tombstoned | 1036 | Vms []*VMDesired `protobuf:"bytes,2,rep,name=vms,proto3" json:"vms,omitempty"` // FULL set for this host, including tombstoned |
| 1015 | AgentUpgrade *AgentUpgrade `protobuf:"bytes,3,opt,name=agent_upgrade,json=agentUpgrade,proto3" json:"agent_upgrade,omitempty"` // optional operator-initiated agent self-upgrade | 1037 | AgentUpgrade *AgentUpgrade `protobuf:"bytes,3,opt,name=agent_upgrade,json=agentUpgrade,proto3" json:"agent_upgrade,omitempty"` // optional operator-initiated agent self-upgrade |
| 1038 | Exposures []*ExposureDesired `protobuf:"bytes,4,rep,name=exposures,proto3" json:"exposures,omitempty"` // FULL set for this host | ||
| 1016 | unknownFields protoimpl.UnknownFields | 1039 | unknownFields protoimpl.UnknownFields |
| 1017 | sizeCache protoimpl.SizeCache | 1040 | sizeCache protoimpl.SizeCache |
| 1018 | } | 1041 | } |
| @@ -1068,6 +1091,13 @@ func (x *DesiredStateSnapshot) GetAgentUpgrade() *AgentUpgrade { | |||
| 1068 | return nil | 1091 | return nil |
| 1069 | } | 1092 | } |
| 1070 | 1093 | ||
| 1094 | func (x *DesiredStateSnapshot) GetExposures() []*ExposureDesired { | ||
| 1095 | if x != nil { | ||
| 1096 | return x.Exposures | ||
| 1097 | } | ||
| 1098 | return nil | ||
| 1099 | } | ||
| 1100 | |||
| 1071 | // AgentUpgrade asks the agent to replace its own binary: download url, verify | 1101 | // AgentUpgrade asks the agent to replace its own binary: download url, verify |
| 1072 | // sha256, swap atomically (keeping .prev), re-exec. Present only on hosts an | 1102 | // sha256, swap atomically (keeping .prev), re-exec. Present only on hosts an |
| 1073 | // operator explicitly clicked; absent otherwise. An agent already running | 1103 | // operator explicitly clicked; absent otherwise. An agent already running |
| @@ -1344,6 +1374,152 @@ func (x *TCPOpened) GetError() string { | |||
| 1344 | return "" | 1374 | return "" |
| 1345 | } | 1375 | } |
| 1346 | 1376 | ||
| 1377 | // ExposureDesired is one published guest port a host should be serving: bind | ||
| 1378 | // host_port on the host, pipe every accepted connection to guest_port inside | ||
| 1379 | // the guest. It rides the snapshot at TOP LEVEL rather than nested in | ||
| 1380 | // VMDesired, because exposures are their own objects converging on their own | ||
| 1381 | // cadence — an exposure can be created while its VM is still imaging, and it | ||
| 1382 | // binds immediately. | ||
| 1383 | type ExposureDesired struct { | ||
| 1384 | state protoimpl.MessageState `protogen:"open.v1"` | ||
| 1385 | Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` | ||
| 1386 | VmId string `protobuf:"bytes,2,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` | ||
| 1387 | GuestPort uint32 `protobuf:"varint,3,opt,name=guest_port,json=guestPort,proto3" json:"guest_port,omitempty"` | ||
| 1388 | HostPort uint32 `protobuf:"varint,4,opt,name=host_port,json=hostPort,proto3" json:"host_port,omitempty"` | ||
| 1389 | Protocol string `protobuf:"bytes,5,opt,name=protocol,proto3" json:"protocol,omitempty"` // "tcp" | ||
| 1390 | unknownFields protoimpl.UnknownFields | ||
| 1391 | sizeCache protoimpl.SizeCache | ||
| 1392 | } | ||
| 1393 | |||
| 1394 | func (x *ExposureDesired) Reset() { | ||
| 1395 | *x = ExposureDesired{} | ||
| 1396 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[16] | ||
| 1397 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 1398 | ms.StoreMessageInfo(mi) | ||
| 1399 | } | ||
| 1400 | |||
| 1401 | func (x *ExposureDesired) String() string { | ||
| 1402 | return protoimpl.X.MessageStringOf(x) | ||
| 1403 | } | ||
| 1404 | |||
| 1405 | func (*ExposureDesired) ProtoMessage() {} | ||
| 1406 | |||
| 1407 | func (x *ExposureDesired) ProtoReflect() protoreflect.Message { | ||
| 1408 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[16] | ||
| 1409 | if x != nil { | ||
| 1410 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 1411 | if ms.LoadMessageInfo() == nil { | ||
| 1412 | ms.StoreMessageInfo(mi) | ||
| 1413 | } | ||
| 1414 | return ms | ||
| 1415 | } | ||
| 1416 | return mi.MessageOf(x) | ||
| 1417 | } | ||
| 1418 | |||
| 1419 | // Deprecated: Use ExposureDesired.ProtoReflect.Descriptor instead. | ||
| 1420 | func (*ExposureDesired) Descriptor() ([]byte, []int) { | ||
| 1421 | return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{16} | ||
| 1422 | } | ||
| 1423 | |||
| 1424 | func (x *ExposureDesired) GetId() string { | ||
| 1425 | if x != nil { | ||
| 1426 | return x.Id | ||
| 1427 | } | ||
| 1428 | return "" | ||
| 1429 | } | ||
| 1430 | |||
| 1431 | func (x *ExposureDesired) GetVmId() string { | ||
| 1432 | if x != nil { | ||
| 1433 | return x.VmId | ||
| 1434 | } | ||
| 1435 | return "" | ||
| 1436 | } | ||
| 1437 | |||
| 1438 | func (x *ExposureDesired) GetGuestPort() uint32 { | ||
| 1439 | if x != nil { | ||
| 1440 | return x.GuestPort | ||
| 1441 | } | ||
| 1442 | return 0 | ||
| 1443 | } | ||
| 1444 | |||
| 1445 | func (x *ExposureDesired) GetHostPort() uint32 { | ||
| 1446 | if x != nil { | ||
| 1447 | return x.HostPort | ||
| 1448 | } | ||
| 1449 | return 0 | ||
| 1450 | } | ||
| 1451 | |||
| 1452 | func (x *ExposureDesired) GetProtocol() string { | ||
| 1453 | if x != nil { | ||
| 1454 | return x.Protocol | ||
| 1455 | } | ||
| 1456 | return "" | ||
| 1457 | } | ||
| 1458 | |||
| 1459 | // ExposureActual is one exposure's state as its host observes it: "active" | ||
| 1460 | // once the host listener is bound, "failed" with the OS error otherwise. | ||
| 1461 | // "active" means the HOST half of the pipe exists — whether anything answers | ||
| 1462 | // inside the guest is the guest's half, and this does not pretend otherwise. | ||
| 1463 | type ExposureActual struct { | ||
| 1464 | state protoimpl.MessageState `protogen:"open.v1"` | ||
| 1465 | Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` | ||
| 1466 | State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` // "active"|"failed" | ||
| 1467 | Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` // the OS error, when failed | ||
| 1468 | unknownFields protoimpl.UnknownFields | ||
| 1469 | sizeCache protoimpl.SizeCache | ||
| 1470 | } | ||
| 1471 | |||
| 1472 | func (x *ExposureActual) Reset() { | ||
| 1473 | *x = ExposureActual{} | ||
| 1474 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[17] | ||
| 1475 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 1476 | ms.StoreMessageInfo(mi) | ||
| 1477 | } | ||
| 1478 | |||
| 1479 | func (x *ExposureActual) String() string { | ||
| 1480 | return protoimpl.X.MessageStringOf(x) | ||
| 1481 | } | ||
| 1482 | |||
| 1483 | func (*ExposureActual) ProtoMessage() {} | ||
| 1484 | |||
| 1485 | func (x *ExposureActual) ProtoReflect() protoreflect.Message { | ||
| 1486 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[17] | ||
| 1487 | if x != nil { | ||
| 1488 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 1489 | if ms.LoadMessageInfo() == nil { | ||
| 1490 | ms.StoreMessageInfo(mi) | ||
| 1491 | } | ||
| 1492 | return ms | ||
| 1493 | } | ||
| 1494 | return mi.MessageOf(x) | ||
| 1495 | } | ||
| 1496 | |||
| 1497 | // Deprecated: Use ExposureActual.ProtoReflect.Descriptor instead. | ||
| 1498 | func (*ExposureActual) Descriptor() ([]byte, []int) { | ||
| 1499 | return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{17} | ||
| 1500 | } | ||
| 1501 | |||
| 1502 | func (x *ExposureActual) GetId() string { | ||
| 1503 | if x != nil { | ||
| 1504 | return x.Id | ||
| 1505 | } | ||
| 1506 | return "" | ||
| 1507 | } | ||
| 1508 | |||
| 1509 | func (x *ExposureActual) GetState() string { | ||
| 1510 | if x != nil { | ||
| 1511 | return x.State | ||
| 1512 | } | ||
| 1513 | return "" | ||
| 1514 | } | ||
| 1515 | |||
| 1516 | func (x *ExposureActual) GetReason() string { | ||
| 1517 | if x != nil { | ||
| 1518 | return x.Reason | ||
| 1519 | } | ||
| 1520 | return "" | ||
| 1521 | } | ||
| 1522 | |||
| 1347 | var File_proto_eitri_v1_sync_proto protoreflect.FileDescriptor | 1523 | var File_proto_eitri_v1_sync_proto protoreflect.FileDescriptor |
| 1348 | 1524 | ||
| 1349 | const file_proto_eitri_v1_sync_proto_rawDesc = "" + | 1525 | const file_proto_eitri_v1_sync_proto_rawDesc = "" + |
| @@ -1410,7 +1586,7 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1410 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" + | 1586 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" + |
| 1411 | "\vvmspec_json\x18\x03 \x01(\fR\n" + | 1587 | "\vvmspec_json\x18\x03 \x01(\fR\n" + |
| 1412 | "vmspecJson\x12&\n" + | 1588 | "vmspecJson\x12&\n" + |
| 1413 | "\x0fdestroy_at_unix\x18\x04 \x01(\x03R\rdestroyAtUnix\"\xe3\x02\n" + | 1589 | "\x0fdestroy_at_unix\x18\x04 \x01(\x03R\rdestroyAtUnix\"\xc5\x03\n" + |
| 1414 | "\x11ActualStateReport\x12$\n" + | 1590 | "\x11ActualStateReport\x12$\n" + |
| 1415 | "\x03vms\x18\x01 \x03(\v2\x12.eitri.v1.ActualVMR\x03vms\x12\x1c\n" + | 1591 | "\x03vms\x18\x01 \x03(\v2\x12.eitri.v1.ActualVMR\x03vms\x12\x1c\n" + |
| 1416 | "\tdestroyed\x18\x02 \x03(\tR\tdestroyed\x129\n" + | 1592 | "\tdestroyed\x18\x02 \x03(\tR\tdestroyed\x129\n" + |
| @@ -1420,7 +1596,10 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1420 | "\x0flast_seen_epoch\x18\x06 \x01(\x04R\rlastSeenEpoch\x12/\n" + | 1596 | "\x0flast_seen_epoch\x18\x06 \x01(\x04R\rlastSeenEpoch\x12/\n" + |
| 1421 | "\ametrics\x18\a \x01(\v2\x15.eitri.v1.HostMetricsR\ametrics\x12\x1d\n" + | 1597 | "\ametrics\x18\a \x01(\v2\x15.eitri.v1.HostMetricsR\ametrics\x12\x1d\n" + |
| 1422 | "\n" + | 1598 | "\n" + |
| 1423 | "guest_cidr\x18\b \x01(\tR\tguestCidr\"\x85\x04\n" + | 1599 | "guest_cidr\x18\b \x01(\tR\tguestCidr\x126\n" + |
| 1600 | "\texposures\x18\t \x03(\v2\x18.eitri.v1.ExposureActualR\texposures\x12(\n" + | ||
| 1601 | "\x10host_uplink_addr\x18\n" + | ||
| 1602 | " \x01(\tR\x0ehostUplinkAddr\"\x85\x04\n" + | ||
| 1424 | "\tVMDesired\x12\x13\n" + | 1603 | "\tVMDesired\x12\x13\n" + |
| 1425 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + | 1604 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + |
| 1426 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + | 1605 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + |
| @@ -1443,11 +1622,12 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1443 | "\x12ssh_authorized_key\x18\f \x01(\tR\x10sshAuthorizedKey\x12'\n" + | 1622 | "\x12ssh_authorized_key\x18\f \x01(\tR\x10sshAuthorizedKey\x12'\n" + |
| 1444 | "\x10ssh_host_key_pem\x18\x10 \x01(\tR\rsshHostKeyPem\x12\"\n" + | 1623 | "\x10ssh_host_key_pem\x18\x10 \x01(\tR\rsshHostKeyPem\x12\"\n" + |
| 1445 | "\rssh_host_cert\x18\x11 \x01(\tR\vsshHostCert\x12<\n" + | 1624 | "\rssh_host_cert\x18\x11 \x01(\tR\vsshHostCert\x12<\n" + |
| 1446 | "\x1bssh_user_ca_authorized_keys\x18\x12 \x03(\tR\x17sshUserCaAuthorizedKeysJ\x04\b\r\x10\x0eJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10\"\x90\x01\n" + | 1625 | "\x1bssh_user_ca_authorized_keys\x18\x12 \x03(\tR\x17sshUserCaAuthorizedKeysJ\x04\b\r\x10\x0eJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10\"\xc9\x01\n" + |
| 1447 | "\x14DesiredStateSnapshot\x12\x14\n" + | 1626 | "\x14DesiredStateSnapshot\x12\x14\n" + |
| 1448 | "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12%\n" + | 1627 | "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12%\n" + |
| 1449 | "\x03vms\x18\x02 \x03(\v2\x13.eitri.v1.VMDesiredR\x03vms\x12;\n" + | 1628 | "\x03vms\x18\x02 \x03(\v2\x13.eitri.v1.VMDesiredR\x03vms\x12;\n" + |
| 1450 | "\ragent_upgrade\x18\x03 \x01(\v2\x16.eitri.v1.AgentUpgradeR\fagentUpgrade\"R\n" + | 1629 | "\ragent_upgrade\x18\x03 \x01(\v2\x16.eitri.v1.AgentUpgradeR\fagentUpgrade\x127\n" + |
| 1630 | "\texposures\x18\x04 \x03(\v2\x19.eitri.v1.ExposureDesiredR\texposures\"R\n" + | ||
| 1451 | "\fAgentUpgrade\x12\x18\n" + | 1631 | "\fAgentUpgrade\x12\x18\n" + |
| 1452 | "\aversion\x18\x01 \x01(\tR\aversion\x12\x10\n" + | 1632 | "\aversion\x18\x01 \x01(\tR\aversion\x12\x10\n" + |
| 1453 | "\x03url\x18\x02 \x01(\tR\x03url\x12\x16\n" + | 1633 | "\x03url\x18\x02 \x01(\tR\x03url\x12\x16\n" + |
| @@ -1462,7 +1642,18 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1462 | "\x04port\x18\x02 \x01(\rR\x04port\"1\n" + | 1642 | "\x04port\x18\x02 \x01(\rR\x04port\"1\n" + |
| 1463 | "\tTCPOpened\x12\x0e\n" + | 1643 | "\tTCPOpened\x12\x0e\n" + |
| 1464 | "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x14\n" + | 1644 | "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x14\n" + |
| 1465 | "\x05error\x18\x02 \x01(\tR\x05errorB&Z$github.com/a73x/eitri/internal/pb;pbb\x06proto3" | 1645 | "\x05error\x18\x02 \x01(\tR\x05error\"\x8e\x01\n" + |
| 1646 | "\x0fExposureDesired\x12\x0e\n" + | ||
| 1647 | "\x02id\x18\x01 \x01(\tR\x02id\x12\x13\n" + | ||
| 1648 | "\x05vm_id\x18\x02 \x01(\tR\x04vmId\x12\x1d\n" + | ||
| 1649 | "\n" + | ||
| 1650 | "guest_port\x18\x03 \x01(\rR\tguestPort\x12\x1b\n" + | ||
| 1651 | "\thost_port\x18\x04 \x01(\rR\bhostPort\x12\x1a\n" + | ||
| 1652 | "\bprotocol\x18\x05 \x01(\tR\bprotocol\"N\n" + | ||
| 1653 | "\x0eExposureActual\x12\x0e\n" + | ||
| 1654 | "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + | ||
| 1655 | "\x05state\x18\x02 \x01(\tR\x05state\x12\x16\n" + | ||
| 1656 | "\x06reason\x18\x03 \x01(\tR\x06reasonB&Z$github.com/a73x/eitri/internal/pb;pbb\x06proto3" | ||
| 1466 | 1657 | ||
| 1467 | var ( | 1658 | var ( |
| 1468 | file_proto_eitri_v1_sync_proto_rawDescOnce sync.Once | 1659 | file_proto_eitri_v1_sync_proto_rawDescOnce sync.Once |
| @@ -1476,7 +1667,7 @@ func file_proto_eitri_v1_sync_proto_rawDescGZIP() []byte { | |||
| 1476 | return file_proto_eitri_v1_sync_proto_rawDescData | 1667 | return file_proto_eitri_v1_sync_proto_rawDescData |
| 1477 | } | 1668 | } |
| 1478 | 1669 | ||
| 1479 | var file_proto_eitri_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 16) | 1670 | var file_proto_eitri_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 18) |
| 1480 | var file_proto_eitri_v1_sync_proto_goTypes = []any{ | 1671 | var file_proto_eitri_v1_sync_proto_goTypes = []any{ |
| 1481 | (*AgentMessage)(nil), // 0: eitri.v1.AgentMessage | 1672 | (*AgentMessage)(nil), // 0: eitri.v1.AgentMessage |
| 1482 | (*ServerMessage)(nil), // 1: eitri.v1.ServerMessage | 1673 | (*ServerMessage)(nil), // 1: eitri.v1.ServerMessage |
| @@ -1494,6 +1685,8 @@ var file_proto_eitri_v1_sync_proto_goTypes = []any{ | |||
| 1494 | (*ConsoleOpened)(nil), // 13: eitri.v1.ConsoleOpened | 1685 | (*ConsoleOpened)(nil), // 13: eitri.v1.ConsoleOpened |
| 1495 | (*TCPOpen)(nil), // 14: eitri.v1.TCPOpen | 1686 | (*TCPOpen)(nil), // 14: eitri.v1.TCPOpen |
| 1496 | (*TCPOpened)(nil), // 15: eitri.v1.TCPOpened | 1687 | (*TCPOpened)(nil), // 15: eitri.v1.TCPOpened |
| 1688 | (*ExposureDesired)(nil), // 16: eitri.v1.ExposureDesired | ||
| 1689 | (*ExposureActual)(nil), // 17: eitri.v1.ExposureActual | ||
| 1497 | } | 1690 | } |
| 1498 | var file_proto_eitri_v1_sync_proto_depIdxs = []int32{ | 1691 | var file_proto_eitri_v1_sync_proto_depIdxs = []int32{ |
| 1499 | 2, // 0: eitri.v1.AgentMessage.hello:type_name -> eitri.v1.Hello | 1692 | 2, // 0: eitri.v1.AgentMessage.hello:type_name -> eitri.v1.Hello |
| @@ -1509,13 +1702,15 @@ var file_proto_eitri_v1_sync_proto_depIdxs = []int32{ | |||
| 1509 | 7, // 10: eitri.v1.ActualStateReport.quarantined:type_name -> eitri.v1.QuarantinedVM | 1702 | 7, // 10: eitri.v1.ActualStateReport.quarantined:type_name -> eitri.v1.QuarantinedVM |
| 1510 | 3, // 11: eitri.v1.ActualStateReport.capacity:type_name -> eitri.v1.Capacity | 1703 | 3, // 11: eitri.v1.ActualStateReport.capacity:type_name -> eitri.v1.Capacity |
| 1511 | 5, // 12: eitri.v1.ActualStateReport.metrics:type_name -> eitri.v1.HostMetrics | 1704 | 5, // 12: eitri.v1.ActualStateReport.metrics:type_name -> eitri.v1.HostMetrics |
| 1512 | 9, // 13: eitri.v1.DesiredStateSnapshot.vms:type_name -> eitri.v1.VMDesired | 1705 | 17, // 13: eitri.v1.ActualStateReport.exposures:type_name -> eitri.v1.ExposureActual |
| 1513 | 11, // 14: eitri.v1.DesiredStateSnapshot.agent_upgrade:type_name -> eitri.v1.AgentUpgrade | 1706 | 9, // 14: eitri.v1.DesiredStateSnapshot.vms:type_name -> eitri.v1.VMDesired |
| 1514 | 15, // [15:15] is the sub-list for method output_type | 1707 | 11, // 15: eitri.v1.DesiredStateSnapshot.agent_upgrade:type_name -> eitri.v1.AgentUpgrade |
| 1515 | 15, // [15:15] is the sub-list for method input_type | 1708 | 16, // 16: eitri.v1.DesiredStateSnapshot.exposures:type_name -> eitri.v1.ExposureDesired |
| 1516 | 15, // [15:15] is the sub-list for extension type_name | 1709 | 17, // [17:17] is the sub-list for method output_type |
| 1517 | 15, // [15:15] is the sub-list for extension extendee | 1710 | 17, // [17:17] is the sub-list for method input_type |
| 1518 | 0, // [0:15] is the sub-list for field type_name | 1711 | 17, // [17:17] is the sub-list for extension type_name |
| 1712 | 17, // [17:17] is the sub-list for extension extendee | ||
| 1713 | 0, // [0:17] is the sub-list for field type_name | ||
| 1519 | } | 1714 | } |
| 1520 | 1715 | ||
| 1521 | func init() { file_proto_eitri_v1_sync_proto_init() } | 1716 | func init() { file_proto_eitri_v1_sync_proto_init() } |
| @@ -1540,7 +1735,7 @@ func file_proto_eitri_v1_sync_proto_init() { | |||
| 1540 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | 1735 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), |
| 1541 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_eitri_v1_sync_proto_rawDesc), len(file_proto_eitri_v1_sync_proto_rawDesc)), | 1736 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_eitri_v1_sync_proto_rawDesc), len(file_proto_eitri_v1_sync_proto_rawDesc)), |
| 1542 | NumEnums: 0, | 1737 | NumEnums: 0, |
| 1543 | NumMessages: 16, | 1738 | NumMessages: 18, |
| 1544 | NumExtensions: 0, | 1739 | NumExtensions: 0, |
| 1545 | NumServices: 0, | 1740 | NumServices: 0, |
| 1546 | }, | 1741 | }, |
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -400,6 +400,7 @@ func toHostResponse(h store.Host, st registry.HostState, ok bool, alloc store.Al | |||
| 400 | Kernel: h.Kernel, | 400 | Kernel: h.Kernel, |
| 401 | CPUModel: h.CPUModel, | 401 | CPUModel: h.CPUModel, |
| 402 | Virt: h.Virt, | 402 | Virt: h.Virt, |
| 403 | UplinkAddr: h.UplinkAddr, | ||
| 403 | } | 404 | } |
| 404 | if ok { | 405 | if ok { |
| 405 | hr.Online = st.Online | 406 | hr.Online = st.Online |
internal/server/api/client/client.go
| Old | New | ||
|---|---|---|---|
| @@ -35,6 +35,8 @@ type ( | |||
| 35 | Me = types.Me | 35 | Me = types.Me |
| 36 | CreateAPITokenResponse = types.CreateAPITokenResponse | 36 | CreateAPITokenResponse = types.CreateAPITokenResponse |
| 37 | UserCA = types.UserCA | 37 | UserCA = types.UserCA |
| 38 | Exposure = types.Exposure | ||
| 39 | CreateExposureRequest = types.CreateExposureRequest | ||
| 38 | ) | 40 | ) |
| 39 | 41 | ||
| 40 | // Client calls the eitri API at BaseURL, authenticating with Token (sent as a | 42 | // Client calls the eitri API at BaseURL, authenticating with Token (sent as a |
| @@ -145,6 +147,28 @@ func (c *Client) PatchVM(ctx context.Context, id, powerState string) error { | |||
| 145 | return c.do(ctx, http.MethodPatch, "/api/v1/vms/"+url.PathEscape(id), PatchVMRequest{PowerState: powerState}, nil) | 147 | return c.do(ctx, http.MethodPatch, "/api/v1/vms/"+url.PathEscape(id), PatchVMRequest{PowerState: powerState}, nil) |
| 146 | } | 148 | } |
| 147 | 149 | ||
| 150 | // CreateExposure publishes guestPort of a VM on its host. hostPort 0 asks the | ||
| 151 | // control plane to allocate one from the reserved range; the returned exposure | ||
| 152 | // carries whichever port it ended up with. | ||
| 153 | func (c *Client) CreateExposure(ctx context.Context, vmID string, guestPort, hostPort int64) (Exposure, error) { | ||
| 154 | req := CreateExposureRequest{GuestPort: guestPort, HostPort: hostPort} | ||
| 155 | var out Exposure | ||
| 156 | return out, c.do(ctx, http.MethodPost, "/api/v1/vms/"+url.PathEscape(vmID)+"/exposures", req, &out) | ||
| 157 | } | ||
| 158 | |||
| 159 | // ListExposures returns a VM's published ports, with the host address to dial | ||
| 160 | // and each listener's reported state. | ||
| 161 | func (c *Client) ListExposures(ctx context.Context, vmID string) ([]Exposure, error) { | ||
| 162 | var out []Exposure | ||
| 163 | return out, c.do(ctx, http.MethodGet, "/api/v1/vms/"+url.PathEscape(vmID)+"/exposures", nil, &out) | ||
| 164 | } | ||
| 165 | |||
| 166 | // DeleteExposure revokes an exposure by id; the host closes the listener on its | ||
| 167 | // next converge. | ||
| 168 | func (c *Client) DeleteExposure(ctx context.Context, id string) error { | ||
| 169 | return c.do(ctx, http.MethodDelete, "/api/v1/exposures/"+url.PathEscape(id), nil, nil) | ||
| 170 | } | ||
| 171 | |||
| 148 | // Me returns the signed-in identity (tenant handle + bound email) for the | 172 | // Me returns the signed-in identity (tenant handle + bound email) for the |
| 149 | // credential this client carries. It takes no context — the consumers (smoke | 173 | // credential this client carries. It takes no context — the consumers (smoke |
| 150 | // gate, CLI) call it as a quick synchronous probe; the do timeout bounds it. | 174 | // gate, CLI) call it as a quick synchronous probe; the do timeout bounds it. |
internal/server/api/client/client_test.go
| Old | New | ||
|---|---|---|---|
| @@ -470,3 +470,60 @@ func TestUploadUserCAEscapesTenant(t *testing.T) { | |||
| 470 | t.Errorf("path = %q, want %q", cap.path, want) | 470 | t.Errorf("path = %q, want %q", cap.path, want) |
| 471 | } | 471 | } |
| 472 | } | 472 | } |
| 473 | |||
| 474 | func TestCreateExposure(t *testing.T) { | ||
| 475 | var cap capture | ||
| 476 | srv := serve(t, &cap, http.StatusCreated, | ||
| 477 | `{"id":"x-1","vm_id":"v-1","host_id":"h-1","guest_port":8080,"host_port":30080,`+ | ||
| 478 | `"host_addr":"192.168.0.190","protocol":"tcp","scope":"lan","state":"pending","reason":"",`+ | ||
| 479 | `"created_at":"2026-08-05T12:00:00Z"}`) | ||
| 480 | c := &client.Client{BaseURL: srv.URL, Token: "eitri_pat_x"} | ||
| 481 | |||
| 482 | got, err := c.CreateExposure(context.Background(), "v-1", 8080, 0) | ||
| 483 | if err != nil { | ||
| 484 | t.Fatalf("CreateExposure: %v", err) | ||
| 485 | } | ||
| 486 | if cap.method != http.MethodPost || cap.path != "/api/v1/vms/v-1/exposures" { | ||
| 487 | t.Errorf("request = %s %s, want POST /api/v1/vms/v-1/exposures", cap.method, cap.path) | ||
| 488 | } | ||
| 489 | var sent types.CreateExposureRequest | ||
| 490 | if err := json.Unmarshal(cap.body, &sent); err != nil { | ||
| 491 | t.Fatalf("decode sent body: %v", err) | ||
| 492 | } | ||
| 493 | if sent.GuestPort != 8080 || sent.HostPort != 0 { | ||
| 494 | t.Errorf("sent = %+v, want guest 8080 / host 0", sent) | ||
| 495 | } | ||
| 496 | if got.ID != "x-1" || got.HostPort != 30080 { | ||
| 497 | t.Errorf("got = %+v, want the allocated exposure back", got) | ||
| 498 | } | ||
| 499 | } | ||
| 500 | |||
| 501 | func TestListExposures(t *testing.T) { | ||
| 502 | var cap capture | ||
| 503 | srv := serve(t, &cap, http.StatusOK, `[{"id":"x-1","host_port":30080,"state":"active"}]`) | ||
| 504 | c := &client.Client{BaseURL: srv.URL, Token: "eitri_pat_x"} | ||
| 505 | |||
| 506 | got, err := c.ListExposures(context.Background(), "v-1") | ||
| 507 | if err != nil { | ||
| 508 | t.Fatalf("ListExposures: %v", err) | ||
| 509 | } | ||
| 510 | if cap.method != http.MethodGet || cap.path != "/api/v1/vms/v-1/exposures" { | ||
| 511 | t.Errorf("request = %s %s, want GET /api/v1/vms/v-1/exposures", cap.method, cap.path) | ||
| 512 | } | ||
| 513 | if len(got) != 1 || got[0].State != "active" { | ||
| 514 | t.Errorf("got = %+v, want one active exposure", got) | ||
| 515 | } | ||
| 516 | } | ||
| 517 | |||
| 518 | func TestDeleteExposure(t *testing.T) { | ||
| 519 | var cap capture | ||
| 520 | srv := serve(t, &cap, http.StatusNoContent, "") | ||
| 521 | c := &client.Client{BaseURL: srv.URL, Token: "eitri_pat_x"} | ||
| 522 | |||
| 523 | if err := c.DeleteExposure(context.Background(), "x-1"); err != nil { | ||
| 524 | t.Fatalf("DeleteExposure: %v", err) | ||
| 525 | } | ||
| 526 | if cap.method != http.MethodDelete || cap.path != "/api/v1/exposures/x-1" { | ||
| 527 | t.Errorf("request = %s %s, want DELETE /api/v1/exposures/x-1", cap.method, cap.path) | ||
| 528 | } | ||
| 529 | } | ||
internal/server/api/exposures.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,185 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "errors" | ||
| 6 | "net/http" | ||
| 7 | "strconv" | ||
| 8 | |||
| 9 | "github.com/a73x/eitri/internal/server/api/types" | ||
| 10 | "github.com/a73x/eitri/internal/server/registry" | ||
| 11 | "github.com/a73x/eitri/internal/server/store" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // exposureVM resolves the {id} path segment to a VM the caller may act on. A | ||
| 15 | // foreign-tenant VM answers exactly like a missing one — existence is not | ||
| 16 | // leaked across tenants. On refusal it writes the response and returns | ||
| 17 | // ok=false. | ||
| 18 | func (a *API) exposureVM(w http.ResponseWriter, r *http.Request) (store.VM, bool) { | ||
| 19 | vm, err := a.st.GetVM(r.PathValue("id")) | ||
| 20 | if err != nil { | ||
| 21 | if errors.Is(err, sql.ErrNoRows) { | ||
| 22 | http.Error(w, "not found", http.StatusNotFound) | ||
| 23 | return store.VM{}, false | ||
| 24 | } | ||
| 25 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 26 | return store.VM{}, false | ||
| 27 | } | ||
| 28 | if !mayActAs(principalFromContext(r), vm.Tenant) { | ||
| 29 | http.Error(w, "not found", http.StatusNotFound) | ||
| 30 | return store.VM{}, false | ||
| 31 | } | ||
| 32 | return vm, true | ||
| 33 | } | ||
| 34 | |||
| 35 | // validateExposurePorts checks a create request, returning (msg, status) on | ||
| 36 | // failure or ("", 0) when valid. | ||
| 37 | // | ||
| 38 | // guest_port is unrestricted within the port space: the guest owns the guest, | ||
| 39 | // including which of its ports are worth publishing. host_port is where | ||
| 40 | // privilege lives — the 1024 floor is uniformity as much as safety, because a | ||
| 41 | // macOS agent is unprivileged and cannot bind lower, and low ports are the | ||
| 42 | // future gateway's territory. | ||
| 43 | func validateExposurePorts(req types.CreateExposureRequest) (string, int) { | ||
| 44 | if req.GuestPort < 1 || req.GuestPort > 65535 { | ||
| 45 | return "guest_port must be between 1 and 65535", http.StatusBadRequest | ||
| 46 | } | ||
| 47 | if req.HostPort != 0 && (req.HostPort < 1024 || req.HostPort > 65535) { | ||
| 48 | return "host_port must be between 1024 and 65535, or omitted to allocate one from " + | ||
| 49 | strconv.Itoa(store.MinAllocatedHostPort) + "-" + strconv.Itoa(store.MaxAllocatedHostPort), | ||
| 50 | http.StatusBadRequest | ||
| 51 | } | ||
| 52 | return "", 0 | ||
| 53 | } | ||
| 54 | |||
| 55 | // exposureState folds a host's live report into one exposure's state. An | ||
| 56 | // exposure the host has not reported on is "pending": the grant exists, and | ||
| 57 | // nothing has said what the host made of it yet. | ||
| 58 | func exposureState(statuses []registry.ExposureStatus, id string) (state, reason string) { | ||
| 59 | for _, s := range statuses { | ||
| 60 | if s.ID == id { | ||
| 61 | return s.State, s.Reason | ||
| 62 | } | ||
| 63 | } | ||
| 64 | return "pending", "" | ||
| 65 | } | ||
| 66 | |||
| 67 | // toExposureResponse merges a durable exposure row with its host's address and | ||
| 68 | // live reported state into the wire shape. | ||
| 69 | func toExposureResponse(e store.Exposure, hostAddr string, statuses []registry.ExposureStatus) types.Exposure { | ||
| 70 | state, reason := exposureState(statuses, e.ID) | ||
| 71 | return types.Exposure{ | ||
| 72 | ID: e.ID, VMID: e.VMID, HostID: e.HostID, | ||
| 73 | GuestPort: e.GuestPort, HostPort: e.HostPort, HostAddr: hostAddr, | ||
| 74 | Protocol: e.Protocol, Scope: e.Scope, | ||
| 75 | State: state, Reason: reason, CreatedAt: e.CreatedAt, | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | // hostFacing returns the address to dial for exposures on hostID, and that | ||
| 80 | // host's live exposure reports. Both are best-effort: a host that has not | ||
| 81 | // reported yields an empty address and no statuses, which renders as a pending | ||
| 82 | // exposure with nowhere named yet. | ||
| 83 | func (a *API) hostFacing(hostID string) (string, []registry.ExposureStatus) { | ||
| 84 | var addr string | ||
| 85 | if h, err := a.st.GetHost(hostID); err == nil { | ||
| 86 | addr = h.UplinkAddr | ||
| 87 | } | ||
| 88 | if st, ok := a.reg.Get(hostID); ok { | ||
| 89 | return addr, st.Report.Exposures | ||
| 90 | } | ||
| 91 | return addr, nil | ||
| 92 | } | ||
| 93 | |||
| 94 | // handleCreateExposure publishes one guest port of one VM on that VM's host. | ||
| 95 | func (a *API) handleCreateExposure(w http.ResponseWriter, r *http.Request) { | ||
| 96 | vm, ok := a.exposureVM(w, r) | ||
| 97 | if !ok { | ||
| 98 | return | ||
| 99 | } | ||
| 100 | var req types.CreateExposureRequest | ||
| 101 | if !decodeJSON(w, r, &req) { | ||
| 102 | return | ||
| 103 | } | ||
| 104 | if msg, code := validateExposurePorts(req); msg != "" { | ||
| 105 | http.Error(w, msg, code) | ||
| 106 | return | ||
| 107 | } | ||
| 108 | e, err := a.st.CreateExposure(vm.ID, req.GuestPort, req.HostPort) | ||
| 109 | if err != nil { | ||
| 110 | switch { | ||
| 111 | case errors.Is(err, store.ErrExposureVMNotFound): | ||
| 112 | http.Error(w, "not found", http.StatusNotFound) | ||
| 113 | case errors.Is(err, store.ErrHostPortTaken): | ||
| 114 | http.Error(w, "host port already in use on that host", http.StatusConflict) | ||
| 115 | case errors.Is(err, store.ErrNoFreeHostPort): | ||
| 116 | http.Error(w, "no free host port in the reserved range on that host", http.StatusConflict) | ||
| 117 | default: | ||
| 118 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 119 | } | ||
| 120 | return | ||
| 121 | } | ||
| 122 | a.audit(vm.Tenant, "exposure.create", map[string]string{ | ||
| 123 | "vm_id": vm.ID, "name": vm.Name, "exposure_id": e.ID, | ||
| 124 | "guest_port": strconv.FormatInt(e.GuestPort, 10), | ||
| 125 | "host_port": strconv.FormatInt(e.HostPort, 10), | ||
| 126 | }) | ||
| 127 | a.hub.Poke(vm.HostID) | ||
| 128 | a.notif.notify() | ||
| 129 | addr, statuses := a.hostFacing(vm.HostID) | ||
| 130 | writeJSON(w, http.StatusCreated, toExposureResponse(e, addr, statuses)) | ||
| 131 | } | ||
| 132 | |||
| 133 | // handleListExposures lists one VM's published ports, lowest host port first. | ||
| 134 | func (a *API) handleListExposures(w http.ResponseWriter, r *http.Request) { | ||
| 135 | vm, ok := a.exposureVM(w, r) | ||
| 136 | if !ok { | ||
| 137 | return | ||
| 138 | } | ||
| 139 | exps, err := a.st.ListExposuresForVM(vm.ID) | ||
| 140 | if err != nil { | ||
| 141 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 142 | return | ||
| 143 | } | ||
| 144 | addr, statuses := a.hostFacing(vm.HostID) | ||
| 145 | out := make([]types.Exposure, 0, len(exps)) | ||
| 146 | for _, e := range exps { | ||
| 147 | out = append(out, toExposureResponse(e, addr, statuses)) | ||
| 148 | } | ||
| 149 | writeJSON(w, http.StatusOK, out) | ||
| 150 | } | ||
| 151 | |||
| 152 | // handleDeleteExposure revokes a grant. There is no update verb: wrong port, | ||
| 153 | // delete it and issue another. | ||
| 154 | func (a *API) handleDeleteExposure(w http.ResponseWriter, r *http.Request) { | ||
| 155 | id := r.PathValue("id") | ||
| 156 | e, err := a.st.GetExposure(id) | ||
| 157 | if err != nil { | ||
| 158 | if errors.Is(err, sql.ErrNoRows) { | ||
| 159 | http.Error(w, "not found", http.StatusNotFound) | ||
| 160 | return | ||
| 161 | } | ||
| 162 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 163 | return | ||
| 164 | } | ||
| 165 | if !mayActAs(principalFromContext(r), e.Tenant) { | ||
| 166 | http.Error(w, "not found", http.StatusNotFound) | ||
| 167 | return | ||
| 168 | } | ||
| 169 | if err := a.st.DeleteExposure(id); err != nil { | ||
| 170 | if errors.Is(err, sql.ErrNoRows) { | ||
| 171 | http.Error(w, "not found", http.StatusNotFound) | ||
| 172 | return | ||
| 173 | } | ||
| 174 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 175 | return | ||
| 176 | } | ||
| 177 | a.audit(e.Tenant, "exposure.delete", map[string]string{ | ||
| 178 | "vm_id": e.VMID, "exposure_id": e.ID, | ||
| 179 | "guest_port": strconv.FormatInt(e.GuestPort, 10), | ||
| 180 | "host_port": strconv.FormatInt(e.HostPort, 10), | ||
| 181 | }) | ||
| 182 | a.hub.Poke(e.HostID) | ||
| 183 | a.notif.notify() | ||
| 184 | w.WriteHeader(http.StatusNoContent) | ||
| 185 | } | ||
internal/server/api/exposures_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,237 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "net/http" | ||
| 6 | "net/http/httptest" | ||
| 7 | "testing" | ||
| 8 | |||
| 9 | "github.com/a73x/eitri/internal/server/registry" | ||
| 10 | "github.com/a73x/eitri/internal/server/store" | ||
| 11 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // createTestVM places one VM on the enrolled host and returns its id. | ||
| 16 | func createTestVM(t *testing.T, ts *httptest.Server, hostID, name string) string { | ||
| 17 | t.Helper() | ||
| 18 | resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, | ||
| 19 | map[string]any{"host_id": hostID, "name": name}) | ||
| 20 | require.Equal(t, 201, resp.StatusCode) | ||
| 21 | var out map[string]string | ||
| 22 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) | ||
| 23 | return out["id"] | ||
| 24 | } | ||
| 25 | |||
| 26 | // patForOtherTenant JIT-provisions a second tenant and mints a PAT for it, so | ||
| 27 | // a test can act as somebody else entirely. | ||
| 28 | func patForOtherTenant(t *testing.T, st *store.Store) string { | ||
| 29 | t.Helper() | ||
| 30 | tn, err := st.CreateTenantForIdentity("https://test-issuer", "other-subject", "other@test.local") | ||
| 31 | require.NoError(t, err) | ||
| 32 | secret, _, err := st.CreateAPIToken(tn.ID, "other", 0) | ||
| 33 | require.NoError(t, err) | ||
| 34 | return secret | ||
| 35 | } | ||
| 36 | |||
| 37 | func TestCreateExposureAllocatesAndLists(t *testing.T) { | ||
| 38 | ts, _, _ := testServer(t) | ||
| 39 | host := enroll(t, ts) | ||
| 40 | vmID := createTestVM(t, ts, host["host_id"], "web-1") | ||
| 41 | |||
| 42 | resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, | ||
| 43 | map[string]any{"guest_port": 8080}) | ||
| 44 | require.Equal(t, 201, resp.StatusCode) | ||
| 45 | var e map[string]any | ||
| 46 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&e)) | ||
| 47 | assert.Equal(t, float64(8080), e["guest_port"]) | ||
| 48 | assert.GreaterOrEqual(t, e["host_port"], float64(30000)) | ||
| 49 | assert.LessOrEqual(t, e["host_port"], float64(32767)) | ||
| 50 | assert.Equal(t, "tcp", e["protocol"]) | ||
| 51 | assert.Equal(t, "lan", e["scope"]) | ||
| 52 | assert.Equal(t, "pending", e["state"], "no agent has reported on it yet") | ||
| 53 | assert.Equal(t, vmID, e["vm_id"]) | ||
| 54 | |||
| 55 | resp = do(t, "GET", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, nil) | ||
| 56 | require.Equal(t, 200, resp.StatusCode) | ||
| 57 | var list []map[string]any | ||
| 58 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&list)) | ||
| 59 | require.Len(t, list, 1) | ||
| 60 | assert.Equal(t, e["id"], list[0]["id"]) | ||
| 61 | } | ||
| 62 | |||
| 63 | func TestCreateExposureHonoursAndRefusesHostPorts(t *testing.T) { | ||
| 64 | ts, _, _ := testServer(t) | ||
| 65 | host := enroll(t, ts) | ||
| 66 | vmID := createTestVM(t, ts, host["host_id"], "web-1") | ||
| 67 | otherID := createTestVM(t, ts, host["host_id"], "web-2") | ||
| 68 | |||
| 69 | resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, | ||
| 70 | map[string]any{"guest_port": 8080, "host_port": 8443}) | ||
| 71 | require.Equal(t, 201, resp.StatusCode) | ||
| 72 | |||
| 73 | // The same port on the same host is taken. | ||
| 74 | resp = do(t, "POST", ts.URL+"/api/v1/vms/"+otherID+"/exposures", testPAT, | ||
| 75 | map[string]any{"guest_port": 9090, "host_port": 8443}) | ||
| 76 | assert.Equal(t, 409, resp.StatusCode) | ||
| 77 | } | ||
| 78 | |||
| 79 | func TestCreateExposureValidatesPorts(t *testing.T) { | ||
| 80 | ts, _, _ := testServer(t) | ||
| 81 | host := enroll(t, ts) | ||
| 82 | vmID := createTestVM(t, ts, host["host_id"], "web-1") | ||
| 83 | |||
| 84 | for _, tc := range []struct { | ||
| 85 | name string | ||
| 86 | body map[string]any | ||
| 87 | }{ | ||
| 88 | {"guest port zero", map[string]any{"guest_port": 0}}, | ||
| 89 | {"guest port too high", map[string]any{"guest_port": 65536}}, | ||
| 90 | {"guest port negative", map[string]any{"guest_port": -1}}, | ||
| 91 | {"privileged host port", map[string]any{"guest_port": 8080, "host_port": 443}}, | ||
| 92 | {"host port too high", map[string]any{"guest_port": 8080, "host_port": 65536}}, | ||
| 93 | } { | ||
| 94 | t.Run(tc.name, func(t *testing.T) { | ||
| 95 | resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, tc.body) | ||
| 96 | assert.Equal(t, 400, resp.StatusCode) | ||
| 97 | }) | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | func TestDeleteExposure(t *testing.T) { | ||
| 102 | ts, _, _ := testServer(t) | ||
| 103 | host := enroll(t, ts) | ||
| 104 | vmID := createTestVM(t, ts, host["host_id"], "web-1") | ||
| 105 | |||
| 106 | resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, | ||
| 107 | map[string]any{"guest_port": 8080}) | ||
| 108 | require.Equal(t, 201, resp.StatusCode) | ||
| 109 | var e map[string]any | ||
| 110 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&e)) | ||
| 111 | |||
| 112 | resp = do(t, "DELETE", ts.URL+"/api/v1/exposures/"+e["id"].(string), testPAT, nil) | ||
| 113 | assert.Equal(t, http.StatusNoContent, resp.StatusCode) | ||
| 114 | |||
| 115 | resp = do(t, "GET", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, nil) | ||
| 116 | var list []map[string]any | ||
| 117 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&list)) | ||
| 118 | assert.Empty(t, list) | ||
| 119 | |||
| 120 | // A second delete is a 404 — an exposure is a grant, and it is gone. | ||
| 121 | resp = do(t, "DELETE", ts.URL+"/api/v1/exposures/"+e["id"].(string), testPAT, nil) | ||
| 122 | assert.Equal(t, 404, resp.StatusCode) | ||
| 123 | } | ||
| 124 | |||
| 125 | func TestExposureRoutesAreTenantScoped(t *testing.T) { | ||
| 126 | ts, st, _ := testServer(t) | ||
| 127 | host := enroll(t, ts) | ||
| 128 | vmID := createTestVM(t, ts, host["host_id"], "web-1") | ||
| 129 | resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, | ||
| 130 | map[string]any{"guest_port": 8080}) | ||
| 131 | require.Equal(t, 201, resp.StatusCode) | ||
| 132 | var e map[string]any | ||
| 133 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&e)) | ||
| 134 | |||
| 135 | otherPAT := patForOtherTenant(t, st) | ||
| 136 | |||
| 137 | // A foreign tenant answers exactly like a missing one — existence is not | ||
| 138 | // leaked across tenants. | ||
| 139 | assert.Equal(t, 404, do(t, "GET", ts.URL+"/api/v1/vms/"+vmID+"/exposures", otherPAT, nil).StatusCode) | ||
| 140 | assert.Equal(t, 404, do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", otherPAT, | ||
| 141 | map[string]any{"guest_port": 9090}).StatusCode) | ||
| 142 | assert.Equal(t, 404, do(t, "DELETE", ts.URL+"/api/v1/exposures/"+e["id"].(string), otherPAT, nil).StatusCode) | ||
| 143 | } | ||
| 144 | |||
| 145 | // TestExposureReportedStateAndAddressReachTheWire pins the fold from a host's | ||
| 146 | // live report onto the exposures it was asked to run: the state and reason the | ||
| 147 | // agent last said, and the address the host answers on. The second exposure — | ||
| 148 | // present in the store, absent from the report — pins that the fold matches by | ||
| 149 | // exposure id rather than by position, so a host reporting on one listener | ||
| 150 | // cannot colour another one's state. | ||
| 151 | func TestExposureReportedStateAndAddressReachTheWire(t *testing.T) { | ||
| 152 | ts, st, _, reg, _ := newServer(t) | ||
| 153 | host := enroll(t, ts) | ||
| 154 | vmID := createTestVM(t, ts, host["host_id"], "web-1") | ||
| 155 | |||
| 156 | newExposure := func(guestPort, hostPort int64) string { | ||
| 157 | t.Helper() | ||
| 158 | resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, | ||
| 159 | map[string]any{"guest_port": guestPort, "host_port": hostPort}) | ||
| 160 | require.Equal(t, 201, resp.StatusCode) | ||
| 161 | var e map[string]any | ||
| 162 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&e)) | ||
| 163 | return e["id"].(string) | ||
| 164 | } | ||
| 165 | // Named host ports, so the list order (lowest host port first) is fixed. | ||
| 166 | reported := newExposure(8080, 30080) | ||
| 167 | newExposure(9090, 30081) | ||
| 168 | |||
| 169 | require.NoError(t, st.RecordHostUplink(host["host_id"], "192.168.0.190")) | ||
| 170 | reg.UpdateReport(host["host_id"], registry.Report{ | ||
| 171 | Exposures: []registry.ExposureStatus{{ | ||
| 172 | ID: reported, | ||
| 173 | State: "failed", | ||
| 174 | Reason: "listen tcp 0.0.0.0:30080: bind: address already in use", | ||
| 175 | }}, | ||
| 176 | }) | ||
| 177 | |||
| 178 | resp := do(t, "GET", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, nil) | ||
| 179 | require.Equal(t, 200, resp.StatusCode) | ||
| 180 | var list []map[string]any | ||
| 181 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&list)) | ||
| 182 | require.Len(t, list, 2) | ||
| 183 | |||
| 184 | assert.Equal(t, reported, list[0]["id"]) | ||
| 185 | assert.Equal(t, "failed", list[0]["state"]) | ||
| 186 | assert.Contains(t, list[0]["reason"], "address already in use") | ||
| 187 | |||
| 188 | // The unreported one keeps its own state — nothing has said what the host | ||
| 189 | // made of it. | ||
| 190 | assert.Equal(t, "pending", list[1]["state"]) | ||
| 191 | assert.Equal(t, "", list[1]["reason"]) | ||
| 192 | |||
| 193 | // The address is the host's, so every exposure on it names the same place. | ||
| 194 | for _, e := range list { | ||
| 195 | assert.Equal(t, "192.168.0.190", e["host_addr"]) | ||
| 196 | } | ||
| 197 | } | ||
| 198 | |||
| 199 | // TestCreateExposureOnTombstonedVMIsNotFound pins that a VM on its way out | ||
| 200 | // takes no new grants: the store refuses, and the refusal reads as a missing | ||
| 201 | // VM rather than a server error. | ||
| 202 | func TestCreateExposureOnTombstonedVMIsNotFound(t *testing.T) { | ||
| 203 | ts, st, _ := testServer(t) | ||
| 204 | host := enroll(t, ts) | ||
| 205 | vmID := createTestVM(t, ts, host["host_id"], "web-1") | ||
| 206 | |||
| 207 | require.NoError(t, st.TombstoneVM(vmID)) | ||
| 208 | |||
| 209 | resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, | ||
| 210 | map[string]any{"guest_port": 8080}) | ||
| 211 | assert.Equal(t, 404, resp.StatusCode) | ||
| 212 | } | ||
| 213 | |||
| 214 | func TestExposureCreateAndDeleteAreAudited(t *testing.T) { | ||
| 215 | ts, _, _ := testServer(t) | ||
| 216 | host := enroll(t, ts) | ||
| 217 | vmID := createTestVM(t, ts, host["host_id"], "web-1") | ||
| 218 | |||
| 219 | resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, | ||
| 220 | map[string]any{"guest_port": 8080}) | ||
| 221 | require.Equal(t, 201, resp.StatusCode) | ||
| 222 | var e map[string]any | ||
| 223 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&e)) | ||
| 224 | require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/exposures/"+e["id"].(string), testPAT, nil).StatusCode) | ||
| 225 | |||
| 226 | // Both rows key the VM as vm_id, so they land in that VM's own timeline. | ||
| 227 | resp = do(t, "GET", ts.URL+"/api/v1/vms/"+vmID+"/events", testPAT, nil) | ||
| 228 | require.Equal(t, 200, resp.StatusCode) | ||
| 229 | var events []map[string]any | ||
| 230 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&events)) | ||
| 231 | actions := map[string]bool{} | ||
| 232 | for _, ev := range events { | ||
| 233 | actions[ev["action"].(string)] = true | ||
| 234 | } | ||
| 235 | assert.True(t, actions["exposure.create"], "events: %v", actions) | ||
| 236 | assert.True(t, actions["exposure.delete"], "events: %v", actions) | ||
| 237 | } | ||
internal/server/api/routes.go
| Old | New | ||
|---|---|---|---|
| @@ -233,6 +233,39 @@ var routeTable = []Route{ | |||
| 233 | Doc: "One VM's lifecycle timeline (audit rows carrying its vm_id), newest first; survives the VM row being reaped.", | 233 | Doc: "One VM's lifecycle timeline (audit rows carrying its vm_id), newest first; survives the VM row being reaped.", |
| 234 | handler: (*API).handleListVMEvents, | 234 | handler: (*API).handleListVMEvents, |
| 235 | }, | 235 | }, |
| 236 | // Service exposure: a tenant publishes one TCP port of one VM, and the | ||
| 237 | // fleet binds it on that VM's host. There is no update verb — an exposure | ||
| 238 | // is a grant, not a document. | ||
| 239 | { | ||
| 240 | Method: "POST", | ||
| 241 | Path: "/api/v1/vms/{id}/exposures", | ||
| 242 | Auth: AuthUser, | ||
| 243 | Kind: KindJSON, | ||
| 244 | Request: (*types.CreateExposureRequest)(nil), | ||
| 245 | Response: (*types.Exposure)(nil), | ||
| 246 | Success: http.StatusCreated, | ||
| 247 | Doc: "Publish a guest TCP port on the VM's host. Omit host_port to allocate one from the reserved range 30000-32767; a named port must be >= 1024 and is honored or refused.", | ||
| 248 | handler: (*API).handleCreateExposure, | ||
| 249 | }, | ||
| 250 | { | ||
| 251 | Method: "GET", | ||
| 252 | Path: "/api/v1/vms/{id}/exposures", | ||
| 253 | Auth: AuthUser, | ||
| 254 | Kind: KindJSON, | ||
| 255 | Response: []types.Exposure(nil), | ||
| 256 | Success: http.StatusOK, | ||
| 257 | Doc: "List the VM's published ports, with the host address to dial and each listener's reported state.", | ||
| 258 | handler: (*API).handleListExposures, | ||
| 259 | }, | ||
| 260 | { | ||
| 261 | Method: "DELETE", | ||
| 262 | Path: "/api/v1/exposures/{id}", | ||
| 263 | Auth: AuthUser, | ||
| 264 | Kind: KindJSON, | ||
| 265 | Success: http.StatusNoContent, | ||
| 266 | Doc: "Revoke an exposure; its host closes the listener on the next converge.", | ||
| 267 | handler: (*API).handleDeleteExposure, | ||
| 268 | }, | ||
| 236 | // BYO per-tenant SSH user CAs: eitri stores only the CA pubkey and never | 269 | // BYO per-tenant SSH user CAs: eitri stores only the CA pubkey and never |
| 237 | // holds a user signing key. Tenant-scoped (caller must act for {tenant}). | 270 | // holds a user signing key. Tenant-scoped (caller must act for {tenant}). |
| 238 | { | 271 | { |
internal/server/api/routes_test.go
| Old | New | ||
|---|---|---|---|
| @@ -34,7 +34,7 @@ func exemplarElem(t *testing.T, route Route, role string, v any) reflect.Type { | |||
| 34 | // `required` array for request schemas, so a type serving both roles would | 34 | // `required` array for request schemas, so a type serving both roles would |
| 35 | // get the wrong treatment on one of them). | 35 | // get the wrong treatment on one of them). |
| 36 | func TestRouteTable(t *testing.T) { | 36 | func TestRouteTable(t *testing.T) { |
| 37 | const wantRoutes = 28 | 37 | const wantRoutes = 31 |
| 38 | if len(routeTable) != wantRoutes { | 38 | if len(routeTable) != wantRoutes { |
| 39 | t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes) | 39 | t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes) |
| 40 | } | 40 | } |
internal/server/api/testdata/create-exposure-request.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,4 @@ | |||
| 1 | { | ||
| 2 | "guest_port": 8080, | ||
| 3 | "host_port": 30080 | ||
| 4 | } | ||
internal/server/api/testdata/exposure.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,15 @@ | |||
| 1 | [ | ||
| 2 | { | ||
| 3 | "id": "x-9012", | ||
| 4 | "vm_id": "v-5678", | ||
| 5 | "host_id": "h-1234", | ||
| 6 | "guest_port": 8080, | ||
| 7 | "host_port": 30080, | ||
| 8 | "host_addr": "192.168.0.190", | ||
| 9 | "protocol": "tcp", | ||
| 10 | "scope": "lan", | ||
| 11 | "state": "failed", | ||
| 12 | "reason": "listen tcp 0.0.0.0:30080: bind: address already in use", | ||
| 13 | "created_at": "2026-07-27T12:04:00Z" | ||
| 14 | } | ||
| 15 | ] | ||
internal/server/api/testdata/host.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -30,6 +30,7 @@ | |||
| 30 | "kernel": "6.15.4-arch1-1", | 30 | "kernel": "6.15.4-arch1-1", |
| 31 | "cpu_model": "AMD Ryzen 9 7950X", | 31 | "cpu_model": "AMD Ryzen 9 7950X", |
| 32 | "virt": "kvm", | 32 | "virt": "kvm", |
| 33 | "uplink_addr": "192.168.0.190", | ||
| 33 | "metrics": { | 34 | "metrics": { |
| 34 | "uptime_s": 86400, | 35 | "uptime_s": 86400, |
| 35 | "mem_used_mb": 12000, | 36 | "mem_used_mb": 12000, |
internal/server/api/testdata/snapshot.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -32,6 +32,7 @@ | |||
| 32 | "kernel": "6.15.4-arch1-1", | 32 | "kernel": "6.15.4-arch1-1", |
| 33 | "cpu_model": "AMD Ryzen 9 7950X", | 33 | "cpu_model": "AMD Ryzen 9 7950X", |
| 34 | "virt": "kvm", | 34 | "virt": "kvm", |
| 35 | "uplink_addr": "192.168.0.190", | ||
| 35 | "metrics": { | 36 | "metrics": { |
| 36 | "uptime_s": 86400, | 37 | "uptime_s": 86400, |
| 37 | "mem_used_mb": 12000, | 38 | "mem_used_mb": 12000, |
internal/server/api/types/types.go
| Old | New | ||
|---|---|---|---|
| @@ -75,6 +75,10 @@ type Host struct { | |||
| 75 | Kernel string `json:"kernel"` | 75 | Kernel string `json:"kernel"` |
| 76 | CPUModel string `json:"cpu_model"` | 76 | CPUModel string `json:"cpu_model"` |
| 77 | Virt string `json:"virt"` | 77 | Virt string `json:"virt"` |
| 78 | // UplinkAddr is the address this host answers on, as the host reports it — | ||
| 79 | // the address to dial for a published guest port. Empty until the host has | ||
| 80 | // said. | ||
| 81 | UplinkAddr string `json:"uplink_addr"` | ||
| 78 | // Metrics is live MEASURED utilization, present only when online. Distinct | 82 | // Metrics is live MEASURED utilization, present only when online. Distinct |
| 79 | // from Allocated (control-plane bookkeeping / VM quotas). | 83 | // from Allocated (control-plane bookkeeping / VM quotas). |
| 80 | Metrics *Metrics `json:"metrics"` | 84 | Metrics *Metrics `json:"metrics"` |
| @@ -222,6 +226,36 @@ type RevokeSSHCertRequest struct { | |||
| 222 | Reason string `json:"reason"` | 226 | Reason string `json:"reason"` |
| 223 | } | 227 | } |
| 224 | 228 | ||
| 229 | // CreateExposureRequest is the POST /api/v1/vms/{id}/exposures body: publish | ||
| 230 | // guest_port of that VM. host_port is optional — omitted (or 0) allocates one | ||
| 231 | // from the reserved range 30000-32767; a named port must be >= 1024 and is | ||
| 232 | // honored or refused. Request-side only. | ||
| 233 | type CreateExposureRequest struct { | ||
| 234 | GuestPort int64 `json:"guest_port"` | ||
| 235 | HostPort int64 `json:"host_port"` // 0 = allocate | ||
| 236 | } | ||
| 237 | |||
| 238 | // Exposure is one published guest port, served by | ||
| 239 | // POST and GET /api/v1/vms/{id}/exposures. HostAddr and State are | ||
| 240 | // server-derived: the address comes from the host's own report, and the state | ||
| 241 | // from what its agent last said its listener is doing — "pending" until an | ||
| 242 | // agent has reported at all, then "active" (the listener is bound) or "failed" | ||
| 243 | // with the OS error in Reason. "active" describes the host half of the pipe; | ||
| 244 | // whether anything answers inside the guest is the guest's business. | ||
| 245 | type Exposure struct { | ||
| 246 | ID string `json:"id"` | ||
| 247 | VMID string `json:"vm_id"` | ||
| 248 | HostID string `json:"host_id"` | ||
| 249 | GuestPort int64 `json:"guest_port"` | ||
| 250 | HostPort int64 `json:"host_port"` | ||
| 251 | HostAddr string `json:"host_addr"` | ||
| 252 | Protocol string `json:"protocol"` | ||
| 253 | Scope string `json:"scope"` | ||
| 254 | State string `json:"state"` | ||
| 255 | Reason string `json:"reason"` | ||
| 256 | CreatedAt time.Time `json:"created_at"` | ||
| 257 | } | ||
| 258 | |||
| 225 | // The response shapes below replace handlers' inline map[string]string | 259 | // The response shapes below replace handlers' inline map[string]string |
| 226 | // literals. Their fields are ordered ALPHABETICALLY BY JSON KEY on purpose: | 260 | // literals. Their fields are ordered ALPHABETICALLY BY JSON KEY on purpose: |
| 227 | // encoding/json marshals map keys sorted, so keeping struct fields in that | 261 | // encoding/json marshals map keys sorted, so keeping struct fields in that |
internal/server/api/wire_golden_test.go
| Old | New | ||
|---|---|---|---|
| @@ -75,6 +75,7 @@ func TestWireGolden(t *testing.T) { | |||
| 75 | Kernel: "6.15.4-arch1-1", | 75 | Kernel: "6.15.4-arch1-1", |
| 76 | CPUModel: "AMD Ryzen 9 7950X", | 76 | CPUModel: "AMD Ryzen 9 7950X", |
| 77 | Virt: "kvm", | 77 | Virt: "kvm", |
| 78 | UplinkAddr: "192.168.0.190", | ||
| 78 | Metrics: &types.Metrics{ | 79 | Metrics: &types.Metrics{ |
| 79 | UptimeS: 86400, | 80 | UptimeS: 86400, |
| 80 | MemUsedMB: 12000, | 81 | MemUsedMB: 12000, |
| @@ -240,4 +241,23 @@ func TestWireGolden(t *testing.T) { | |||
| 240 | Name: "boot-gate", | 241 | Name: "boot-gate", |
| 241 | RevokedAt: "", | 242 | RevokedAt: "", |
| 242 | }}) | 243 | }}) |
| 244 | |||
| 245 | goldenCheck(t, "create-exposure-request", types.CreateExposureRequest{ | ||
| 246 | GuestPort: 8080, | ||
| 247 | HostPort: 30080, | ||
| 248 | }) | ||
| 249 | |||
| 250 | goldenCheck(t, "exposure", []types.Exposure{{ | ||
| 251 | ID: "x-9012", | ||
| 252 | VMID: "v-5678", | ||
| 253 | HostID: "h-1234", | ||
| 254 | GuestPort: 8080, | ||
| 255 | HostPort: 30080, | ||
| 256 | HostAddr: "192.168.0.190", | ||
| 257 | Protocol: "tcp", | ||
| 258 | Scope: "lan", | ||
| 259 | State: "failed", | ||
| 260 | Reason: "listen tcp 0.0.0.0:30080: bind: address already in use", | ||
| 261 | CreatedAt: base.Add(4 * time.Minute), | ||
| 262 | }}) | ||
| 243 | } | 263 | } |
internal/server/registry/registry.go
| Old | New | ||
|---|---|---|---|
| @@ -46,9 +46,17 @@ type QuarantinedVM struct { | |||
| 46 | DestroyAtUnix int64 | 46 | DestroyAtUnix int64 |
| 47 | } | 47 | } |
| 48 | 48 | ||
| 49 | // ExposureStatus is one exposure's live state as its host's agent reports it: | ||
| 50 | // "active" once the host listener is bound, "failed" with the OS error | ||
| 51 | // otherwise. Never persisted — like Metrics, it lives only while the host is | ||
| 52 | // connected, and an exposure with no row here has simply not been reported on | ||
| 53 | // yet. | ||
| 54 | type ExposureStatus struct{ ID, State, Reason string } | ||
| 55 | |||
| 49 | type Report struct { | 56 | type Report struct { |
| 50 | VMs []ActualVM | 57 | VMs []ActualVM |
| 51 | Quarantined []QuarantinedVM | 58 | Quarantined []QuarantinedVM |
| 59 | Exposures []ExposureStatus | ||
| 52 | Capacity Capacity | 60 | Capacity Capacity |
| 53 | Metrics Metrics | 61 | Metrics Metrics |
| 54 | FenceViolation bool | 62 | FenceViolation bool |
| @@ -136,6 +144,7 @@ func (r *Registry) Get(hostID string) (HostState, bool) { | |||
| 136 | } | 144 | } |
| 137 | // Deep-copy slices so callers cannot corrupt registry state. | 145 | // Deep-copy slices so callers cannot corrupt registry state. |
| 138 | st.Report.VMs = slices.Clone(st.Report.VMs) | 146 | st.Report.VMs = slices.Clone(st.Report.VMs) |
| 147 | st.Report.Exposures = slices.Clone(st.Report.Exposures) | ||
| 139 | quarantined := slices.Clone(st.Report.Quarantined) | 148 | quarantined := slices.Clone(st.Report.Quarantined) |
| 140 | for i := range quarantined { | 149 | for i := range quarantined { |
| 141 | quarantined[i].VMSpecJSON = bytes.Clone(quarantined[i].VMSpecJSON) | 150 | quarantined[i].VMSpecJSON = bytes.Clone(quarantined[i].VMSpecJSON) |
internal/server/registry/registry_test.go
| Old | New | ||
|---|---|---|---|
| @@ -131,3 +131,17 @@ func TestAgentVersionSurvivesReports(t *testing.T) { | |||
| 131 | t.Fatalf("AgentVersion = %q ok=%v, want v0.0.2 true", st.AgentVersion, ok) | 131 | t.Fatalf("AgentVersion = %q ok=%v, want v0.0.2 true", st.AgentVersion, ok) |
| 132 | } | 132 | } |
| 133 | } | 133 | } |
| 134 | |||
| 135 | // TestGetClonesExposureStatuses pins that a caller cannot corrupt registry | ||
| 136 | // state through the slice it is handed — the same rule the VM rows follow. | ||
| 137 | func TestGetClonesExposureStatuses(t *testing.T) { | ||
| 138 | r := New(time.Now) | ||
| 139 | r.UpdateReport("h1", Report{Exposures: []ExposureStatus{{ID: "e1", State: "active"}}}) | ||
| 140 | |||
| 141 | got, ok := r.Get("h1") | ||
| 142 | require.True(t, ok) | ||
| 143 | got.Report.Exposures[0].State = "clobbered" | ||
| 144 | |||
| 145 | again, _ := r.Get("h1") | ||
| 146 | assert.Equal(t, "active", again.Report.Exposures[0].State) | ||
| 147 | } | ||
internal/server/store/exposures.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,209 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | "time" | ||
| 8 | |||
| 9 | "github.com/a73x/eitri/internal/random" | ||
| 10 | sqlite "modernc.org/sqlite" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // Exposure is one published guest port: the fleet binds HostPort on the VM's | ||
| 14 | // host and pipes every accepted connection to GuestPort inside the guest. | ||
| 15 | // Tenant and HostID are both derived from the VM at create — never accepted | ||
| 16 | // from a caller — so an exposure can only ever name the partition and the | ||
| 17 | // machine its VM already lives in. | ||
| 18 | type Exposure struct { | ||
| 19 | ID, Tenant, VMID, HostID string | ||
| 20 | GuestPort, HostPort int64 | ||
| 21 | Protocol, Scope string | ||
| 22 | CreatedAt time.Time | ||
| 23 | } | ||
| 24 | |||
| 25 | // The reserved host-port range. It is the host contract: one documented span | ||
| 26 | // that belongs to eitri on every host, so allocating a port needs no per-port | ||
| 27 | // hygiene reasoning anywhere else. A caller that names its own port is not | ||
| 28 | // held to it — only to the 1024 floor the API enforces. | ||
| 29 | const ( | ||
| 30 | MinAllocatedHostPort = 30000 | ||
| 31 | MaxAllocatedHostPort = 32767 | ||
| 32 | ) | ||
| 33 | |||
| 34 | // ErrExposureVMNotFound reports that the VM an exposure would belong to does | ||
| 35 | // not exist, or is tombstoned — a VM being torn down takes no new exposures. | ||
| 36 | var ErrExposureVMNotFound = errors.New("vm not found") | ||
| 37 | |||
| 38 | // ErrHostPortTaken reports that another exposure already holds the requested | ||
| 39 | // host port on that host. | ||
| 40 | var ErrHostPortTaken = errors.New("host port already exposed on this host") | ||
| 41 | |||
| 42 | // ErrNoFreeHostPort reports that the reserved range is fully allocated on the | ||
| 43 | // host, so there is nothing left to hand out. | ||
| 44 | var ErrNoFreeHostPort = errors.New("no free host port in the reserved range") | ||
| 45 | |||
| 46 | // exposureColumns is the positional column list every exposure SELECT uses, so | ||
| 47 | // the order stays locked to scanExposure's positional Scan. Every column is | ||
| 48 | // table-qualified because one of the reads joins vms — the alias `e` is part | ||
| 49 | // of the contract each `from` clause below keeps. | ||
| 50 | const exposureColumns = `e.id, e.tenant, e.vm_id, e.host_id, e.guest_port, e.host_port, e.protocol, e.scope, e.created_at` | ||
| 51 | |||
| 52 | // scanExposure's column order must match exposureColumns exactly — it is | ||
| 53 | // positional, not name-based. | ||
| 54 | func scanExposure(rows *sql.Rows) (Exposure, error) { | ||
| 55 | var e Exposure | ||
| 56 | var createdAt string | ||
| 57 | if err := rows.Scan(&e.ID, &e.Tenant, &e.VMID, &e.HostID, | ||
| 58 | &e.GuestPort, &e.HostPort, &e.Protocol, &e.Scope, &createdAt); err != nil { | ||
| 59 | return Exposure{}, err | ||
| 60 | } | ||
| 61 | e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) | ||
| 62 | return e, nil | ||
| 63 | } | ||
| 64 | |||
| 65 | // queryExposures runs an exposureColumns-projected SELECT, with from appended | ||
| 66 | // verbatim after the column list (it carries the FROM clause, any join, the | ||
| 67 | // WHERE and the ORDER BY) and args bound in order. | ||
| 68 | func queryExposures(q querier, from string, args ...any) ([]Exposure, error) { | ||
| 69 | rows, err := q.Query(`SELECT `+exposureColumns+` `+from, args...) | ||
| 70 | if err != nil { | ||
| 71 | return nil, err | ||
| 72 | } | ||
| 73 | defer rows.Close() | ||
| 74 | var out []Exposure | ||
| 75 | for rows.Next() { | ||
| 76 | e, err := scanExposure(rows) | ||
| 77 | if err != nil { | ||
| 78 | return nil, err | ||
| 79 | } | ||
| 80 | out = append(out, e) | ||
| 81 | } | ||
| 82 | return out, rows.Err() | ||
| 83 | } | ||
| 84 | |||
| 85 | // CreateExposure publishes guestPort of vmID on that VM's host. hostPort 0 asks | ||
| 86 | // for one from the reserved range; a named port is honored or refused. The | ||
| 87 | // whole decision — which host, which tenant, which port — happens in one | ||
| 88 | // transaction, so two concurrent creates cannot agree on the same port. | ||
| 89 | func (s *Store) CreateExposure(vmID string, guestPort, hostPort int64) (Exposure, error) { | ||
| 90 | tx, err := s.db.Begin() | ||
| 91 | if err != nil { | ||
| 92 | return Exposure{}, err | ||
| 93 | } | ||
| 94 | defer tx.Rollback() | ||
| 95 | |||
| 96 | // The VM read is the derivation point: an exposure's tenant is ALWAYS its | ||
| 97 | // VM's, and its host is the host the VM is placed on. A tombstoned VM is | ||
| 98 | // treated as absent — its guest is on its way out. | ||
| 99 | var tenant, hostID string | ||
| 100 | switch err := tx.QueryRow( | ||
| 101 | `SELECT tenant, host_id FROM vms WHERE id=? AND deleted_at IS NULL`, vmID, | ||
| 102 | ).Scan(&tenant, &hostID); { | ||
| 103 | case errors.Is(err, sql.ErrNoRows): | ||
| 104 | return Exposure{}, ErrExposureVMNotFound | ||
| 105 | case err != nil: | ||
| 106 | return Exposure{}, fmt.Errorf("lookup vm: %w", err) | ||
| 107 | } | ||
| 108 | |||
| 109 | if hostPort == 0 { | ||
| 110 | hostPort, err = allocateHostPort(tx, hostID) | ||
| 111 | if err != nil { | ||
| 112 | return Exposure{}, err | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | e := Exposure{ | ||
| 117 | ID: random.Hex(16), Tenant: tenant, VMID: vmID, HostID: hostID, | ||
| 118 | GuestPort: guestPort, HostPort: hostPort, | ||
| 119 | Protocol: "tcp", Scope: "lan", CreatedAt: time.Now().UTC(), | ||
| 120 | } | ||
| 121 | if _, err := tx.Exec( | ||
| 122 | `INSERT INTO exposures(id, tenant, vm_id, host_id, guest_port, host_port, protocol, scope, created_at) | ||
| 123 | VALUES (?,?,?,?,?,?,?,?,?)`, | ||
| 124 | e.ID, e.Tenant, e.VMID, e.HostID, e.GuestPort, e.HostPort, e.Protocol, e.Scope, | ||
| 125 | e.CreatedAt.Format(time.RFC3339), | ||
| 126 | ); err != nil { | ||
| 127 | // SQLITE_CONSTRAINT_UNIQUE (2067): the only UNIQUE constraint this | ||
| 128 | // insert can trip besides the random-hex PK is exposures_host_port. | ||
| 129 | // Matched by errno, not message text, which embeds the index's column | ||
| 130 | // list and breaks on the next index change. | ||
| 131 | if serr, ok := errors.AsType[*sqlite.Error](err); ok && serr.Code() == 2067 { | ||
| 132 | return Exposure{}, ErrHostPortTaken | ||
| 133 | } | ||
| 134 | return Exposure{}, fmt.Errorf("insert exposure: %w", err) | ||
| 135 | } | ||
| 136 | |||
| 137 | if err := bumpEpoch(tx); err != nil { | ||
| 138 | return Exposure{}, fmt.Errorf("bump epoch: %w", err) | ||
| 139 | } | ||
| 140 | if err := tx.Commit(); err != nil { | ||
| 141 | return Exposure{}, err | ||
| 142 | } | ||
| 143 | return e, nil | ||
| 144 | } | ||
| 145 | |||
| 146 | // allocateHostPort returns the lowest free port of the reserved range on | ||
| 147 | // hostID. It reads the range's held ports into memory before choosing, because | ||
| 148 | // the store runs on a single connection: an open result set would block the | ||
| 149 | // insert that follows in the same transaction. | ||
| 150 | func allocateHostPort(tx *sql.Tx, hostID string) (int64, error) { | ||
| 151 | rows, err := tx.Query( | ||
| 152 | `SELECT host_port FROM exposures WHERE host_id=? AND host_port BETWEEN ? AND ?`, | ||
| 153 | hostID, MinAllocatedHostPort, MaxAllocatedHostPort) | ||
| 154 | if err != nil { | ||
| 155 | return 0, fmt.Errorf("read allocated host ports: %w", err) | ||
| 156 | } | ||
| 157 | defer rows.Close() | ||
| 158 | held := map[int64]bool{} | ||
| 159 | for rows.Next() { | ||
| 160 | var p int64 | ||
| 161 | if err := rows.Scan(&p); err != nil { | ||
| 162 | return 0, err | ||
| 163 | } | ||
| 164 | held[p] = true | ||
| 165 | } | ||
| 166 | if err := rows.Err(); err != nil { | ||
| 167 | return 0, err | ||
| 168 | } | ||
| 169 | |||
| 170 | for p := int64(MinAllocatedHostPort); p <= MaxAllocatedHostPort; p++ { | ||
| 171 | if !held[p] { | ||
| 172 | return p, nil | ||
| 173 | } | ||
| 174 | } | ||
| 175 | return 0, ErrNoFreeHostPort | ||
| 176 | } | ||
| 177 | |||
| 178 | // DeleteExposure revokes an exposure. sql.ErrNoRows when there is none with | ||
| 179 | // that id. Bumps the epoch so the host's agent closes the listener. | ||
| 180 | func (s *Store) DeleteExposure(id string) error { | ||
| 181 | return s.mutate(`DELETE FROM exposures WHERE id=?`, id) | ||
| 182 | } | ||
| 183 | |||
| 184 | // GetExposure returns one exposure by id. sql.ErrNoRows when absent. | ||
| 185 | func (s *Store) GetExposure(id string) (Exposure, error) { | ||
| 186 | out, err := queryExposures(s.db, `FROM exposures e WHERE e.id=?`, id) | ||
| 187 | if err != nil { | ||
| 188 | return Exposure{}, err | ||
| 189 | } | ||
| 190 | if len(out) == 0 { | ||
| 191 | return Exposure{}, sql.ErrNoRows | ||
| 192 | } | ||
| 193 | return out[0], nil | ||
| 194 | } | ||
| 195 | |||
| 196 | // ListExposuresForVM returns one VM's exposures, lowest host port first. | ||
| 197 | func (s *Store) ListExposuresForVM(vmID string) ([]Exposure, error) { | ||
| 198 | return queryExposures(s.db, `FROM exposures e WHERE e.vm_id=? ORDER BY e.host_port`, vmID) | ||
| 199 | } | ||
| 200 | |||
| 201 | // ListExposuresForHost returns the exposures a host's agent should be running: | ||
| 202 | // its own, for VMs that are not tombstoned. A VM on its way out has already | ||
| 203 | // stopped being somewhere to send traffic, so its listener goes before its row | ||
| 204 | // does. | ||
| 205 | func (s *Store) ListExposuresForHost(hostID string) ([]Exposure, error) { | ||
| 206 | return queryExposures(s.db, | ||
| 207 | `FROM exposures e JOIN vms v ON v.id = e.vm_id | ||
| 208 | WHERE e.host_id=? AND v.deleted_at IS NULL ORDER BY e.host_port`, hostID) | ||
| 209 | } | ||
internal/server/store/exposures_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,257 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "strconv" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "github.com/stretchr/testify/assert" | ||
| 9 | "github.com/stretchr/testify/require" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // makeExposureVM enrolls nothing new: it places one live VM on h so an exposure | ||
| 13 | // has something to belong to. | ||
| 14 | func makeExposureVM(t *testing.T, s *Store, h Host, name string) VM { | ||
| 15 | t.Helper() | ||
| 16 | return vmWithResources(t, s, h, name, 1, 512, 5) | ||
| 17 | } | ||
| 18 | |||
| 19 | func TestCreateExposureAllocatesFromTheReservedRange(t *testing.T) { | ||
| 20 | s := newStore(t) | ||
| 21 | h := enrollHost(t, s) | ||
| 22 | vm := makeExposureVM(t, s, h, "web-1") | ||
| 23 | |||
| 24 | e, err := s.CreateExposure(vm.ID, 8080, 0) | ||
| 25 | require.NoError(t, err) | ||
| 26 | assert.Equal(t, vm.ID, e.VMID) | ||
| 27 | assert.Equal(t, h.ID, e.HostID) | ||
| 28 | assert.Equal(t, h.Tenant, e.Tenant, "an exposure's tenant is always its VM's") | ||
| 29 | assert.Equal(t, int64(8080), e.GuestPort) | ||
| 30 | assert.Equal(t, int64(MinAllocatedHostPort), e.HostPort) | ||
| 31 | assert.Equal(t, "tcp", e.Protocol) | ||
| 32 | assert.Equal(t, "lan", e.Scope) | ||
| 33 | assert.NotEmpty(t, e.ID) | ||
| 34 | } | ||
| 35 | |||
| 36 | func TestCreateExposureAllocatesTheLowestFreePort(t *testing.T) { | ||
| 37 | s := newStore(t) | ||
| 38 | h := enrollHost(t, s) | ||
| 39 | vm := makeExposureVM(t, s, h, "web-1") | ||
| 40 | |||
| 41 | first, err := s.CreateExposure(vm.ID, 8080, 0) | ||
| 42 | require.NoError(t, err) | ||
| 43 | second, err := s.CreateExposure(vm.ID, 8081, 0) | ||
| 44 | require.NoError(t, err) | ||
| 45 | assert.Equal(t, int64(MinAllocatedHostPort), first.HostPort) | ||
| 46 | assert.Equal(t, int64(MinAllocatedHostPort+1), second.HostPort) | ||
| 47 | |||
| 48 | // A freed port is the lowest gap, so it is handed out again. | ||
| 49 | require.NoError(t, s.DeleteExposure(first.ID)) | ||
| 50 | third, err := s.CreateExposure(vm.ID, 8082, 0) | ||
| 51 | require.NoError(t, err) | ||
| 52 | assert.Equal(t, int64(MinAllocatedHostPort), third.HostPort) | ||
| 53 | } | ||
| 54 | |||
| 55 | func TestCreateExposureHonoursARequestedPort(t *testing.T) { | ||
| 56 | s := newStore(t) | ||
| 57 | h := enrollHost(t, s) | ||
| 58 | vm := makeExposureVM(t, s, h, "web-1") | ||
| 59 | |||
| 60 | e, err := s.CreateExposure(vm.ID, 8080, 8443) | ||
| 61 | require.NoError(t, err) | ||
| 62 | assert.Equal(t, int64(8443), e.HostPort) | ||
| 63 | } | ||
| 64 | |||
| 65 | func TestCreateExposureRefusesAPortAnotherExposureHolds(t *testing.T) { | ||
| 66 | s := newStore(t) | ||
| 67 | h := enrollHost(t, s) | ||
| 68 | vm := makeExposureVM(t, s, h, "web-1") | ||
| 69 | other := makeExposureVM(t, s, h, "web-2") | ||
| 70 | |||
| 71 | _, err := s.CreateExposure(vm.ID, 8080, 8443) | ||
| 72 | require.NoError(t, err) | ||
| 73 | |||
| 74 | _, err = s.CreateExposure(other.ID, 9090, 8443) | ||
| 75 | assert.ErrorIs(t, err, ErrHostPortTaken, "the unique index IS the collision check") | ||
| 76 | } | ||
| 77 | |||
| 78 | func TestCreateExposureAllowsTheSamePortOnAnotherHost(t *testing.T) { | ||
| 79 | s := newStore(t) | ||
| 80 | h1 := enrollHost(t, s) | ||
| 81 | tok, err := s.CreateEnrollmentToken(testTenant) | ||
| 82 | require.NoError(t, err) | ||
| 83 | h2, err := s.RedeemEnrollmentToken(tok, EnrollFacts{Name: "h2", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: ""}) | ||
| 84 | require.NoError(t, err) | ||
| 85 | |||
| 86 | vm1 := makeExposureVM(t, s, h1, "web-1") | ||
| 87 | vm2 := makeExposureVM(t, s, h2, "web-2") | ||
| 88 | |||
| 89 | _, err = s.CreateExposure(vm1.ID, 8080, 8443) | ||
| 90 | require.NoError(t, err) | ||
| 91 | _, err = s.CreateExposure(vm2.ID, 8080, 8443) | ||
| 92 | assert.NoError(t, err, "the port is unique per host, not per fleet") | ||
| 93 | } | ||
| 94 | |||
| 95 | func TestCreateExposureRefusesAnUnknownOrTombstonedVM(t *testing.T) { | ||
| 96 | s := newStore(t) | ||
| 97 | h := enrollHost(t, s) | ||
| 98 | vm := makeExposureVM(t, s, h, "web-1") | ||
| 99 | |||
| 100 | _, err := s.CreateExposure("no-such-vm", 8080, 0) | ||
| 101 | assert.ErrorIs(t, err, ErrExposureVMNotFound) | ||
| 102 | |||
| 103 | require.NoError(t, s.TombstoneVM(vm.ID)) | ||
| 104 | _, err = s.CreateExposure(vm.ID, 8080, 0) | ||
| 105 | assert.ErrorIs(t, err, ErrExposureVMNotFound, "a VM being torn down takes no new exposures") | ||
| 106 | } | ||
| 107 | |||
| 108 | func TestCreateExposureReportsRangeExhaustion(t *testing.T) { | ||
| 109 | s := newStore(t) | ||
| 110 | h := enrollHost(t, s) | ||
| 111 | vm := makeExposureVM(t, s, h, "web-1") | ||
| 112 | |||
| 113 | // Fill the whole reserved range by hand — CreateExposure would be 2768 | ||
| 114 | // transactions. | ||
| 115 | for p := int64(MinAllocatedHostPort); p <= MaxAllocatedHostPort; p++ { | ||
| 116 | _, err := s.db.Exec( | ||
| 117 | `INSERT INTO exposures(id, tenant, vm_id, host_id, guest_port, host_port, protocol, scope, created_at) | ||
| 118 | VALUES (?,?,?,?,?,?,'tcp','lan','2026-08-05T00:00:00Z')`, | ||
| 119 | "fill-"+strconv.FormatInt(p, 10), h.Tenant, vm.ID, h.ID, 8080, p) | ||
| 120 | require.NoError(t, err) | ||
| 121 | } | ||
| 122 | |||
| 123 | _, err := s.CreateExposure(vm.ID, 8080, 0) | ||
| 124 | assert.ErrorIs(t, err, ErrNoFreeHostPort) | ||
| 125 | } | ||
| 126 | |||
| 127 | func TestCreateExposureBumpsTheEpoch(t *testing.T) { | ||
| 128 | s := newStore(t) | ||
| 129 | h := enrollHost(t, s) | ||
| 130 | vm := makeExposureVM(t, s, h, "web-1") | ||
| 131 | |||
| 132 | before, err := s.Epoch() | ||
| 133 | require.NoError(t, err) | ||
| 134 | _, err = s.CreateExposure(vm.ID, 8080, 0) | ||
| 135 | require.NoError(t, err) | ||
| 136 | after, err := s.Epoch() | ||
| 137 | require.NoError(t, err) | ||
| 138 | assert.Greater(t, after, before, "exposures are desired state; agents must re-snapshot") | ||
| 139 | } | ||
| 140 | |||
| 141 | func TestCreateExposureCountsANamedPortInTheReservedRange(t *testing.T) { | ||
| 142 | s := newStore(t) | ||
| 143 | h := enrollHost(t, s) | ||
| 144 | vm := makeExposureVM(t, s, h, "web-1") | ||
| 145 | |||
| 146 | named, err := s.CreateExposure(vm.ID, 8080, MinAllocatedHostPort) | ||
| 147 | require.NoError(t, err) | ||
| 148 | assert.Equal(t, int64(MinAllocatedHostPort), named.HostPort) | ||
| 149 | |||
| 150 | auto, err := s.CreateExposure(vm.ID, 8081, 0) | ||
| 151 | require.NoError(t, err) | ||
| 152 | assert.Equal(t, int64(MinAllocatedHostPort+1), auto.HostPort, "a named port inside the reserved range is held against auto-allocation") | ||
| 153 | } | ||
| 154 | |||
| 155 | func TestListExposuresForVMIsPortOrdered(t *testing.T) { | ||
| 156 | s := newStore(t) | ||
| 157 | h := enrollHost(t, s) | ||
| 158 | vm := makeExposureVM(t, s, h, "web-1") | ||
| 159 | other := makeExposureVM(t, s, h, "web-2") | ||
| 160 | |||
| 161 | _, err := s.CreateExposure(vm.ID, 8081, 31000) | ||
| 162 | require.NoError(t, err) | ||
| 163 | _, err = s.CreateExposure(vm.ID, 8080, 30500) | ||
| 164 | require.NoError(t, err) | ||
| 165 | _, err = s.CreateExposure(other.ID, 9090, 30001) | ||
| 166 | require.NoError(t, err) | ||
| 167 | |||
| 168 | got, err := s.ListExposuresForVM(vm.ID) | ||
| 169 | require.NoError(t, err) | ||
| 170 | require.Len(t, got, 2, "another VM's exposures are not this VM's") | ||
| 171 | assert.Equal(t, int64(30500), got[0].HostPort) | ||
| 172 | assert.Equal(t, int64(31000), got[1].HostPort) | ||
| 173 | } | ||
| 174 | |||
| 175 | func TestListExposuresForHostSkipsTombstonedVMs(t *testing.T) { | ||
| 176 | s := newStore(t) | ||
| 177 | h := enrollHost(t, s) | ||
| 178 | live := makeExposureVM(t, s, h, "web-1") | ||
| 179 | dying := makeExposureVM(t, s, h, "web-2") | ||
| 180 | |||
| 181 | _, err := s.CreateExposure(live.ID, 8080, 30001) | ||
| 182 | require.NoError(t, err) | ||
| 183 | _, err = s.CreateExposure(dying.ID, 8080, 30002) | ||
| 184 | require.NoError(t, err) | ||
| 185 | |||
| 186 | require.NoError(t, s.TombstoneVM(dying.ID)) | ||
| 187 | |||
| 188 | got, err := s.ListExposuresForHost(h.ID) | ||
| 189 | require.NoError(t, err) | ||
| 190 | require.Len(t, got, 1, "no listener outlives the thing it points at") | ||
| 191 | assert.Equal(t, live.ID, got[0].VMID) | ||
| 192 | } | ||
| 193 | |||
| 194 | func TestGetExposureAndDelete(t *testing.T) { | ||
| 195 | s := newStore(t) | ||
| 196 | h := enrollHost(t, s) | ||
| 197 | vm := makeExposureVM(t, s, h, "web-1") | ||
| 198 | |||
| 199 | e, err := s.CreateExposure(vm.ID, 8080, 0) | ||
| 200 | require.NoError(t, err) | ||
| 201 | |||
| 202 | got, err := s.GetExposure(e.ID) | ||
| 203 | require.NoError(t, err) | ||
| 204 | assert.Equal(t, e.HostPort, got.HostPort) | ||
| 205 | assert.Equal(t, e.VMID, got.VMID) | ||
| 206 | |||
| 207 | require.NoError(t, s.DeleteExposure(e.ID)) | ||
| 208 | _, err = s.GetExposure(e.ID) | ||
| 209 | assert.ErrorIs(t, err, sql.ErrNoRows) | ||
| 210 | |||
| 211 | // Deleting one that is already gone is not a silent success. | ||
| 212 | assert.ErrorIs(t, s.DeleteExposure(e.ID), sql.ErrNoRows) | ||
| 213 | } | ||
| 214 | |||
| 215 | func TestDeleteExposureBumpsTheEpoch(t *testing.T) { | ||
| 216 | s := newStore(t) | ||
| 217 | h := enrollHost(t, s) | ||
| 218 | vm := makeExposureVM(t, s, h, "web-1") | ||
| 219 | e, err := s.CreateExposure(vm.ID, 8080, 0) | ||
| 220 | require.NoError(t, err) | ||
| 221 | |||
| 222 | before, err := s.Epoch() | ||
| 223 | require.NoError(t, err) | ||
| 224 | require.NoError(t, s.DeleteExposure(e.ID)) | ||
| 225 | after, err := s.Epoch() | ||
| 226 | require.NoError(t, err) | ||
| 227 | assert.Greater(t, after, before) | ||
| 228 | } | ||
| 229 | |||
| 230 | func TestReapingAVMDestroysItsExposures(t *testing.T) { | ||
| 231 | s := newStore(t) | ||
| 232 | h := enrollHost(t, s) | ||
| 233 | vm := makeExposureVM(t, s, h, "web-1") | ||
| 234 | e, err := s.CreateExposure(vm.ID, 8080, 0) | ||
| 235 | require.NoError(t, err) | ||
| 236 | |||
| 237 | require.NoError(t, s.TombstoneVM(vm.ID)) | ||
| 238 | require.NoError(t, s.HardDeleteVM(vm.ID)) | ||
| 239 | |||
| 240 | _, err = s.GetExposure(e.ID) | ||
| 241 | assert.ErrorIs(t, err, sql.ErrNoRows, "an exposure belongs to its VM and goes with it") | ||
| 242 | } | ||
| 243 | |||
| 244 | func TestForceRemovingAHostDestroysItsExposures(t *testing.T) { | ||
| 245 | s := newStore(t) | ||
| 246 | h := enrollHost(t, s) | ||
| 247 | vm := makeExposureVM(t, s, h, "web-1") | ||
| 248 | e, err := s.CreateExposure(vm.ID, 8080, 0) | ||
| 249 | require.NoError(t, err) | ||
| 250 | |||
| 251 | purged, err := s.ForceRemoveHost(h.ID) | ||
| 252 | require.NoError(t, err) | ||
| 253 | assert.Equal(t, 1, purged) | ||
| 254 | |||
| 255 | _, err = s.GetExposure(e.ID) | ||
| 256 | assert.ErrorIs(t, err, sql.ErrNoRows) | ||
| 257 | } | ||
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -57,6 +57,11 @@ type Host struct { | |||
| 57 | Tenant string | 57 | Tenant string |
| 58 | // Host OS facts, best-effort, refreshed from the agent's Hello each connect. | 58 | // Host OS facts, best-effort, refreshed from the agent's Hello each connect. |
| 59 | OSID, OSPretty, OSVersion, Kernel, CPUModel, Virt string | 59 | OSID, OSPretty, OSVersion, Kernel, CPUModel, Virt string |
| 60 | // UplinkAddr is the address this host presents on the network it reaches | ||
| 61 | // the control plane over — the address an operator dials to reach a | ||
| 62 | // published guest port. Empty means the host has not said, never that it | ||
| 63 | // has none. | ||
| 64 | UplinkAddr string | ||
| 60 | } | 65 | } |
| 61 | 66 | ||
| 62 | // HostFacts is the write-side shape for UpdateHostFacts (store stays pb-free; | 67 | // HostFacts is the write-side shape for UpdateHostFacts (store stays pb-free; |
| @@ -204,6 +209,32 @@ CREATE TABLE IF NOT EXISTS tenant_user_cas ( | |||
| 204 | ); | 209 | ); |
| 205 | CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_user_cas_pubkey ON tenant_user_cas(ca_pubkey); | 210 | CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_user_cas_pubkey ON tenant_user_cas(ca_pubkey); |
| 206 | 211 | ||
| 212 | -- exposures: one published guest port. The fleet binds host_port on the VM's | ||
| 213 | -- host and pipes it to guest_port inside the guest. host_id is denormalized | ||
| 214 | -- from the VM at create so allocation is a plain transaction against one | ||
| 215 | -- table, and the unique index IS the collision check — a port is claimed by | ||
| 216 | -- whoever inserts first, and the second insert fails at the storage layer | ||
| 217 | -- rather than after a read-then-write race. | ||
| 218 | -- | ||
| 219 | -- protocol is 'tcp' and scope is 'lan', both stored so 'udp' and 'public' are | ||
| 220 | -- additive values later rather than a migration. | ||
| 221 | -- | ||
| 222 | -- ON DELETE CASCADE against vms is what keeps the promise that no listener | ||
| 223 | -- outlives the thing it pointed at: hard-deleting a reaped VM takes its | ||
| 224 | -- exposures with it, with no sweep to write and none to forget. | ||
| 225 | CREATE TABLE IF NOT EXISTS exposures ( | ||
| 226 | id TEXT PRIMARY KEY, | ||
| 227 | tenant TEXT NOT NULL REFERENCES tenants(id), | ||
| 228 | vm_id TEXT NOT NULL REFERENCES vms(id) ON DELETE CASCADE, | ||
| 229 | host_id TEXT NOT NULL REFERENCES hosts(id), | ||
| 230 | guest_port INTEGER NOT NULL, | ||
| 231 | host_port INTEGER NOT NULL, | ||
| 232 | protocol TEXT NOT NULL DEFAULT 'tcp', | ||
| 233 | scope TEXT NOT NULL DEFAULT 'lan', | ||
| 234 | created_at DATETIME NOT NULL | ||
| 235 | ); | ||
| 236 | CREATE UNIQUE INDEX IF NOT EXISTS exposures_host_port ON exposures(host_id, host_port); | ||
| 237 | |||
| 207 | -- Console sessions. Server-side so revocation works and restarts keep | 238 | -- Console sessions. Server-side so revocation works and restarts keep |
| 208 | -- users signed in. id is 256-bit random hex; expiry enforced on read. | 239 | -- users signed in. id is 256-bit random hex; expiry enforced on read. |
| 209 | CREATE TABLE IF NOT EXISTS sessions ( | 240 | CREATE TABLE IF NOT EXISTS sessions ( |
| @@ -282,6 +313,11 @@ func Open(path, cidrPool string) (*Store, error) { | |||
| 282 | {"vms", "injected_key_type", "TEXT NOT NULL DEFAULT ''"}, | 313 | {"vms", "injected_key_type", "TEXT NOT NULL DEFAULT ''"}, |
| 283 | {"vms", "injected_key_fp", "TEXT NOT NULL DEFAULT ''"}, | 314 | {"vms", "injected_key_fp", "TEXT NOT NULL DEFAULT ''"}, |
| 284 | {"vms", "injected_key_comment", "TEXT NOT NULL DEFAULT ''"}, | 315 | {"vms", "injected_key_comment", "TEXT NOT NULL DEFAULT ''"}, |
| 316 | // The address a host presents on the network it reaches the fleet | ||
| 317 | // over. Reported every tick like the guest subnet, and stored for the | ||
| 318 | // same reason: the console renders `host:port` for every exposure, | ||
| 319 | // including on a host that is momentarily offline. | ||
| 320 | {"hosts", "uplink_addr", "TEXT NOT NULL DEFAULT ''"}, | ||
| 285 | } { | 321 | } { |
| 286 | if err := ensureColumn(db, c.table, c.column, c.decl); err != nil { | 322 | if err := ensureColumn(db, c.table, c.column, c.decl); err != nil { |
| 287 | db.Close() | 323 | db.Close() |
| @@ -523,8 +559,8 @@ func (s *Store) GetHost(id string) (Host, error) { | |||
| 523 | var h Host | 559 | var h Host |
| 524 | var enrolledAt string | 560 | var enrolledAt string |
| 525 | err := s.db.QueryRow( | 561 | err := s.db.QueryRow( |
| 526 | `SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant, os_id, os_pretty, os_version, kernel, cpu_model, virt FROM hosts WHERE id=?`, id, | 562 | `SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant, os_id, os_pretty, os_version, kernel, cpu_model, virt, uplink_addr FROM hosts WHERE id=?`, id, |
| 527 | ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant, &h.OSID, &h.OSPretty, &h.OSVersion, &h.Kernel, &h.CPUModel, &h.Virt) | 563 | ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant, &h.OSID, &h.OSPretty, &h.OSVersion, &h.Kernel, &h.CPUModel, &h.Virt, &h.UplinkAddr) |
| 528 | if err != nil { | 564 | if err != nil { |
| 529 | return Host{}, err | 565 | return Host{}, err |
| 530 | } | 566 | } |
| @@ -572,6 +608,27 @@ func (s *Store) RecordHostNetwork(id, cidr string) error { | |||
| 572 | return err | 608 | return err |
| 573 | } | 609 | } |
| 574 | 610 | ||
| 611 | // RecordHostUplink stores the address a host says it presents on the network it | ||
| 612 | // reaches the fleet over. The value is free text supplied by whoever claims to | ||
| 613 | // be that host, so it is checked the way every reported network fact is: it | ||
| 614 | // must parse as an address. It asks whether the value IS an address, never | ||
| 615 | // which address it ought to be — a host's own uplink is that host's business. | ||
| 616 | // | ||
| 617 | // An address that reaches nobody — loopback, unspecified, link-local, | ||
| 618 | // multicast — is refused rather than stored: an operator dials this value, so a | ||
| 619 | // row holding somewhere unreachable is worse than a row holding nothing. The | ||
| 620 | // caller keeps what it already knows. | ||
| 621 | // | ||
| 622 | // The WHERE clause makes a repeat free at the storage layer; the caller still | ||
| 623 | // guards the round trip, because reports arrive every tick forever. | ||
| 624 | func (s *Store) RecordHostUplink(id, addr string) error { | ||
| 625 | if !usableAddress(addr) { | ||
| 626 | return fmt.Errorf("host uplink %q: not an address anyone can reach", addr) | ||
| 627 | } | ||
| 628 | _, err := s.db.Exec(`UPDATE hosts SET uplink_addr=? WHERE id=? AND uplink_addr<>?`, addr, id, addr) | ||
| 629 | return err | ||
| 630 | } | ||
| 631 | |||
| 575 | // UpdateHostFacts refreshes what a host reports about itself. Every column | 632 | // UpdateHostFacts refreshes what a host reports about itself. Every column |
| 576 | // keeps its prior value when the reported one is EMPTY, because silence is not | 633 | // keeps its prior value when the reported one is EMPTY, because silence is not |
| 577 | // a statement: an agent too old to send a field, or a Hello carrying no facts | 634 | // a statement: an agent too old to send a field, or a Hello carrying no facts |
| @@ -632,7 +689,7 @@ type querier interface { | |||
| 632 | func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) } | 689 | func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) } |
| 633 | 690 | ||
| 634 | func listHosts(q querier) ([]Host, error) { | 691 | func listHosts(q querier) ([]Host, error) { |
| 635 | rows, err := q.Query(`SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant, os_id, os_pretty, os_version, kernel, cpu_model, virt FROM hosts`) | 692 | rows, err := q.Query(`SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant, os_id, os_pretty, os_version, kernel, cpu_model, virt, uplink_addr FROM hosts`) |
| 636 | if err != nil { | 693 | if err != nil { |
| 637 | return nil, err | 694 | return nil, err |
| 638 | } | 695 | } |
| @@ -641,7 +698,7 @@ func listHosts(q querier) ([]Host, error) { | |||
| 641 | for rows.Next() { | 698 | for rows.Next() { |
| 642 | var h Host | 699 | var h Host |
| 643 | var enrolledAt string | 700 | var enrolledAt string |
| 644 | if err := rows.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant, &h.OSID, &h.OSPretty, &h.OSVersion, &h.Kernel, &h.CPUModel, &h.Virt); err != nil { | 701 | if err := rows.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant, &h.OSID, &h.OSPretty, &h.OSVersion, &h.Kernel, &h.CPUModel, &h.Virt, &h.UplinkAddr); err != nil { |
| 645 | return nil, err | 702 | return nil, err |
| 646 | } | 703 | } |
| 647 | h.EnrolledAt, _ = time.Parse(time.RFC3339, enrolledAt) | 704 | h.EnrolledAt, _ = time.Parse(time.RFC3339, enrolledAt) |
| @@ -1118,15 +1175,16 @@ func (s *Store) ForceRemoveHost(id string) (int, error) { | |||
| 1118 | return int(purged), nil | 1175 | return int(purged), nil |
| 1119 | } | 1176 | } |
| 1120 | 1177 | ||
| 1121 | // usableGuestAddress reports whether ip is an address a guest could actually be | 1178 | // usableAddress reports whether ip is an address something could actually be |
| 1122 | // reached on. It asks nothing about topology: a host's guests live wherever that | 1179 | // reached on — a guest on its host's network, or a host on the network it |
| 1123 | // host's network puts them — inside a Linux bridge the fleet allocated, or on | 1180 | // reaches the fleet over. It asks nothing about topology: a host's guests live |
| 1124 | // whatever subnet macOS's vmnet happens to run — and the fleet is told that | 1181 | // wherever that host's network puts them — inside a Linux bridge the fleet |
| 1125 | // subnet, it does not decide it. So the only thing worth rejecting here is a | 1182 | // allocated, or on whatever subnet macOS's vmnet happens to run — and the fleet |
| 1126 | // value that names no reachable guest under any topology: unparseable, the | 1183 | // is told that subnet, it does not decide it. So the only thing worth rejecting |
| 1184 | // here is a value that names nobody under any topology: unparseable, the | ||
| 1127 | // unspecified address, loopback, link-local (the 169.254/16 a guest reports when | 1185 | // unspecified address, loopback, link-local (the 169.254/16 a guest reports when |
| 1128 | // DHCP never answered), or multicast. | 1186 | // DHCP never answered), or multicast. |
| 1129 | func usableGuestAddress(ip string) bool { | 1187 | func usableAddress(ip string) bool { |
| 1130 | addr, err := netip.ParseAddr(ip) | 1188 | addr, err := netip.ParseAddr(ip) |
| 1131 | if err != nil { | 1189 | if err != nil { |
| 1132 | return false | 1190 | return false |
| @@ -1149,7 +1207,7 @@ func usableGuestAddress(ip string) bool { | |||
| 1149 | // cache came to remember addresses that were never stored, and then suppress | 1207 | // cache came to remember addresses that were never stored, and then suppress |
| 1150 | // every later report that would have corrected them. | 1208 | // every later report that would have corrected them. |
| 1151 | func (s *Store) RecordVMStatus(id, status, lastErr, ip string) (string, error) { | 1209 | func (s *Store) RecordVMStatus(id, status, lastErr, ip string) (string, error) { |
| 1152 | if ip != "" && !usableGuestAddress(ip) { | 1210 | if ip != "" && !usableAddress(ip) { |
| 1153 | ip = "" | 1211 | ip = "" |
| 1154 | } | 1212 | } |
| 1155 | 1213 | ||
internal/server/store/store_test.go
| Old | New | ||
|---|---|---|---|
| @@ -885,3 +885,42 @@ func TestRecordHostNetworkSanityChecksWithoutJudgingTopology(t *testing.T) { | |||
| 885 | require.NoError(t, err) | 885 | require.NoError(t, err) |
| 886 | assert.Equal(t, "192.168.64.0/24", got.BridgeCIDR) | 886 | assert.Equal(t, "192.168.64.0/24", got.BridgeCIDR) |
| 887 | } | 887 | } |
| 888 | |||
| 889 | func TestRecordHostUplink(t *testing.T) { | ||
| 890 | s := newStore(t) | ||
| 891 | h := enrollHost(t, s) | ||
| 892 | |||
| 893 | // A freshly enrolled host has said nothing about its uplink yet. | ||
| 894 | got, err := s.GetHost(h.ID) | ||
| 895 | require.NoError(t, err) | ||
| 896 | assert.Equal(t, "", got.UplinkAddr) | ||
| 897 | |||
| 898 | require.NoError(t, s.RecordHostUplink(h.ID, "192.168.0.190")) | ||
| 899 | got, err = s.GetHost(h.ID) | ||
| 900 | require.NoError(t, err) | ||
| 901 | assert.Equal(t, "192.168.0.190", got.UplinkAddr) | ||
| 902 | |||
| 903 | // The list read carries it too — the console renders it from there. | ||
| 904 | hosts, err := s.ListHosts() | ||
| 905 | require.NoError(t, err) | ||
| 906 | require.Len(t, hosts, 1) | ||
| 907 | assert.Equal(t, "192.168.0.190", hosts[0].UplinkAddr) | ||
| 908 | |||
| 909 | // A host that moves is recorded where it moved to. | ||
| 910 | require.NoError(t, s.RecordHostUplink(h.ID, "10.0.0.7")) | ||
| 911 | got, err = s.GetHost(h.ID) | ||
| 912 | require.NoError(t, err) | ||
| 913 | assert.Equal(t, "10.0.0.7", got.UplinkAddr) | ||
| 914 | } | ||
| 915 | |||
| 916 | func TestRecordHostUplinkRefusesWhatIsNotAnAddress(t *testing.T) { | ||
| 917 | s := newStore(t) | ||
| 918 | h := enrollHost(t, s) | ||
| 919 | assert.Error(t, s.RecordHostUplink(h.ID, "not-an-address")) | ||
| 920 | assert.Error(t, s.RecordHostUplink(h.ID, "192.168.0.190/24")) | ||
| 921 | assert.Error(t, s.RecordHostUplink(h.ID, "127.0.0.1"), "an address that reaches nobody is not an uplink") | ||
| 922 | |||
| 923 | got, err := s.GetHost(h.ID) | ||
| 924 | require.NoError(t, err) | ||
| 925 | assert.Equal(t, "", got.UplinkAddr, "a value that is not an address never reaches the row") | ||
| 926 | } | ||
internal/server/syncsvc/exposures_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,91 @@ | |||
| 1 | package syncsvc | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | |||
| 6 | "github.com/a73x/eitri/internal/pb" | ||
| 7 | "github.com/a73x/eitri/internal/server/registry" | ||
| 8 | "github.com/a73x/eitri/internal/server/store" | ||
| 9 | "github.com/stretchr/testify/assert" | ||
| 10 | "github.com/stretchr/testify/require" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // exposureVM places one live VM on the fixture's host. | ||
| 14 | func exposureVM(t *testing.T, f *fixture, id, name string) { | ||
| 15 | t.Helper() | ||
| 16 | require.NoError(t, f.st.CreateVM(store.VM{ID: id, HostID: f.host.ID, Name: name, | ||
| 17 | ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | ||
| 18 | } | ||
| 19 | |||
| 20 | // TestSnapshotCarriesExposures pins that a host's desired state names every | ||
| 21 | // exposure it should be running, at snapshot level rather than nested in a VM: | ||
| 22 | // exposures are their own objects converging on their own cadence. | ||
| 23 | func TestSnapshotCarriesExposures(t *testing.T) { | ||
| 24 | f := setup(t) | ||
| 25 | exposureVM(t, f, "vm1", "web-1") | ||
| 26 | |||
| 27 | e, err := f.st.CreateExposure("vm1", 8080, 30080) | ||
| 28 | require.NoError(t, err) | ||
| 29 | |||
| 30 | snap, err := f.svc.buildSnapshot(f.host.ID) | ||
| 31 | require.NoError(t, err) | ||
| 32 | require.Len(t, snap.GetExposures(), 1) | ||
| 33 | got := snap.GetExposures()[0] | ||
| 34 | assert.Equal(t, e.ID, got.GetId()) | ||
| 35 | assert.Equal(t, "vm1", got.GetVmId()) | ||
| 36 | assert.Equal(t, uint32(8080), got.GetGuestPort()) | ||
| 37 | assert.Equal(t, uint32(30080), got.GetHostPort()) | ||
| 38 | assert.Equal(t, "tcp", got.GetProtocol()) | ||
| 39 | } | ||
| 40 | |||
| 41 | // TestSnapshotDropsExposuresOfATombstonedVM pins that a listener stops being | ||
| 42 | // desired the moment its VM is on its way out, rather than when its row is | ||
| 43 | // finally reaped. | ||
| 44 | func TestSnapshotDropsExposuresOfATombstonedVM(t *testing.T) { | ||
| 45 | f := setup(t) | ||
| 46 | exposureVM(t, f, "vm1", "web-1") | ||
| 47 | _, err := f.st.CreateExposure("vm1", 8080, 30080) | ||
| 48 | require.NoError(t, err) | ||
| 49 | require.NoError(t, f.st.TombstoneVM("vm1")) | ||
| 50 | |||
| 51 | snap, err := f.svc.buildSnapshot(f.host.ID) | ||
| 52 | require.NoError(t, err) | ||
| 53 | assert.Empty(t, snap.GetExposures()) | ||
| 54 | } | ||
| 55 | |||
| 56 | // TestApplyReportFoldsExposureStateIntoTheRegistry pins the return leg: what a | ||
| 57 | // host says its listeners are doing is live state, held in memory beside the | ||
| 58 | // VM rows it reports in the same message. | ||
| 59 | func TestApplyReportFoldsExposureStateIntoTheRegistry(t *testing.T) { | ||
| 60 | f := setup(t) | ||
| 61 | |||
| 62 | f.svc.applyReport(f.host.ID, &pb.ActualStateReport{ | ||
| 63 | Exposures: []*pb.ExposureActual{ | ||
| 64 | {Id: "e1", State: "active"}, | ||
| 65 | {Id: "e2", State: "failed", Reason: "listen tcp 0.0.0.0:30080: bind: address already in use"}, | ||
| 66 | }, | ||
| 67 | }) | ||
| 68 | |||
| 69 | got, ok := f.reg.Get(f.host.ID) | ||
| 70 | require.True(t, ok) | ||
| 71 | require.Len(t, got.Report.Exposures, 2) | ||
| 72 | assert.Equal(t, registry.ExposureStatus{ID: "e1", State: "active"}, got.Report.Exposures[0]) | ||
| 73 | assert.Equal(t, "failed", got.Report.Exposures[1].State) | ||
| 74 | assert.Contains(t, got.Report.Exposures[1].Reason, "address already in use") | ||
| 75 | } | ||
| 76 | |||
| 77 | // TestApplyReportRecordsTheHostUplinkAddress pins that the address an operator | ||
| 78 | // dials is durable, and that a report which carries none leaves it alone. | ||
| 79 | func TestApplyReportRecordsTheHostUplinkAddress(t *testing.T) { | ||
| 80 | f := setup(t) | ||
| 81 | |||
| 82 | f.svc.applyReport(f.host.ID, &pb.ActualStateReport{HostUplinkAddr: "192.168.0.190"}) | ||
| 83 | h, err := f.st.GetHost(f.host.ID) | ||
| 84 | require.NoError(t, err) | ||
| 85 | assert.Equal(t, "192.168.0.190", h.UplinkAddr) | ||
| 86 | |||
| 87 | f.svc.applyReport(f.host.ID, &pb.ActualStateReport{}) | ||
| 88 | h, err = f.st.GetHost(f.host.ID) | ||
| 89 | require.NoError(t, err) | ||
| 90 | assert.Equal(t, "192.168.0.190", h.UplinkAddr, "silence is not a statement") | ||
| 91 | } | ||
internal/server/syncsvc/syncsvc.go
| Old | New | ||
|---|---|---|---|
| @@ -48,6 +48,10 @@ type Service struct { | |||
| 48 | recorder vmStatusRecorder | 48 | recorder vmStatusRecorder |
| 49 | tracker *statusTracker | 49 | tracker *statusTracker |
| 50 | netTrack *netTracker | 50 | netTrack *netTracker |
| 51 | // uplinkTrack remembers the uplink address each host last had WRITTEN, for | ||
| 52 | // the same reason netTrack does: every host reports every tick forever, and | ||
| 53 | // the store runs on a single connection. | ||
| 54 | uplinkTrack *netTracker | ||
| 51 | // maxCredAge, when non-zero, rejects credentials whose issued-at is older. | 55 | // maxCredAge, when non-zero, rejects credentials whose issued-at is older. |
| 52 | // Zero disables the age check (default: expiry without an auto-renewal | 56 | // Zero disables the age check (default: expiry without an auto-renewal |
| 53 | // channel would force periodic re-enrolls; per-host generation revocation | 57 | // channel would force periodic re-enrolls; per-host generation revocation |
| @@ -83,7 +87,7 @@ func newWithWriteTimeout(st *store.Store, reg *registry.Registry, h *hub.Hub, se | |||
| 83 | } | 87 | } |
| 84 | return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout, | 88 | return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout, |
| 85 | conns: map[string]quic.Connection{}, recorder: st, tracker: newStatusTracker(), netTrack: newNetTracker(), | 89 | conns: map[string]quic.Connection{}, recorder: st, tracker: newStatusTracker(), netTrack: newNetTracker(), |
| 86 | offers: map[string]*pb.AgentUpgrade{}} | 90 | uplinkTrack: newNetTracker(), offers: map[string]*pb.AgentUpgrade{}} |
| 87 | } | 91 | } |
| 88 | 92 | ||
| 89 | // Serve accepts QUIC connections until ctx is cancelled. | 93 | // Serve accepts QUIC connections until ctx is cancelled. |
| @@ -202,11 +206,12 @@ func (s *Service) handleConn(ctx context.Context, conn quic.Connection) { | |||
| 202 | delete(s.conns, hostID) | 206 | delete(s.conns, hostID) |
| 203 | } | 207 | } |
| 204 | s.consoleMu.Unlock() | 208 | s.consoleMu.Unlock() |
| 205 | // Forget this host's cached guest subnet, so the next connection writes | 209 | // Forget this host's cached network facts, so the next connection writes |
| 206 | // it once rather than trusting a memory of a row that may have been | 210 | // them once rather than trusting a memory of a row that may have been |
| 207 | // removed, re-enrolled or edited while the host was away. Bounds the map | 211 | // removed, re-enrolled or edited while the host was away. Bounds the maps |
| 208 | // to connected hosts, and costs one write per reconnect. | 212 | // to connected hosts, and costs one write per reconnect. |
| 209 | s.netTrack.forget(hostID) | 213 | s.netTrack.forget(hostID) |
| 214 | s.uplinkTrack.forget(hostID) | ||
| 210 | }() | 215 | }() |
| 211 | 216 | ||
| 212 | // Single writer for the down-stream: the poke goroutine. | 217 | // Single writer for the down-stream: the poke goroutine. |
| @@ -270,11 +275,12 @@ func (s *Service) handleConn(ctx context.Context, conn quic.Connection) { | |||
| 270 | } | 275 | } |
| 271 | } | 276 | } |
| 272 | 277 | ||
| 273 | // pushSnapshot reads the current desired state in a single transaction and sends it. | 278 | // buildSnapshot reads hostID's desired state — its VMs and the exposures it |
| 274 | func (s *Service) pushSnapshot(down quic.Stream, hostID string) error { | 279 | // should be serving — and renders it as one snapshot. |
| 280 | func (s *Service) buildSnapshot(hostID string) (*pb.DesiredStateSnapshot, error) { | ||
| 275 | epoch, vms, err := s.st.DesiredForHost(hostID) | 281 | epoch, vms, err := s.st.DesiredForHost(hostID) |
| 276 | if err != nil { | 282 | if err != nil { |
| 277 | return fmt.Errorf("desired for host: %w", err) | 283 | return nil, fmt.Errorf("desired for host: %w", err) |
| 278 | } | 284 | } |
| 279 | snap := &pb.DesiredStateSnapshot{Epoch: epoch, Vms: make([]*pb.VMDesired, 0, len(vms))} | 285 | snap := &pb.DesiredStateSnapshot{Epoch: epoch, Vms: make([]*pb.VMDesired, 0, len(vms))} |
| 280 | snap.AgentUpgrade = s.offerFor(hostID) | 286 | snap.AgentUpgrade = s.offerFor(hostID) |
| @@ -284,7 +290,7 @@ func (s *Service) pushSnapshot(down quic.Stream, hostID string) error { | |||
| 284 | if !ok { | 290 | if !ok { |
| 285 | list, err := s.st.ListTenantUserCAs(v.Tenant) | 291 | list, err := s.st.ListTenantUserCAs(v.Tenant) |
| 286 | if err != nil { | 292 | if err != nil { |
| 287 | return fmt.Errorf("list tenant user cas: %w", err) | 293 | return nil, fmt.Errorf("list tenant user cas: %w", err) |
| 288 | } | 294 | } |
| 289 | cas = make([]string, 0, len(list)) | 295 | cas = make([]string, 0, len(list)) |
| 290 | for _, c := range list { | 296 | for _, c := range list { |
| @@ -302,6 +308,28 @@ func (s *Service) pushSnapshot(down quic.Stream, hostID string) error { | |||
| 302 | SshHostCert: v.SSHHostCert, | 308 | SshHostCert: v.SSHHostCert, |
| 303 | }) | 309 | }) |
| 304 | } | 310 | } |
| 311 | exps, err := s.st.ListExposuresForHost(hostID) | ||
| 312 | if err != nil { | ||
| 313 | return nil, fmt.Errorf("list host exposures: %w", err) | ||
| 314 | } | ||
| 315 | for _, e := range exps { | ||
| 316 | // In-range by construction: the API validates ports at create | ||
| 317 | // (validateExposurePorts), and the store accepts rows only from the API. | ||
| 318 | snap.Exposures = append(snap.Exposures, &pb.ExposureDesired{ | ||
| 319 | Id: e.ID, VmId: e.VMID, | ||
| 320 | GuestPort: uint32(e.GuestPort), HostPort: uint32(e.HostPort), | ||
| 321 | Protocol: e.Protocol, | ||
| 322 | }) | ||
| 323 | } | ||
| 324 | return snap, nil | ||
| 325 | } | ||
| 326 | |||
| 327 | // pushSnapshot sends hostID's current desired state down the stream. | ||
| 328 | func (s *Service) pushSnapshot(down quic.Stream, hostID string) error { | ||
| 329 | snap, err := s.buildSnapshot(hostID) | ||
| 330 | if err != nil { | ||
| 331 | return err | ||
| 332 | } | ||
| 305 | // Bound the write: if a stalled agent stops reading the down-stream but keeps | 333 | // Bound the write: if a stalled agent stops reading the down-stream but keeps |
| 306 | // the connection alive, the flow-control window fills and an unbounded Write | 334 | // the connection alive, the flow-control window fills and an unbounded Write |
| 307 | // would block forever, pinning this goroutine. The deadline turns that into a | 335 | // would block forever, pinning this goroutine. The deadline turns that into a |
| @@ -369,6 +397,7 @@ func (s *Service) applyReport(hostID string, rep *pb.ActualStateReport) { | |||
| 369 | 397 | ||
| 370 | r.VMs = toRegistryVMs(rep.GetVms()) | 398 | r.VMs = toRegistryVMs(rep.GetVms()) |
| 371 | r.Quarantined = toRegistryQuarantined(rep.GetQuarantined()) | 399 | r.Quarantined = toRegistryQuarantined(rep.GetQuarantined()) |
| 400 | r.Exposures = toRegistryExposures(rep.GetExposures()) | ||
| 372 | r.Capacity = toRegistryCapacity(rep.GetCapacity()) | 401 | r.Capacity = toRegistryCapacity(rep.GetCapacity()) |
| 373 | r.Metrics = toRegistryMetrics(rep.GetMetrics()) | 402 | r.Metrics = toRegistryMetrics(rep.GetMetrics()) |
| 374 | 403 | ||
| @@ -412,6 +441,16 @@ func (s *Service) applyReport(hostID string, rep *pb.ActualStateReport) { | |||
| 412 | } | 441 | } |
| 413 | } | 442 | } |
| 414 | 443 | ||
| 444 | // The address this host says it answers on. Empty is "no answer", never | ||
| 445 | // "no address" — the same rule guest_cidr follows, and for the same reason. | ||
| 446 | if addr := rep.GetHostUplinkAddr(); addr != "" { | ||
| 447 | if err := s.uplinkTrack.writeThrough(hostID, addr, func() error { | ||
| 448 | return s.st.RecordHostUplink(hostID, addr) | ||
| 449 | }); err != nil { | ||
| 450 | slog.Warn("record host uplink", "host", hostID, "addr", addr, "err", err) | ||
| 451 | } | ||
| 452 | } | ||
| 453 | |||
| 415 | // Fence violation: log ERROR and point at the restore runbook. | 454 | // Fence violation: log ERROR and point at the restore runbook. |
| 416 | if rep.GetFenceViolation() { | 455 | if rep.GetFenceViolation() { |
| 417 | slog.Error("agent refused snapshot: epoch fence violation — see restore runbook", | 456 | slog.Error("agent refused snapshot: epoch fence violation — see restore runbook", |
| @@ -498,6 +537,21 @@ func toRegistryQuarantined(in []*pb.QuarantinedVM) []registry.QuarantinedVM { | |||
| 498 | return out | 537 | return out |
| 499 | } | 538 | } |
| 500 | 539 | ||
| 540 | // toRegistryExposures maps reported exposure state to registry rows. Returns | ||
| 541 | // nil (not an empty slice) for empty input, matching append-into-nil behavior. | ||
| 542 | func toRegistryExposures(in []*pb.ExposureActual) []registry.ExposureStatus { | ||
| 543 | if len(in) == 0 { | ||
| 544 | return nil | ||
| 545 | } | ||
| 546 | out := make([]registry.ExposureStatus, 0, len(in)) | ||
| 547 | for _, e := range in { | ||
| 548 | out = append(out, registry.ExposureStatus{ | ||
| 549 | ID: e.GetId(), State: e.GetState(), Reason: e.GetReason(), | ||
| 550 | }) | ||
| 551 | } | ||
| 552 | return out | ||
| 553 | } | ||
| 554 | |||
| 501 | // toRegistryCapacity maps reported capacity (nil → zero value). | 555 | // toRegistryCapacity maps reported capacity (nil → zero value). |
| 502 | func toRegistryCapacity(c *pb.Capacity) registry.Capacity { | 556 | func toRegistryCapacity(c *pb.Capacity) registry.Capacity { |
| 503 | if c == nil { | 557 | if c == nil { |
internal/server/syncsvc/tracker.go
| Old | New | ||
|---|---|---|---|
| @@ -78,12 +78,14 @@ func (t *statusTracker) forget(vmID string) { | |||
| 78 | delete(t.last, vmID) | 78 | delete(t.last, vmID) |
| 79 | } | 79 | } |
| 80 | 80 | ||
| 81 | // netTracker remembers the guest subnet each host last had WRITTEN, so a report | 81 | // netTracker remembers the last value durably WRITTEN per host — one instance |
| 82 | // that repeats it costs nothing. The guard is not premature: every host reports | 82 | // per fact, currently the host's guest subnet and the address it answers on — |
| 83 | // every tick — ten seconds by default — forever, and the store runs on a single | 83 | // so a report that repeats it costs nothing. The guard is not premature: every |
| 84 | // connection, so an unguarded write spends a round trip on that connection for | 84 | // host reports every tick — ten seconds by default — forever, and the store runs |
| 85 | // every host for the life of the fleet. The SQL's own `WHERE bridge_cidr<>?` | 85 | // on a single connection, so an unguarded write spends a round trip on that |
| 86 | // makes a concurrent reconnect harmless; this makes the steady state free. | 86 | // connection for every host for the life of the fleet. Each write's own |
| 87 | // `WHERE <column><>?` makes a concurrent reconnect harmless; this makes the | ||
| 88 | // steady state free. | ||
| 87 | // | 89 | // |
| 88 | // Keyed by hostID. Only a SUCCESSFUL write is remembered, so a rejected one is | 90 | // Keyed by hostID. Only a SUCCESSFUL write is remembered, so a rejected one is |
| 89 | // retried by the next report rather than suppressed — the same rule the status | 91 | // retried by the next report rather than suppressed — the same rule the status |
| @@ -98,20 +100,20 @@ func newNetTracker() *netTracker { return &netTracker{last: map[string]string{}} | |||
| 98 | // writeThrough runs decide→write→commit for one host under a single lock, so | 100 | // writeThrough runs decide→write→commit for one host under a single lock, so |
| 99 | // two connections for the same host cannot interleave and leave the cache | 101 | // two connections for the same host cannot interleave and leave the cache |
| 100 | // disagreeing with the row. | 102 | // disagreeing with the row. |
| 101 | func (t *netTracker) writeThrough(hostID, cidr string, write func() error) error { | 103 | func (t *netTracker) writeThrough(hostID, value string, write func() error) error { |
| 102 | t.mu.Lock() | 104 | t.mu.Lock() |
| 103 | defer t.mu.Unlock() | 105 | defer t.mu.Unlock() |
| 104 | if t.last[hostID] == cidr { | 106 | if t.last[hostID] == value { |
| 105 | return nil | 107 | return nil |
| 106 | } | 108 | } |
| 107 | if err := write(); err != nil { | 109 | if err := write(); err != nil { |
| 108 | return err | 110 | return err |
| 109 | } | 111 | } |
| 110 | t.last[hostID] = cidr | 112 | t.last[hostID] = value |
| 111 | return nil | 113 | return nil |
| 112 | } | 114 | } |
| 113 | 115 | ||
| 114 | // forget drops a host's cached subnet, so a host that leaves and returns is | 116 | // forget drops a host's cached value, so a host that leaves and returns is |
| 115 | // written afresh rather than trusted to a memory of a row that may be gone. | 117 | // written afresh rather than trusted to a memory of a row that may be gone. |
| 116 | func (t *netTracker) forget(hostID string) { | 118 | func (t *netTracker) forget(hostID string) { |
| 117 | t.mu.Lock() | 119 | t.mu.Lock() |
internal/smoke/exposure.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,110 @@ | |||
| 1 | package smoke | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "context" | ||
| 6 | "errors" | ||
| 7 | "fmt" | ||
| 8 | "net" | ||
| 9 | "os" | ||
| 10 | "strconv" | ||
| 11 | "strings" | ||
| 12 | "time" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // sshBannerPrefix is what an sshd answers a fresh connection with, before any | ||
| 16 | // key exchange. Reading it back through a published port proves the whole path | ||
| 17 | // — the host listener, the splice, and the guest dial — with no infrastructure | ||
| 18 | // the fleet does not already have, and no privileged bind on either machine. | ||
| 19 | const sshBannerPrefix = "SSH-2.0" | ||
| 20 | |||
| 21 | // bannerFunc reads the first bytes a TCP peer sends after accepting. | ||
| 22 | type bannerFunc func(ctx context.Context, addr string) (string, error) | ||
| 23 | |||
| 24 | // proveExposure publishes the smoke VM's ssh port on its host, dials the host | ||
| 25 | // address raw, and expects an SSH banner. hostAddr is the address the host | ||
| 26 | // itself reported; the exposure is revoked before the leg returns, whatever | ||
| 27 | // the outcome. | ||
| 28 | func proveExposure(ctx context.Context, c vmAPI, hostAddr, vmID string, now func() time.Time, sleep func(time.Duration), dial bannerFunc) error { | ||
| 29 | if hostAddr == "" { | ||
| 30 | return errors.New("FAIL: the VM's host reported no uplink address; nothing to dial a published port on") | ||
| 31 | } | ||
| 32 | exp, err := c.CreateExposure(ctx, vmID, 22, 0) | ||
| 33 | if err != nil { | ||
| 34 | return fmt.Errorf("create exposure: %w", err) | ||
| 35 | } | ||
| 36 | defer func() { | ||
| 37 | // Revoke on the way out even when the leg failed: the smoke leaves no | ||
| 38 | // grant behind on the live fleet. A revoke that itself fails leaves a | ||
| 39 | // published port on a live host, so say so — the leg's own verdict | ||
| 40 | // stands either way. | ||
| 41 | if err := c.DeleteExposure(context.WithoutCancel(ctx), exp.ID); err != nil { | ||
| 42 | fmt.Fprintf(os.Stderr, "eitri-smoke: exposure revoke failed; grant %s left behind: %v\n", exp.ID, err) | ||
| 43 | } | ||
| 44 | }() | ||
| 45 | |||
| 46 | target := net.JoinHostPort(hostAddr, strconv.FormatInt(exp.HostPort, 10)) | ||
| 47 | var lastErr error | ||
| 48 | err = pollLoop(ctx, now, sleep, 60*time.Second, 3*time.Second, func() (bool, error) { | ||
| 49 | banner, derr := dial(ctx, target) | ||
| 50 | if derr != nil { | ||
| 51 | // The listener binds on the host's next converge, a tick away — a | ||
| 52 | // refused dial in that window is expected, not fatal. | ||
| 53 | lastErr = derr | ||
| 54 | return false, nil | ||
| 55 | } | ||
| 56 | if !strings.HasPrefix(banner, sshBannerPrefix) { | ||
| 57 | return false, fmt.Errorf("FAIL: published port %s answered %q, want an %s banner", | ||
| 58 | target, truncateBanner(banner), sshBannerPrefix) | ||
| 59 | } | ||
| 60 | return true, nil | ||
| 61 | }) | ||
| 62 | if errors.Is(err, errPollTimeout) { | ||
| 63 | return fmt.Errorf("FAIL: no %s banner from published port %s within 60s: %v", sshBannerPrefix, target, lastErr) | ||
| 64 | } | ||
| 65 | return err | ||
| 66 | } | ||
| 67 | |||
| 68 | // truncateBanner bounds what a wrong answer puts in the failure message. | ||
| 69 | func truncateBanner(s string) string { | ||
| 70 | s = strings.TrimSpace(s) | ||
| 71 | if len(s) > 64 { | ||
| 72 | return s[:64] | ||
| 73 | } | ||
| 74 | return s | ||
| 75 | } | ||
| 76 | |||
| 77 | // readBanner is the real bannerFunc: dial addr and read the peer's first line, | ||
| 78 | // bounded by a deadline so a listener that binds but never answers fails the | ||
| 79 | // leg instead of hanging it. It reads until a newline (or 256 bytes) rather | ||
| 80 | // than taking one Read's worth, because a spliced connection can deliver the | ||
| 81 | // banner in fragments and a segment shorter than the prefix would fail a | ||
| 82 | // perfectly good sshd. A read that returns nothing at all keeps its error, so | ||
| 83 | // the caller can retry a listener that is not up yet. | ||
| 84 | func readBanner(ctx context.Context, addr string) (string, error) { | ||
| 85 | var d net.Dialer | ||
| 86 | conn, err := d.DialContext(ctx, "tcp", addr) | ||
| 87 | if err != nil { | ||
| 88 | return "", err | ||
| 89 | } | ||
| 90 | defer conn.Close() | ||
| 91 | if err := conn.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil { | ||
| 92 | return "", err | ||
| 93 | } | ||
| 94 | buf := make([]byte, 256) | ||
| 95 | n := 0 | ||
| 96 | for n < len(buf) { | ||
| 97 | m, err := conn.Read(buf[n:]) | ||
| 98 | n += m | ||
| 99 | if err != nil { | ||
| 100 | if n == 0 { | ||
| 101 | return "", err | ||
| 102 | } | ||
| 103 | break | ||
| 104 | } | ||
| 105 | if bytes.IndexByte(buf[:n], '\n') >= 0 { | ||
| 106 | break | ||
| 107 | } | ||
| 108 | } | ||
| 109 | return string(buf[:n]), nil | ||
| 110 | } | ||
internal/smoke/exposure_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,191 @@ | |||
| 1 | package smoke | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "errors" | ||
| 6 | "net" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "github.com/a73x/eitri/internal/server/api/client" | ||
| 12 | ) | ||
| 13 | |||
| 14 | func TestProveExposureReadsTheBannerBack(t *testing.T) { | ||
| 15 | clock := &fakeClock{} | ||
| 16 | deleted := "" | ||
| 17 | api := &testAPI{ | ||
| 18 | createExposureFunc: func(ctx context.Context, vmID string, guest, host int64) (client.Exposure, error) { | ||
| 19 | if vmID != "vm-1" { | ||
| 20 | t.Errorf("CreateExposure vmID = %q, want vm-1", vmID) | ||
| 21 | } | ||
| 22 | if guest != 22 { | ||
| 23 | t.Errorf("CreateExposure guestPort = %d, want 22", guest) | ||
| 24 | } | ||
| 25 | if host != 0 { | ||
| 26 | t.Errorf("CreateExposure hostPort = %d, want 0 — the fleet allocates the host port", host) | ||
| 27 | } | ||
| 28 | return client.Exposure{ID: "x-1", HostPort: 30080}, nil | ||
| 29 | }, | ||
| 30 | deleteExposureFunc: func(ctx context.Context, id string) error { deleted = id; return nil }, | ||
| 31 | } | ||
| 32 | dialed := "" | ||
| 33 | dial := func(ctx context.Context, addr string) (string, error) { | ||
| 34 | dialed = addr | ||
| 35 | return "SSH-2.0-OpenSSH_9.6\r\n", nil | ||
| 36 | } | ||
| 37 | |||
| 38 | if err := proveExposure(context.Background(), api, "192.168.0.190", "vm-1", clock.now, clock.sleep, dial); err != nil { | ||
| 39 | t.Fatalf("proveExposure: %v", err) | ||
| 40 | } | ||
| 41 | if dialed != "192.168.0.190:30080" { | ||
| 42 | t.Errorf("dialed = %q, want 192.168.0.190:30080", dialed) | ||
| 43 | } | ||
| 44 | if deleted != "x-1" { | ||
| 45 | t.Errorf("revoked exposure = %q, want x-1 — the smoke leaves no exposure behind", deleted) | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | // TestProveExposureDialsAnIPv6Uplink pins the dial target's composition: a host | ||
| 50 | // that answers on an IPv6 address only resolves when the address is bracketed, | ||
| 51 | // which is what net.JoinHostPort does and plain concatenation does not. | ||
| 52 | func TestProveExposureDialsAnIPv6Uplink(t *testing.T) { | ||
| 53 | clock := &fakeClock{} | ||
| 54 | api := &testAPI{ | ||
| 55 | createExposureFunc: func(context.Context, string, int64, int64) (client.Exposure, error) { | ||
| 56 | return client.Exposure{ID: "x-1", HostPort: 30080}, nil | ||
| 57 | }, | ||
| 58 | deleteExposureFunc: func(context.Context, string) error { return nil }, | ||
| 59 | } | ||
| 60 | dialed := "" | ||
| 61 | dial := func(ctx context.Context, addr string) (string, error) { | ||
| 62 | dialed = addr | ||
| 63 | return "SSH-2.0-OpenSSH_9.6\r\n", nil | ||
| 64 | } | ||
| 65 | |||
| 66 | if err := proveExposure(context.Background(), api, "2001:db8::1", "vm-1", clock.now, clock.sleep, dial); err != nil { | ||
| 67 | t.Fatalf("proveExposure: %v", err) | ||
| 68 | } | ||
| 69 | if dialed != "[2001:db8::1]:30080" { | ||
| 70 | t.Errorf("dialed = %q, want [2001:db8::1]:30080", dialed) | ||
| 71 | } | ||
| 72 | } | ||
| 73 | |||
| 74 | func TestProveExposureRetriesUntilTheListenerConverges(t *testing.T) { | ||
| 75 | clock := &fakeClock{} | ||
| 76 | api := &testAPI{ | ||
| 77 | createExposureFunc: func(context.Context, string, int64, int64) (client.Exposure, error) { | ||
| 78 | return client.Exposure{ID: "x-1", HostPort: 30080}, nil | ||
| 79 | }, | ||
| 80 | deleteExposureFunc: func(context.Context, string) error { return nil }, | ||
| 81 | } | ||
| 82 | calls := 0 | ||
| 83 | dial := func(ctx context.Context, addr string) (string, error) { | ||
| 84 | calls++ | ||
| 85 | if calls < 3 { | ||
| 86 | return "", errors.New("connection refused") | ||
| 87 | } | ||
| 88 | return "SSH-2.0-OpenSSH_9.6\r\n", nil | ||
| 89 | } | ||
| 90 | |||
| 91 | if err := proveExposure(context.Background(), api, "192.168.0.190", "vm-1", clock.now, clock.sleep, dial); err != nil { | ||
| 92 | t.Fatalf("proveExposure: %v", err) | ||
| 93 | } | ||
| 94 | if calls != 3 { | ||
| 95 | t.Errorf("dial calls = %d, want 3", calls) | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | func TestProveExposureFailsOnTheWrongBanner(t *testing.T) { | ||
| 100 | clock := &fakeClock{} | ||
| 101 | api := &testAPI{ | ||
| 102 | createExposureFunc: func(context.Context, string, int64, int64) (client.Exposure, error) { | ||
| 103 | return client.Exposure{ID: "x-1", HostPort: 30080}, nil | ||
| 104 | }, | ||
| 105 | deleteExposureFunc: func(context.Context, string) error { return nil }, | ||
| 106 | } | ||
| 107 | dial := func(ctx context.Context, addr string) (string, error) { return "HTTP/1.1 400\r\n", nil } | ||
| 108 | |||
| 109 | err := proveExposure(context.Background(), api, "192.168.0.190", "vm-1", clock.now, clock.sleep, dial) | ||
| 110 | if err == nil { | ||
| 111 | t.Fatal("proveExposure: want error for a non-sshd answer, got nil") | ||
| 112 | } | ||
| 113 | if !strings.Contains(err.Error(), "SSH-2.0") { | ||
| 114 | t.Errorf("error = %q, want it to name the banner it wanted", err.Error()) | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | func TestProveExposureFailsWhenTheHostNamesNoAddress(t *testing.T) { | ||
| 119 | clock := &fakeClock{} | ||
| 120 | api := &testAPI{} | ||
| 121 | err := proveExposure(context.Background(), api, "", "vm-1", clock.now, clock.sleep, nil) | ||
| 122 | if err == nil { | ||
| 123 | t.Fatal("proveExposure: want error when the host reported no address, got nil") | ||
| 124 | } | ||
| 125 | if !strings.Contains(err.Error(), "uplink") { | ||
| 126 | t.Errorf("error = %q, want it to mention the uplink address", err.Error()) | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | // bannerListener serves one connection, writing each of writes in turn with a | ||
| 131 | // pause between them, and returns the address to dial it on. | ||
| 132 | func bannerListener(t *testing.T, writes ...string) string { | ||
| 133 | t.Helper() | ||
| 134 | ln, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 135 | if err != nil { | ||
| 136 | t.Fatalf("listen: %v", err) | ||
| 137 | } | ||
| 138 | t.Cleanup(func() { ln.Close() }) | ||
| 139 | go func() { | ||
| 140 | c, err := ln.Accept() | ||
| 141 | if err != nil { | ||
| 142 | return | ||
| 143 | } | ||
| 144 | defer c.Close() | ||
| 145 | for _, w := range writes { | ||
| 146 | if _, err := c.Write([]byte(w)); err != nil { | ||
| 147 | return | ||
| 148 | } | ||
| 149 | time.Sleep(20 * time.Millisecond) | ||
| 150 | } | ||
| 151 | time.Sleep(50 * time.Millisecond) | ||
| 152 | }() | ||
| 153 | return ln.Addr().String() | ||
| 154 | } | ||
| 155 | |||
| 156 | func TestReadBannerReadsWhatAListenerWrites(t *testing.T) { | ||
| 157 | got, err := readBanner(context.Background(), bannerListener(t, "SSH-2.0-Test\r\n")) | ||
| 158 | if err != nil { | ||
| 159 | t.Fatalf("readBanner: %v", err) | ||
| 160 | } | ||
| 161 | if !strings.HasPrefix(got, "SSH-2.0") { | ||
| 162 | t.Errorf("banner = %q, want an SSH-2.0 prefix", got) | ||
| 163 | } | ||
| 164 | } | ||
| 165 | |||
| 166 | // TestReadBannerReassemblesAFragmentedBanner pins the read-to-newline: a | ||
| 167 | // spliced connection can deliver the banner in segments, and a first segment | ||
| 168 | // shorter than the prefix must not fail a listener that is answering | ||
| 169 | // correctly. | ||
| 170 | func TestReadBannerReassemblesAFragmentedBanner(t *testing.T) { | ||
| 171 | got, err := readBanner(context.Background(), bannerListener(t, "SS", "H-2.0-Test\r\n")) | ||
| 172 | if err != nil { | ||
| 173 | t.Fatalf("readBanner: %v", err) | ||
| 174 | } | ||
| 175 | if !strings.HasPrefix(got, "SSH-2.0") { | ||
| 176 | t.Errorf("banner = %q, want the fragments reassembled into an SSH-2.0 prefix", got) | ||
| 177 | } | ||
| 178 | } | ||
| 179 | |||
| 180 | // TestReadBannerKeepsTheErrorWhenNothingArrives pins the retryable case: a | ||
| 181 | // listener that accepts and hangs up without a byte returns its error, which | ||
| 182 | // the leg treats as "not converged yet" rather than as a wrong answer. | ||
| 183 | func TestReadBannerKeepsTheErrorWhenNothingArrives(t *testing.T) { | ||
| 184 | got, err := readBanner(context.Background(), bannerListener(t)) | ||
| 185 | if err == nil { | ||
| 186 | t.Fatalf("readBanner = %q, want the read error from a silent listener", got) | ||
| 187 | } | ||
| 188 | if got != "" { | ||
| 189 | t.Errorf("banner = %q, want empty", got) | ||
| 190 | } | ||
| 191 | } | ||
internal/smoke/run.go
| Old | New | ||
|---|---|---|---|
| @@ -97,7 +97,7 @@ func Run() error { | |||
| 97 | } | 97 | } |
| 98 | 98 | ||
| 99 | ctx := context.Background() | 99 | ctx := context.Background() |
| 100 | msg, err := runScenario(ctx, cfg, vmName, api, realRunSSH(cfg.AgentUserHost, cfg.AgentPort), gate, time.Now, time.Sleep, realReadPubKey) | 100 | msg, err := runScenario(ctx, cfg, vmName, api, realRunSSH(cfg.AgentUserHost, cfg.AgentPort), gate, time.Now, time.Sleep, realReadPubKey, readBanner) |
| 101 | if err != nil { | 101 | if err != nil { |
| 102 | return err | 102 | return err |
| 103 | } | 103 | } |
internal/smoke/scenario.go
| Old | New | ||
|---|---|---|---|
| @@ -72,6 +72,8 @@ type vmAPI interface { | |||
| 72 | ListVMs(ctx context.Context) ([]client.VM, error) | 72 | ListVMs(ctx context.Context) ([]client.VM, error) |
| 73 | DeleteVM(ctx context.Context, id string) error | 73 | DeleteVM(ctx context.Context, id string) error |
| 74 | PatchVM(ctx context.Context, id, powerState string) error | 74 | PatchVM(ctx context.Context, id, powerState string) error |
| 75 | CreateExposure(ctx context.Context, vmID string, guestPort, hostPort int64) (client.Exposure, error) | ||
| 76 | DeleteExposure(ctx context.Context, id string) error | ||
| 75 | } | 77 | } |
| 76 | 78 | ||
| 77 | // getVM finds the VM with the given id in the current listing. The bool | 79 | // getVM finds the VM with the given id in the current listing. The bool |
| @@ -90,6 +92,21 @@ func getVM(ctx context.Context, c vmAPI, id string) (client.VM, bool, error) { | |||
| 90 | return client.VM{}, false, nil | 92 | return client.VM{}, false, nil |
| 91 | } | 93 | } |
| 92 | 94 | ||
| 95 | // uplinkFor re-reads the host's own reported address — the one an operator | ||
| 96 | // pastes to reach a port published on it. | ||
| 97 | func uplinkFor(ctx context.Context, c vmAPI, hostID string) (string, error) { | ||
| 98 | hosts, err := c.ListHosts(ctx) | ||
| 99 | if err != nil { | ||
| 100 | return "", fmt.Errorf("list hosts for uplink address: %w", err) | ||
| 101 | } | ||
| 102 | for _, h := range hosts { | ||
| 103 | if h.ID == hostID { | ||
| 104 | return h.UplinkAddr, nil | ||
| 105 | } | ||
| 106 | } | ||
| 107 | return "", fmt.Errorf("host %s vanished from the fleet mid-run", hostID) | ||
| 108 | } | ||
| 109 | |||
| 93 | // sshFunc runs remoteCmd on the agent host over SSH and returns its stdout. | 110 | // sshFunc runs remoteCmd on the agent host over SSH and returns its stdout. |
| 94 | type sshFunc func(ctx context.Context, remoteCmd string) (string, error) | 111 | type sshFunc func(ctx context.Context, remoteCmd string) (string, error) |
| 95 | 112 | ||
| @@ -124,8 +141,9 @@ func pollLoop(ctx context.Context, now func() time.Time, sleep func(time.Duratio | |||
| 124 | } | 141 | } |
| 125 | 142 | ||
| 126 | // runScenario drives the full register -> create -> ready -> boot-proof -> | 143 | // runScenario drives the full register -> create -> ready -> boot-proof -> |
| 127 | // gate-exec -> reap sequence against c (the API), runSSH (the boot-proof | 144 | // gate-exec -> published-port -> reap sequence against c (the API), runSSH |
| 128 | // transport), and readPubKey (the local SSH key source). vmName is the | 145 | // (the boot-proof transport), readPubKey (the local SSH key source), and |
| 146 | // dialBanner (the published-port transport). vmName is the | ||
| 129 | // pre-generated name for the throwaway VM. gate, when non-nil, registers the | 147 | // pre-generated name for the throwaway VM. gate, when non-nil, registers the |
| 130 | // smoke's user CA with the tenant before create (the guest bakes its trusted | 148 | // smoke's user CA with the tenant before create (the guest bakes its trusted |
| 131 | // CAs at boot, so registration MUST happen first) and proves gate SSH access | 149 | // CAs at boot, so registration MUST happen first) and proves gate SSH access |
| @@ -133,7 +151,7 @@ func pollLoop(ctx context.Context, now func() time.Time, sleep func(time.Duratio | |||
| 133 | // now/sleep are the injected clock so the poll deadlines are unit-testable | 151 | // now/sleep are the injected clock so the poll deadlines are unit-testable |
| 134 | // without real waiting. On success it returns the human-readable COMPLETE | 152 | // without real waiting. On success it returns the human-readable COMPLETE |
| 135 | // line; on any failure it returns a descriptive error. | 153 | // line; on any failure it returns a descriptive error. |
| 136 | func runScenario(ctx context.Context, cfg Config, vmName string, c vmAPI, runSSH sshFunc, gate *gateHooks, now func() time.Time, sleep func(time.Duration), readPubKey func() string) (string, error) { | 154 | func runScenario(ctx context.Context, cfg Config, vmName string, c vmAPI, runSSH sshFunc, gate *gateHooks, now func() time.Time, sleep func(time.Duration), readPubKey func() string, dialBanner bannerFunc) (string, error) { |
| 137 | if gate != nil { | 155 | if gate != nil { |
| 138 | if err := gate.register(ctx); err != nil { | 156 | if err := gate.register(ctx); err != nil { |
| 139 | return "", fmt.Errorf("register smoke user CA: %w", err) | 157 | return "", fmt.Errorf("register smoke user CA: %w", err) |
| @@ -180,6 +198,7 @@ func runScenario(ctx context.Context, cfg Config, vmName string, c vmAPI, runSSH | |||
| 180 | } | 198 | } |
| 181 | 199 | ||
| 182 | gateOK := false | 200 | gateOK := false |
| 201 | exposureOK := false | ||
| 183 | if gate != nil { | 202 | if gate != nil { |
| 184 | if err := gate.exec(ctx, vmName); err != nil { | 203 | if err := gate.exec(ctx, vmName); err != nil { |
| 185 | return "", err | 204 | return "", err |
| @@ -187,6 +206,19 @@ func runScenario(ctx context.Context, cfg Config, vmName string, c vmAPI, runSSH | |||
| 187 | gateOK = true | 206 | gateOK = true |
| 188 | } | 207 | } |
| 189 | 208 | ||
| 209 | // Publish the guest's own sshd on its host and read the banner back | ||
| 210 | // through the listener. The host's address comes from the host itself, | ||
| 211 | // re-read here rather than reused from the placement lookup, so the leg | ||
| 212 | // dials whatever the host is answering on right now. | ||
| 213 | hostAddr, err := uplinkFor(ctx, c, hostID) | ||
| 214 | if err != nil { | ||
| 215 | return "", err | ||
| 216 | } | ||
| 217 | if err := proveExposure(ctx, c, hostAddr, vmID, now, sleep, dialBanner); err != nil { | ||
| 218 | return "", err | ||
| 219 | } | ||
| 220 | exposureOK = true | ||
| 221 | |||
| 190 | // Power cycle: prove the guest comes BACK. A first boot runs on the | 222 | // Power cycle: prove the guest comes BACK. A first boot runs on the |
| 191 | // kernel's in-memory partition table; only a stop→start proves the | 223 | // kernel's in-memory partition table; only a stop→start proves the |
| 192 | // on-disk GPT survived growpart. The sector-0 regression hid exactly | 224 | // on-disk GPT survived growpart. The sector-0 regression hid exactly |
| @@ -257,5 +289,8 @@ func runScenario(ctx context.Context, cfg Config, vmName string, c vmAPI, runSSH | |||
| 257 | if gateOK { | 289 | if gateOK { |
| 258 | msg += ", gate SSH: ok" | 290 | msg += ", gate SSH: ok" |
| 259 | } | 291 | } |
| 292 | if exposureOK { | ||
| 293 | msg += ", exposed port: ok" | ||
| 294 | } | ||
| 260 | return msg, nil | 295 | return msg, nil |
| 261 | } | 296 | } |
internal/smoke/scenario_test.go
| Old | New | ||
|---|---|---|---|
| @@ -54,14 +54,17 @@ type fakeClock struct{ t time.Time } | |||
| 54 | func (c *fakeClock) now() time.Time { return c.t } | 54 | func (c *fakeClock) now() time.Time { return c.t } |
| 55 | func (c *fakeClock) sleep(d time.Duration) { c.t = c.t.Add(d) } | 55 | func (c *fakeClock) sleep(d time.Duration) { c.t = c.t.Add(d) } |
| 56 | 56 | ||
| 57 | // testAPI implements vmAPI by delegating to per-test closures. patchVMFunc | 57 | // testAPI implements vmAPI by delegating to per-test closures. patchVMFunc, |
| 58 | // defaults to accepting any power-state change when nil. | 58 | // createExposureFunc, and deleteExposureFunc default to a benign answer when |
| 59 | // nil, so a test whose subject is elsewhere need not supply them. | ||
| 59 | type testAPI struct { | 60 | type testAPI struct { |
| 60 | listHostsFunc func(ctx context.Context) ([]client.Host, error) | 61 | listHostsFunc func(ctx context.Context) ([]client.Host, error) |
| 61 | createVMFunc func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) | 62 | createVMFunc func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) |
| 62 | listVMsFunc func(ctx context.Context) ([]client.VM, error) | 63 | listVMsFunc func(ctx context.Context) ([]client.VM, error) |
| 63 | deleteVMFunc func(ctx context.Context, id string) error | 64 | deleteVMFunc func(ctx context.Context, id string) error |
| 64 | patchVMFunc func(ctx context.Context, id, powerState string) error | 65 | patchVMFunc func(ctx context.Context, id, powerState string) error |
| 66 | createExposureFunc func(ctx context.Context, vmID string, guestPort, hostPort int64) (client.Exposure, error) | ||
| 67 | deleteExposureFunc func(ctx context.Context, id string) error | ||
| 65 | } | 68 | } |
| 66 | 69 | ||
| 67 | func (a *testAPI) ListHosts(ctx context.Context) ([]client.Host, error) { | 70 | func (a *testAPI) ListHosts(ctx context.Context) ([]client.Host, error) { |
| @@ -80,6 +83,19 @@ func (a *testAPI) PatchVM(ctx context.Context, id, powerState string) error { | |||
| 80 | } | 83 | } |
| 81 | return a.patchVMFunc(ctx, id, powerState) | 84 | return a.patchVMFunc(ctx, id, powerState) |
| 82 | } | 85 | } |
| 86 | func (a *testAPI) CreateExposure(ctx context.Context, vmID string, guestPort, hostPort int64) (client.Exposure, error) { | ||
| 87 | if a.createExposureFunc == nil { | ||
| 88 | return client.Exposure{ID: "x-fake", HostPort: 30080}, nil | ||
| 89 | } | ||
| 90 | return a.createExposureFunc(ctx, vmID, guestPort, hostPort) | ||
| 91 | } | ||
| 92 | |||
| 93 | func (a *testAPI) DeleteExposure(ctx context.Context, id string) error { | ||
| 94 | if a.deleteExposureFunc == nil { | ||
| 95 | return nil | ||
| 96 | } | ||
| 97 | return a.deleteExposureFunc(ctx, id) | ||
| 98 | } | ||
| 83 | 99 | ||
| 84 | func baseCfg() Config { | 100 | func baseCfg() Config { |
| 85 | return Config{AgentStateDir: "/var/lib/eitri-agent", AgentUserHost: "ubuntu@10.0.0.5", AgentPort: 22} | 101 | return Config{AgentStateDir: "/var/lib/eitri-agent", AgentUserHost: "ubuntu@10.0.0.5", AgentPort: 22} |
| @@ -87,6 +103,10 @@ func baseCfg() Config { | |||
| 87 | 103 | ||
| 88 | func noopReadPubKey() string { return "ssh-ed25519 AAAAfake test@smoke" } | 104 | func noopReadPubKey() string { return "ssh-ed25519 AAAAfake test@smoke" } |
| 89 | 105 | ||
| 106 | // okBanner is the banner a converged listener answers with, for tests whose | ||
| 107 | // subject is not the exposure leg. | ||
| 108 | func okBanner(ctx context.Context, addr string) (string, error) { return "SSH-2.0-Test\r\n", nil } | ||
| 109 | |||
| 90 | // --- runScenario: success path --------------------------------------------- | 110 | // --- runScenario: success path --------------------------------------------- |
| 91 | 111 | ||
| 92 | func TestRunScenarioSuccess(t *testing.T) { | 112 | func TestRunScenarioSuccess(t *testing.T) { |
| @@ -95,7 +115,7 @@ func TestRunScenarioSuccess(t *testing.T) { | |||
| 95 | deleted := false | 115 | deleted := false |
| 96 | api := &testAPI{ | 116 | api := &testAPI{ |
| 97 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 117 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 98 | return []client.Host{{ID: "host-1"}}, nil | 118 | return []client.Host{{ID: "host-1", UplinkAddr: "192.168.0.190"}}, nil |
| 99 | }, | 119 | }, |
| 100 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 120 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 101 | if req.HostID != "host-1" { | 121 | if req.HostID != "host-1" { |
| @@ -149,7 +169,7 @@ func TestRunScenarioSuccess(t *testing.T) { | |||
| 149 | } | 169 | } |
| 150 | 170 | ||
| 151 | clock := &fakeClock{t: time.Unix(0, 0)} | 171 | clock := &fakeClock{t: time.Unix(0, 0)} |
| 152 | msg, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey) | 172 | msg, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey, okBanner) |
| 153 | if err != nil { | 173 | if err != nil { |
| 154 | t.Fatalf("runScenario: %v", err) | 174 | t.Fatalf("runScenario: %v", err) |
| 155 | } | 175 | } |
| @@ -162,6 +182,9 @@ func TestRunScenarioSuccess(t *testing.T) { | |||
| 162 | if !strings.Contains(msg, "reboot: ok") { | 182 | if !strings.Contains(msg, "reboot: ok") { |
| 163 | t.Errorf("message = %q, want reboot: ok", msg) | 183 | t.Errorf("message = %q, want reboot: ok", msg) |
| 164 | } | 184 | } |
| 185 | if !strings.Contains(msg, "exposed port: ok") { | ||
| 186 | t.Errorf("message = %q, want exposed port: ok", msg) | ||
| 187 | } | ||
| 165 | if !sawTruncate { | 188 | if !sawTruncate { |
| 166 | t.Error("serial log was never truncated before the reboot proof — proof could pass on first-boot bytes") | 189 | t.Error("serial log was never truncated before the reboot proof — proof could pass on first-boot bytes") |
| 167 | } | 190 | } |
| @@ -175,7 +198,7 @@ func TestRunScenarioRebootDeathFails(t *testing.T) { | |||
| 175 | power := "running" | 198 | power := "running" |
| 176 | api := &testAPI{ | 199 | api := &testAPI{ |
| 177 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 200 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 178 | return []client.Host{{ID: "host-1"}}, nil | 201 | return []client.Host{{ID: "host-1", UplinkAddr: "192.168.0.190"}}, nil |
| 179 | }, | 202 | }, |
| 180 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 203 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 181 | return client.CreateVMResponse{ID: "vm-1"}, nil | 204 | return client.CreateVMResponse{ID: "vm-1"}, nil |
| @@ -207,7 +230,7 @@ func TestRunScenarioRebootDeathFails(t *testing.T) { | |||
| 207 | } | 230 | } |
| 208 | 231 | ||
| 209 | clock := &fakeClock{t: time.Unix(0, 0)} | 232 | clock := &fakeClock{t: time.Unix(0, 0)} |
| 210 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey) | 233 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey, okBanner) |
| 211 | if err == nil { | 234 | if err == nil { |
| 212 | t.Fatal("runScenario: want error for a VM that never came back, got nil") | 235 | t.Fatal("runScenario: want error for a VM that never came back, got nil") |
| 213 | } | 236 | } |
| @@ -216,12 +239,41 @@ func TestRunScenarioRebootDeathFails(t *testing.T) { | |||
| 216 | } | 239 | } |
| 217 | } | 240 | } |
| 218 | 241 | ||
| 242 | // --- runScenario: published port ---------------------------------------- | ||
| 243 | |||
| 244 | // TestRunScenarioExposureFailureFails pins the published-port leg as a hard | ||
| 245 | // gate: a host whose listener answers with something that is not an sshd fails | ||
| 246 | // the scenario, and the VM is never reaped over the failure. | ||
| 247 | func TestRunScenarioExposureFailureFails(t *testing.T) { | ||
| 248 | var calls []string | ||
| 249 | api := happyPathAPI(t, &calls) | ||
| 250 | api.deleteVMFunc = func(ctx context.Context, id string) error { | ||
| 251 | t.Fatal("DeleteVM must not be called when the published port failed") | ||
| 252 | return nil | ||
| 253 | } | ||
| 254 | revoked := "" | ||
| 255 | api.deleteExposureFunc = func(ctx context.Context, id string) error { revoked = id; return nil } | ||
| 256 | badBanner := func(ctx context.Context, addr string) (string, error) { return "HTTP/1.1 400\r\n", nil } | ||
| 257 | |||
| 258 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 259 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), nil, clock.now, clock.sleep, noopReadPubKey, badBanner) | ||
| 260 | if err == nil { | ||
| 261 | t.Fatal("runScenario: want error, got nil") | ||
| 262 | } | ||
| 263 | if !strings.Contains(err.Error(), "SSH-2.0") { | ||
| 264 | t.Errorf("error = %q, want it to name the banner it wanted", err.Error()) | ||
| 265 | } | ||
| 266 | if revoked != "x-fake" { | ||
| 267 | t.Errorf("revoked exposure = %q, want the failed leg to revoke its grant", revoked) | ||
| 268 | } | ||
| 269 | } | ||
| 270 | |||
| 219 | // --- runScenario: serial panic ----------------------------------------- | 271 | // --- runScenario: serial panic ----------------------------------------- |
| 220 | 272 | ||
| 221 | func TestRunScenarioSerialPanicFails(t *testing.T) { | 273 | func TestRunScenarioSerialPanicFails(t *testing.T) { |
| 222 | api := &testAPI{ | 274 | api := &testAPI{ |
| 223 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 275 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 224 | return []client.Host{{ID: "host-1"}}, nil | 276 | return []client.Host{{ID: "host-1", UplinkAddr: "192.168.0.190"}}, nil |
| 225 | }, | 277 | }, |
| 226 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 278 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 227 | return client.CreateVMResponse{ID: "vm-1"}, nil | 279 | return client.CreateVMResponse{ID: "vm-1"}, nil |
| @@ -239,7 +291,7 @@ func TestRunScenarioSerialPanicFails(t *testing.T) { | |||
| 239 | } | 291 | } |
| 240 | 292 | ||
| 241 | clock := &fakeClock{t: time.Unix(0, 0)} | 293 | clock := &fakeClock{t: time.Unix(0, 0)} |
| 242 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey) | 294 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey, okBanner) |
| 243 | if err == nil { | 295 | if err == nil { |
| 244 | t.Fatal("runScenario: want error, got nil") | 296 | t.Fatal("runScenario: want error, got nil") |
| 245 | } | 297 | } |
| @@ -253,7 +305,7 @@ func TestRunScenarioSerialPanicFails(t *testing.T) { | |||
| 253 | func TestRunScenarioNeverReadyTimesOut(t *testing.T) { | 305 | func TestRunScenarioNeverReadyTimesOut(t *testing.T) { |
| 254 | api := &testAPI{ | 306 | api := &testAPI{ |
| 255 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 307 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 256 | return []client.Host{{ID: "host-1"}}, nil | 308 | return []client.Host{{ID: "host-1", UplinkAddr: "192.168.0.190"}}, nil |
| 257 | }, | 309 | }, |
| 258 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 310 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 259 | return client.CreateVMResponse{ID: "vm-1"}, nil | 311 | return client.CreateVMResponse{ID: "vm-1"}, nil |
| @@ -272,7 +324,7 @@ func TestRunScenarioNeverReadyTimesOut(t *testing.T) { | |||
| 272 | } | 324 | } |
| 273 | 325 | ||
| 274 | clock := &fakeClock{t: time.Unix(0, 0)} | 326 | clock := &fakeClock{t: time.Unix(0, 0)} |
| 275 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey) | 327 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey, okBanner) |
| 276 | if err == nil { | 328 | if err == nil { |
| 277 | t.Fatal("runScenario: want error, got nil") | 329 | t.Fatal("runScenario: want error, got nil") |
| 278 | } | 330 | } |
| @@ -290,7 +342,7 @@ func TestRunScenarioNeverReapedTimesOut(t *testing.T) { | |||
| 290 | power := "running" | 342 | power := "running" |
| 291 | api := &testAPI{ | 343 | api := &testAPI{ |
| 292 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 344 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 293 | return []client.Host{{ID: "host-1"}}, nil | 345 | return []client.Host{{ID: "host-1", UplinkAddr: "192.168.0.190"}}, nil |
| 294 | }, | 346 | }, |
| 295 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 347 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 296 | return client.CreateVMResponse{ID: "vm-1"}, nil | 348 | return client.CreateVMResponse{ID: "vm-1"}, nil |
| @@ -310,7 +362,7 @@ func TestRunScenarioNeverReapedTimesOut(t *testing.T) { | |||
| 310 | } | 362 | } |
| 311 | 363 | ||
| 312 | clock := &fakeClock{t: time.Unix(0, 0)} | 364 | clock := &fakeClock{t: time.Unix(0, 0)} |
| 313 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey) | 365 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey, okBanner) |
| 314 | if err == nil { | 366 | if err == nil { |
| 315 | t.Fatal("runScenario: want error, got nil") | 367 | t.Fatal("runScenario: want error, got nil") |
| 316 | } | 368 | } |
| @@ -338,7 +390,7 @@ func TestRunScenarioNoHosts(t *testing.T) { | |||
| 338 | }, | 390 | }, |
| 339 | } | 391 | } |
| 340 | clock := &fakeClock{t: time.Unix(0, 0)} | 392 | clock := &fakeClock{t: time.Unix(0, 0)} |
| 341 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, nil, nil, clock.now, clock.sleep, noopReadPubKey) | 393 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, nil, nil, clock.now, clock.sleep, noopReadPubKey, okBanner) |
| 342 | if err == nil { | 394 | if err == nil { |
| 343 | t.Fatal("runScenario: want error, got nil") | 395 | t.Fatal("runScenario: want error, got nil") |
| 344 | } | 396 | } |
| @@ -355,7 +407,7 @@ func happyPathAPI(t *testing.T, calls *[]string) *testAPI { | |||
| 355 | deleted := false | 407 | deleted := false |
| 356 | return &testAPI{ | 408 | return &testAPI{ |
| 357 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 409 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 358 | return []client.Host{{ID: "host-1"}}, nil | 410 | return []client.Host{{ID: "host-1", UplinkAddr: "192.168.0.190"}}, nil |
| 359 | }, | 411 | }, |
| 360 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 412 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 361 | *calls = append(*calls, "createVM") | 413 | *calls = append(*calls, "createVM") |
| @@ -412,7 +464,7 @@ func TestRunScenarioGateRegistersBeforeCreateAndExecsAfterBoot(t *testing.T) { | |||
| 412 | } | 464 | } |
| 413 | 465 | ||
| 414 | clock := &fakeClock{t: time.Unix(0, 0)} | 466 | clock := &fakeClock{t: time.Unix(0, 0)} |
| 415 | msg, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), gate, clock.now, clock.sleep, noopReadPubKey) | 467 | msg, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), gate, clock.now, clock.sleep, noopReadPubKey, okBanner) |
| 416 | if err != nil { | 468 | if err != nil { |
| 417 | t.Fatalf("runScenario: %v", err) | 469 | t.Fatalf("runScenario: %v", err) |
| 418 | } | 470 | } |
| @@ -462,7 +514,7 @@ func TestRunScenarioGateRegisterErrorAbortsBeforeCreate(t *testing.T) { | |||
| 462 | } | 514 | } |
| 463 | 515 | ||
| 464 | clock := &fakeClock{t: time.Unix(0, 0)} | 516 | clock := &fakeClock{t: time.Unix(0, 0)} |
| 465 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, nil, gate, clock.now, clock.sleep, noopReadPubKey) | 517 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, nil, gate, clock.now, clock.sleep, noopReadPubKey, okBanner) |
| 466 | if err == nil { | 518 | if err == nil { |
| 467 | t.Fatal("runScenario: want error, got nil") | 519 | t.Fatal("runScenario: want error, got nil") |
| 468 | } | 520 | } |
| @@ -486,7 +538,7 @@ func TestRunScenarioGateExecErrorFails(t *testing.T) { | |||
| 486 | } | 538 | } |
| 487 | 539 | ||
| 488 | clock := &fakeClock{t: time.Unix(0, 0)} | 540 | clock := &fakeClock{t: time.Unix(0, 0)} |
| 489 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), gate, clock.now, clock.sleep, noopReadPubKey) | 541 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), gate, clock.now, clock.sleep, noopReadPubKey, okBanner) |
| 490 | if err == nil { | 542 | if err == nil { |
| 491 | t.Fatal("runScenario: want error, got nil") | 543 | t.Fatal("runScenario: want error, got nil") |
| 492 | } | 544 | } |
proto/eitri/v1/sync.proto
| Old | New | ||
|---|---|---|---|
| @@ -99,6 +99,14 @@ message ActualStateReport { | |||
| 99 | // knowable at connect time at all. Capacity is in both messages for the same | 99 | // knowable at connect time at all. Capacity is in both messages for the same |
| 100 | // reason: an opening value, then the ongoing truth. | 100 | // reason: an opening value, then the ongoing truth. |
| 101 | string guest_cidr = 8; | 101 | string guest_cidr = 8; |
| 102 | repeated ExposureActual exposures = 9; | ||
| 103 | // The address this host presents on the network it reaches the control | ||
| 104 | // plane over — the address an operator dials to reach a published guest | ||
| 105 | // port. Empty means "not yet known", never "no address": a host that cannot | ||
| 106 | // answer leaves the fleet's record alone rather than erasing it. In the | ||
| 107 | // report rather than Hello for the same reason guest_cidr is: it can change | ||
| 108 | // while an agent stays connected. | ||
| 109 | string host_uplink_addr = 10; | ||
| 102 | } | 110 | } |
| 103 | 111 | ||
| 104 | message VMDesired { | 112 | message VMDesired { |
| @@ -125,6 +133,7 @@ message DesiredStateSnapshot { | |||
| 125 | uint64 epoch = 1; // agents refuse epoch < highest seen | 133 | uint64 epoch = 1; // agents refuse epoch < highest seen |
| 126 | repeated VMDesired vms = 2; // FULL set for this host, including tombstoned | 134 | repeated VMDesired vms = 2; // FULL set for this host, including tombstoned |
| 127 | AgentUpgrade agent_upgrade = 3; // optional operator-initiated agent self-upgrade | 135 | AgentUpgrade agent_upgrade = 3; // optional operator-initiated agent self-upgrade |
| 136 | repeated ExposureDesired exposures = 4; // FULL set for this host | ||
| 128 | } | 137 | } |
| 129 | 138 | ||
| 130 | // AgentUpgrade asks the agent to replace its own binary: download url, verify | 139 | // AgentUpgrade asks the agent to replace its own binary: download url, verify |
| @@ -167,3 +176,27 @@ message TCPOpened { | |||
| 167 | bool ok = 1; | 176 | bool ok = 1; |
| 168 | string error = 2; | 177 | string error = 2; |
| 169 | } | 178 | } |
| 179 | |||
| 180 | // ExposureDesired is one published guest port a host should be serving: bind | ||
| 181 | // host_port on the host, pipe every accepted connection to guest_port inside | ||
| 182 | // the guest. It rides the snapshot at TOP LEVEL rather than nested in | ||
| 183 | // VMDesired, because exposures are their own objects converging on their own | ||
| 184 | // cadence — an exposure can be created while its VM is still imaging, and it | ||
| 185 | // binds immediately. | ||
| 186 | message ExposureDesired { | ||
| 187 | string id = 1; | ||
| 188 | string vm_id = 2; | ||
| 189 | uint32 guest_port = 3; | ||
| 190 | uint32 host_port = 4; | ||
| 191 | string protocol = 5; // "tcp" | ||
| 192 | } | ||
| 193 | |||
| 194 | // ExposureActual is one exposure's state as its host observes it: "active" | ||
| 195 | // once the host listener is bound, "failed" with the OS error otherwise. | ||
| 196 | // "active" means the HOST half of the pipe exists — whether anything answers | ||
| 197 | // inside the guest is the guest's half, and this does not pretend otherwise. | ||
| 198 | message ExposureActual { | ||
| 199 | string id = 1; | ||
| 200 | string state = 2; // "active"|"failed" | ||
| 201 | string reason = 3; // the OS error, when failed | ||
| 202 | } | ||
scripts/coverage.sh
| Old | New | ||
|---|---|---|---|
| @@ -28,6 +28,7 @@ declare -A FLOOR=( | |||
| 28 | [internal/agent/pidfile]=95 | 28 | [internal/agent/pidfile]=95 |
| 29 | [internal/agent/vfkit]=85 | 29 | [internal/agent/vfkit]=85 |
| 30 | [internal/agent/syncclient]=74 | 30 | [internal/agent/syncclient]=74 |
| 31 | [internal/agent/exposeproxy]=80 | ||
| 31 | [internal/server/api]=76 | 32 | [internal/server/api]=76 |
| 32 | [internal/server/boot]=17 | 33 | [internal/server/boot]=17 |
| 33 | [internal/server/api/client]=89 | 34 | [internal/server/api/client]=89 |
web/src/lib/api-types.ts
| Old | New | ||
|---|---|---|---|
| @@ -194,6 +194,51 @@ export interface paths { | |||
| 194 | patch?: never; | 194 | patch?: never; |
| 195 | trace?: never; | 195 | trace?: never; |
| 196 | }; | 196 | }; |
| 197 | "/api/v1/exposures/{id}": { | ||
| 198 | parameters: { | ||
| 199 | query?: never; | ||
| 200 | header?: never; | ||
| 201 | path?: never; | ||
| 202 | cookie?: never; | ||
| 203 | }; | ||
| 204 | get?: never; | ||
| 205 | put?: never; | ||
| 206 | post?: never; | ||
| 207 | /** Revoke an exposure; its host closes the listener on the next converge. */ | ||
| 208 | delete: { | ||
| 209 | parameters: { | ||
| 210 | query?: never; | ||
| 211 | header?: never; | ||
| 212 | path: { | ||
| 213 | id: string; | ||
| 214 | }; | ||
| 215 | cookie?: never; | ||
| 216 | }; | ||
| 217 | requestBody?: never; | ||
| 218 | responses: { | ||
| 219 | /** @description success */ | ||
| 220 | 204: { | ||
| 221 | headers: { | ||
| 222 | [name: string]: unknown; | ||
| 223 | }; | ||
| 224 | content?: never; | ||
| 225 | }; | ||
| 226 | /** @description error (plain text) */ | ||
| 227 | default: { | ||
| 228 | headers: { | ||
| 229 | [name: string]: unknown; | ||
| 230 | }; | ||
| 231 | content: { | ||
| 232 | "text/plain": string; | ||
| 233 | }; | ||
| 234 | }; | ||
| 235 | }; | ||
| 236 | }; | ||
| 237 | options?: never; | ||
| 238 | head?: never; | ||
| 239 | patch?: never; | ||
| 240 | trace?: never; | ||
| 241 | }; | ||
| 197 | "/api/v1/hosts": { | 242 | "/api/v1/hosts": { |
| 198 | parameters: { | 243 | parameters: { |
| 199 | query?: never; | 244 | query?: never; |
| @@ -1174,6 +1219,88 @@ export interface paths { | |||
| 1174 | patch?: never; | 1219 | patch?: never; |
| 1175 | trace?: never; | 1220 | trace?: never; |
| 1176 | }; | 1221 | }; |
| 1222 | "/api/v1/vms/{id}/exposures": { | ||
| 1223 | parameters: { | ||
| 1224 | query?: never; | ||
| 1225 | header?: never; | ||
| 1226 | path?: never; | ||
| 1227 | cookie?: never; | ||
| 1228 | }; | ||
| 1229 | /** List the VM's published ports, with the host address to dial and each listener's reported state. */ | ||
| 1230 | get: { | ||
| 1231 | parameters: { | ||
| 1232 | query?: never; | ||
| 1233 | header?: never; | ||
| 1234 | path: { | ||
| 1235 | id: string; | ||
| 1236 | }; | ||
| 1237 | cookie?: never; | ||
| 1238 | }; | ||
| 1239 | requestBody?: never; | ||
| 1240 | responses: { | ||
| 1241 | /** @description success */ | ||
| 1242 | 200: { | ||
| 1243 | headers: { | ||
| 1244 | [name: string]: unknown; | ||
| 1245 | }; | ||
| 1246 | content: { | ||
| 1247 | "application/json": components["schemas"]["Exposure"][]; | ||
| 1248 | }; | ||
| 1249 | }; | ||
| 1250 | /** @description error (plain text) */ | ||
| 1251 | default: { | ||
| 1252 | headers: { | ||
| 1253 | [name: string]: unknown; | ||
| 1254 | }; | ||
| 1255 | content: { | ||
| 1256 | "text/plain": string; | ||
| 1257 | }; | ||
| 1258 | }; | ||
| 1259 | }; | ||
| 1260 | }; | ||
| 1261 | put?: never; | ||
| 1262 | /** Publish a guest TCP port on the VM's host. Omit host_port to allocate one from the reserved range 30000-32767; a named port must be >= 1024 and is honored or refused. */ | ||
| 1263 | post: { | ||
| 1264 | parameters: { | ||
| 1265 | query?: never; | ||
| 1266 | header?: never; | ||
| 1267 | path: { | ||
| 1268 | id: string; | ||
| 1269 | }; | ||
| 1270 | cookie?: never; | ||
| 1271 | }; | ||
| 1272 | requestBody: { | ||
| 1273 | content: { | ||
| 1274 | "application/json": components["schemas"]["CreateExposureRequest"]; | ||
| 1275 | }; | ||
| 1276 | }; | ||
| 1277 | responses: { | ||
| 1278 | /** @description success */ | ||
| 1279 | 201: { | ||
| 1280 | headers: { | ||
| 1281 | [name: string]: unknown; | ||
| 1282 | }; | ||
| 1283 | content: { | ||
| 1284 | "application/json": components["schemas"]["Exposure"]; | ||
| 1285 | }; | ||
| 1286 | }; | ||
| 1287 | /** @description error (plain text) */ | ||
| 1288 | default: { | ||
| 1289 | headers: { | ||
| 1290 | [name: string]: unknown; | ||
| 1291 | }; | ||
| 1292 | content: { | ||
| 1293 | "text/plain": string; | ||
| 1294 | }; | ||
| 1295 | }; | ||
| 1296 | }; | ||
| 1297 | }; | ||
| 1298 | delete?: never; | ||
| 1299 | options?: never; | ||
| 1300 | head?: never; | ||
| 1301 | patch?: never; | ||
| 1302 | trace?: never; | ||
| 1303 | }; | ||
| 1177 | "/api/v1/vms/{id}/restore": { | 1304 | "/api/v1/vms/{id}/restore": { |
| 1178 | parameters: { | 1305 | parameters: { |
| 1179 | query?: never; | 1306 | query?: never; |
| @@ -1252,6 +1379,10 @@ export interface components { | |||
| 1252 | name: string; | 1379 | name: string; |
| 1253 | token: string; | 1380 | token: string; |
| 1254 | }; | 1381 | }; |
| 1382 | CreateExposureRequest: { | ||
| 1383 | guest_port?: number; | ||
| 1384 | host_port?: number; | ||
| 1385 | }; | ||
| 1255 | CreateVMRequest: { | 1386 | CreateVMRequest: { |
| 1256 | cloud_init?: string; | 1387 | cloud_init?: string; |
| 1257 | disk_gb?: number; | 1388 | disk_gb?: number; |
| @@ -1287,6 +1418,20 @@ export interface components { | |||
| 1287 | join: string; | 1418 | join: string; |
| 1288 | token: string; | 1419 | token: string; |
| 1289 | }; | 1420 | }; |
| 1421 | Exposure: { | ||
| 1422 | /** Format: date-time */ | ||
| 1423 | created_at: string; | ||
| 1424 | guest_port: number; | ||
| 1425 | host_addr: string; | ||
| 1426 | host_id: string; | ||
| 1427 | host_port: number; | ||
| 1428 | id: string; | ||
| 1429 | protocol: string; | ||
| 1430 | reason: string; | ||
| 1431 | scope: string; | ||
| 1432 | state: string; | ||
| 1433 | vm_id: string; | ||
| 1434 | }; | ||
| 1290 | Host: { | 1435 | Host: { |
| 1291 | agent_update_available: boolean; | 1436 | agent_update_available: boolean; |
| 1292 | agent_version: string; | 1437 | agent_version: string; |
| @@ -1313,6 +1458,7 @@ export interface components { | |||
| 1313 | sessions: number; | 1458 | sessions: number; |
| 1314 | stale: boolean; | 1459 | stale: boolean; |
| 1315 | status: string; | 1460 | status: string; |
| 1461 | uplink_addr: string; | ||
| 1316 | virt: string; | 1462 | virt: string; |
| 1317 | }; | 1463 | }; |
| 1318 | InjectedKey: { | 1464 | InjectedKey: { |
web/src/lib/fleet.svelte.ts
| Old | New | ||
|---|---|---|---|
| @@ -20,6 +20,11 @@ export type VMEvent = components['schemas']['AuditEvent']; | |||
| 20 | * the canonical pubkey line, an optional label, and the SHA256 fingerprint. */ | 20 | * the canonical pubkey line, an optional label, and the SHA256 fingerprint. */ |
| 21 | export type UserCA = components['schemas']['UserCA']; | 21 | export type UserCA = components['schemas']['UserCA']; |
| 22 | 22 | ||
| 23 | /** Exposure is one published guest port: the fleet binds host_port on the VM's | ||
| 24 | * host and pipes it to guest_port inside the guest. host_addr is the address | ||
| 25 | * to dial; state is what the host says its listener is doing. */ | ||
| 26 | export type Exposure = components['schemas']['Exposure']; | ||
| 27 | |||
| 23 | export type CreateVMRequest = components['schemas']['CreateVMRequest']; | 28 | export type CreateVMRequest = components['schemas']['CreateVMRequest']; |
| 24 | 29 | ||
| 25 | /** Me is the signed-in identity: the caller's tenant handle and bound email. */ | 30 | /** Me is the signed-in identity: the caller's tenant handle and bound email. */ |
| @@ -255,6 +260,31 @@ export async function vmEvents(id: string): Promise<VMEvent[]> { | |||
| 255 | return (await req('GET', `/api/v1/vms/${id}/events`)).json(); | 260 | return (await req('GET', `/api/v1/vms/${id}/events`)).json(); |
| 256 | } | 261 | } |
| 257 | 262 | ||
| 263 | /** listExposures fetches the VM's published ports, lowest host port first. */ | ||
| 264 | export async function listExposures(id: string): Promise<Exposure[]> { | ||
| 265 | return (await req('GET', `/api/v1/vms/${id}/exposures`)).json(); | ||
| 266 | } | ||
| 267 | |||
| 268 | /** createExposure publishes a guest port. hostPort 0 asks the control plane to | ||
| 269 | * allocate one from the reserved range. */ | ||
| 270 | export async function createExposure( | ||
| 271 | id: string, | ||
| 272 | guestPort: number, | ||
| 273 | hostPort: number | ||
| 274 | ): Promise<Exposure> { | ||
| 275 | return ( | ||
| 276 | await req('POST', `/api/v1/vms/${id}/exposures`, { | ||
| 277 | guest_port: guestPort, | ||
| 278 | host_port: hostPort | ||
| 279 | }) | ||
| 280 | ).json(); | ||
| 281 | } | ||
| 282 | |||
| 283 | /** deleteExposure revokes an exposure; its host closes the listener. */ | ||
| 284 | export async function deleteExposure(id: string) { | ||
| 285 | await req('DELETE', `/api/v1/exposures/${id}`); | ||
| 286 | } | ||
| 287 | |||
| 258 | /** listUserCAs fetches the tenant's registered SSH user CAs. */ | 288 | /** listUserCAs fetches the tenant's registered SSH user CAs. */ |
| 259 | export async function listUserCAs(): Promise<UserCA[]> { | 289 | export async function listUserCAs(): Promise<UserCA[]> { |
| 260 | return (await req('GET', `/api/v1/tenants/${tenant()}/user-cas`)).json(); | 290 | return (await req('GET', `/api/v1/tenants/${tenant()}/user-cas`)).json(); |
| @@ -371,6 +401,14 @@ export function teardownApprox(vm: VM, nowSec: number): string { | |||
| 371 | return `~${Math.ceil(left / 60)}m`; | 401 | return `~${Math.ceil(left / 60)}m`; |
| 372 | } | 402 | } |
| 373 | 403 | ||
| 404 | /** formatHostPort renders a dial target from an address and a port. The API | ||
| 405 | * ships the two apart precisely so the renderer owns this: an IPv6 address | ||
| 406 | * (the only form carrying a colon) has to be bracketed, or the result reads | ||
| 407 | * as one more hextet. */ | ||
| 408 | export function formatHostPort(addr: string, port: number): string { | ||
| 409 | return addr.includes(':') ? `[${addr}]:${port}` : `${addr}:${port}`; | ||
| 410 | } | ||
| 411 | |||
| 374 | /** eventLabel maps a lifecycle VMEvent to a human label, folding in the one | 412 | /** eventLabel maps a lifecycle VMEvent to a human label, folding in the one |
| 375 | * detail that matters per action. detail arrives already decoded (raw JSON on | 413 | * detail that matters per action. detail arrives already decoded (raw JSON on |
| 376 | * the wire); guard the shape defensively rather than parsing. */ | 414 | * the wire); guard the shape defensively rather than parsing. */ |
| @@ -388,6 +426,10 @@ export function eventLabel(ev: VMEvent): string { | |||
| 388 | return 'Restored'; | 426 | return 'Restored'; |
| 389 | case 'vm.reap': | 427 | case 'vm.reap': |
| 390 | return `Destroyed (${detail.reason ?? '?'})`; | 428 | return `Destroyed (${detail.reason ?? '?'})`; |
| 429 | case 'exposure.create': | ||
| 430 | return `Exposed guest :${detail.guest_port ?? '?'} on host :${detail.host_port ?? '?'}`; | ||
| 431 | case 'exposure.delete': | ||
| 432 | return `Exposure removed (guest :${detail.guest_port ?? '?'})`; | ||
| 391 | default: | 433 | default: |
| 392 | return ev.action; | 434 | return ev.action; |
| 393 | } | 435 | } |
web/src/routes/vms/[id]/+page.svelte
| Old | New | ||
|---|---|---|---|
| @@ -14,8 +14,13 @@ | |||
| 14 | vmIsRunning, | 14 | vmIsRunning, |
| 15 | vmEvents, | 15 | vmEvents, |
| 16 | eventLabel, | 16 | eventLabel, |
| 17 | listExposures, | ||
| 18 | createExposure, | ||
| 19 | deleteExposure, | ||
| 20 | formatHostPort, | ||
| 17 | clock, | 21 | clock, |
| 18 | type VMEvent | 22 | type VMEvent, |
| 23 | type Exposure | ||
| 19 | } from '$lib/fleet.svelte'; | 24 | } from '$lib/fleet.svelte'; |
| 20 | 25 | ||
| 21 | const id = $derived(page.params.id); | 26 | const id = $derived(page.params.id); |
| @@ -62,6 +67,71 @@ | |||
| 62 | loadEvents(id); | 67 | loadEvents(id); |
| 63 | }); | 68 | }); |
| 64 | 69 | ||
| 70 | let exposures = $state<Exposure[]>([]); | ||
| 71 | let exposuresFailed = $state(false); | ||
| 72 | let guestPortInput = $state(''); | ||
| 73 | let hostPortInput = $state(''); | ||
| 74 | |||
| 75 | async function loadExposures(forId: string | undefined) { | ||
| 76 | if (!forId) return; | ||
| 77 | try { | ||
| 78 | const list = await listExposures(forId); | ||
| 79 | if (forId !== id) return; // navigated away mid-flight | ||
| 80 | exposures = list; | ||
| 81 | exposuresFailed = false; | ||
| 82 | } catch { | ||
| 83 | if (forId !== id) return; | ||
| 84 | exposuresFailed = true; | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | // Reload alongside the timeline: an exposure's state is what its host last | ||
| 89 | // reported, so it moves as the VM does. | ||
| 90 | $effect(() => { | ||
| 91 | void lifecycle; | ||
| 92 | exposures = []; | ||
| 93 | exposuresFailed = false; | ||
| 94 | loadExposures(id); | ||
| 95 | }); | ||
| 96 | |||
| 97 | // exposureTarget is the address:port to dial, or '' while no address is | ||
| 98 | // known yet — an empty host_addr means the host hasn't reported one, never | ||
| 99 | // that the exposure has none, so the row says so rather than printing | ||
| 100 | // something undialable. The SSE-fed host is the second source, so a row | ||
| 101 | // heals within a tick of its host reporting instead of waiting for a refetch. | ||
| 102 | function exposureTarget(e: Exposure): string { | ||
| 103 | const addr = e.host_addr || host?.uplink_addr || ''; | ||
| 104 | return addr ? formatHostPort(addr, e.host_port) : ''; | ||
| 105 | } | ||
| 106 | |||
| 107 | // exposureState is the state a row shows. A VM being torn down dominates: | ||
| 108 | // its host stops reporting the listeners, so every row would otherwise read | ||
| 109 | // "pending" while the truer answer is that the VM is going away. | ||
| 110 | function exposureState(e: Exposure): string { | ||
| 111 | return tearingDown ? 'deleting' : e.state; | ||
| 112 | } | ||
| 113 | |||
| 114 | async function addExposure(e: SubmitEvent) { | ||
| 115 | e.preventDefault(); | ||
| 116 | if (!vm) return; | ||
| 117 | const guest = Number(guestPortInput); | ||
| 118 | const host = hostPortInput === '' ? 0 : Number(hostPortInput); | ||
| 119 | const vmId = vm.id; | ||
| 120 | if (await action(() => createExposure(vmId, guest, host))) { | ||
| 121 | guestPortInput = ''; | ||
| 122 | hostPortInput = ''; | ||
| 123 | await loadExposures(vmId); | ||
| 124 | } | ||
| 125 | } | ||
| 126 | |||
| 127 | async function removeExposure(exposureId: string) { | ||
| 128 | if (!vm) return; | ||
| 129 | const vmId = vm.id; | ||
| 130 | if (await action(() => deleteExposure(exposureId))) { | ||
| 131 | await loadExposures(vmId); | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 65 | async function power(p: 'running' | 'stopped') { | 135 | async function power(p: 'running' | 'stopped') { |
| 66 | if (!vm) return; | 136 | if (!vm) return; |
| 67 | const vmId = vm.id; | 137 | const vmId = vm.id; |
| @@ -167,6 +237,66 @@ | |||
| 167 | </div> | 237 | </div> |
| 168 | {/if} | 238 | {/if} |
| 169 | 239 | ||
| 240 | <div class="exposures"> | ||
| 241 | <h3>Exposures</h3> | ||
| 242 | {#if exposuresFailed} | ||
| 243 | <p class="hint">couldn't load exposures</p> | ||
| 244 | {:else if exposures.length === 0} | ||
| 245 | <p class="hint"> | ||
| 246 | No published ports. The SSH gate reaches this guest; anything else it serves needs a port | ||
| 247 | published here. | ||
| 248 | </p> | ||
| 249 | {:else} | ||
| 250 | <table class="kv"> | ||
| 251 | <tbody> | ||
| 252 | {#each exposures as e (e.id)} | ||
| 253 | <tr> | ||
| 254 | <th>guest :{e.guest_port}</th> | ||
| 255 | <td> | ||
| 256 | {#if exposureTarget(e)} | ||
| 257 | <code>{exposureTarget(e)}</code> | ||
| 258 | {:else} | ||
| 259 | <span class="hint">host address not yet known · :{e.host_port}</span> | ||
| 260 | {/if} | ||
| 261 | <span class="state {exposureState(e)}">● {exposureState(e)}</span> | ||
| 262 | {#if e.reason && !tearingDown}<span class="err">{e.reason}</span>{/if} | ||
| 263 | </td> | ||
| 264 | <td> | ||
| 265 | {#if !tearingDown} | ||
| 266 | <button class="ghost" onclick={() => removeExposure(e.id)}>Remove</button> | ||
| 267 | {/if} | ||
| 268 | </td> | ||
| 269 | </tr> | ||
| 270 | {/each} | ||
| 271 | </tbody> | ||
| 272 | </table> | ||
| 273 | {/if} | ||
| 274 | {#if !tearingDown} | ||
| 275 | <form class="expose-form" onsubmit={addExposure}> | ||
| 276 | <input | ||
| 277 | type="number" | ||
| 278 | min="1" | ||
| 279 | max="65535" | ||
| 280 | required | ||
| 281 | bind:value={guestPortInput} | ||
| 282 | placeholder="guest port" | ||
| 283 | /> | ||
| 284 | <input | ||
| 285 | type="number" | ||
| 286 | min="1024" | ||
| 287 | max="65535" | ||
| 288 | bind:value={hostPortInput} | ||
| 289 | placeholder="host port (optional)" | ||
| 290 | /> | ||
| 291 | <button type="submit">Expose</button> | ||
| 292 | </form> | ||
| 293 | {/if} | ||
| 294 | <p class="hint"> | ||
| 295 | Anything that can reach the host can reach a published port—there is no authentication in | ||
| 296 | front of one. | ||
| 297 | </p> | ||
| 298 | </div> | ||
| 299 | |||
| 170 | {#if !tearingDown} | 300 | {#if !tearingDown} |
| 171 | <Console vmId={vm.id} /> | 301 | <Console vmId={vm.id} /> |
| 172 | {/if} | 302 | {/if} |
| @@ -219,6 +349,41 @@ | |||
| 219 | :global(button.restore:hover) { | 349 | :global(button.restore:hover) { |
| 220 | background: #37b24d; | 350 | background: #37b24d; |
| 221 | } | 351 | } |
| 352 | .exposures { | ||
| 353 | margin-top: 1.2rem; | ||
| 354 | } | ||
| 355 | .exposures h3 { | ||
| 356 | margin: 0 0 0.2rem; | ||
| 357 | font-size: 13px; | ||
| 358 | } | ||
| 359 | .exposures .kv th { | ||
| 360 | color: #8b919c; | ||
| 361 | width: 140px; | ||
| 362 | font-weight: 400; | ||
| 363 | white-space: nowrap; | ||
| 364 | } | ||
| 365 | .state { | ||
| 366 | margin-left: 0.5rem; | ||
| 367 | font-size: 12px; | ||
| 368 | } | ||
| 369 | .state.active { | ||
| 370 | color: #51cf66; | ||
| 371 | } | ||
| 372 | .state.failed { | ||
| 373 | color: #ffb4b4; | ||
| 374 | } | ||
| 375 | .state.pending, | ||
| 376 | .state.deleting { | ||
| 377 | color: #8b919c; | ||
| 378 | } | ||
| 379 | .expose-form { | ||
| 380 | display: flex; | ||
| 381 | gap: 0.4rem; | ||
| 382 | margin-top: 0.5rem; | ||
| 383 | } | ||
| 384 | .expose-form input { | ||
| 385 | width: 10rem; | ||
| 386 | } | ||
| 222 | .timeline { | 387 | .timeline { |
| 223 | margin-top: 1.2rem; | 388 | margin-top: 1.2rem; |
| 224 | } | 389 | } |