a73x

6b7245e6

feat: a tenant publishes a VM's port on its host

a73x   2026-08-06 09:12

Commit message
feat: a tenant publishes a VM's port on its host

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 }