internal/server/api/exposures.go
Ref: Size: 7.9 KiB History
package api
import (
"database/sql"
"errors"
"net/http"
"strconv"
"github.com/a73x/eitri/internal/server/api/types"
"github.com/a73x/eitri/internal/server/registry"
"github.com/a73x/eitri/internal/server/release"
"github.com/a73x/eitri/internal/server/store"
)
// exposureVM resolves the {id} path segment to a VM the caller may act on. A
// foreign-tenant VM answers exactly like a missing one — existence is not
// leaked across tenants. On refusal it writes the response and returns
// ok=false.
func (a *API) exposureVM(w http.ResponseWriter, r *http.Request) (store.VM, bool) {
vm, err := a.st.GetVM(r.PathValue("id"))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "not found", http.StatusNotFound)
return store.VM{}, false
}
http.Error(w, "internal error", http.StatusInternalServerError)
return store.VM{}, false
}
if !mayActAs(principalFromContext(r), vm.Tenant) {
http.Error(w, "not found", http.StatusNotFound)
return store.VM{}, false
}
return vm, true
}
// validateExposure checks a create request, returning (msg, status) on failure
// or ("", 0) when valid.
//
// guest_port is unrestricted within the port space: the guest owns the guest,
// including which of its ports are worth publishing. host_port is where
// privilege lives — the 1024 floor is uniformity as much as safety, because a
// macOS agent is unprivileged and cannot bind lower, and low ports are the
// future gateway's territory.
//
// protocol is closed at two values because the agent has exactly two proxies
// to offer, and a third string accepted here would become a grant no host ever
// binds — a row that reads published and serves nothing.
func validateExposure(req types.CreateExposureRequest) (string, int) {
if req.GuestPort < 1 || req.GuestPort > 65535 {
return "guest_port must be between 1 and 65535", http.StatusBadRequest
}
if req.HostPort != 0 && (req.HostPort < 1024 || req.HostPort > 65535) {
return "host_port must be between 1024 and 65535, or omitted to allocate one from " +
strconv.Itoa(store.MinAllocatedHostPort) + "-" + strconv.Itoa(store.MaxAllocatedHostPort),
http.StatusBadRequest
}
if req.Protocol != "" && req.Protocol != "tcp" && req.Protocol != "udp" {
return `protocol must be "tcp" or "udp"`, http.StatusBadRequest
}
return "", 0
}
// exposureProtocol is the protocol a create request asks for. An omitted one is
// tcp: the field arrived after the endpoint did, and every caller that predates
// it means the same thing by silence.
func exposureProtocol(req types.CreateExposureRequest) string {
if req.Protocol == "" {
return "tcp"
}
return req.Protocol
}
// exposureState folds a host's live report into one exposure's state. An
// exposure the host has not reported on is "pending": the grant exists, and
// nothing has said what the host made of it yet.
func exposureState(statuses []registry.ExposureStatus, id string) (state, reason string, sessions *types.ExposureSessions) {
for _, s := range statuses {
if s.ID == id {
return s.State, s.Reason, toExposureSessions(s.Sessions)
}
}
return "pending", "", nil
}
// toExposureSessions maps a host's reported counters to the wire shape, and
// keeps nil meaning nil the whole way: an exposure nobody has reported on, and
// one served by an agent that predates the counters, both say nothing rather
// than saying zero.
func toExposureSessions(s *registry.ExposureSessions) *types.ExposureSessions {
if s == nil {
return nil
}
return &types.ExposureSessions{Active: s.Active, Refused: s.Refused, Dropped: s.Dropped}
}
func toExposureResponse(e store.Exposure, hostAddr string, statuses []registry.ExposureStatus) types.Exposure {
state, reason, sessions := exposureState(statuses, e.ID)
return types.Exposure{
ID: e.ID, VMID: e.VMID, HostID: e.HostID,
GuestPort: e.GuestPort, HostPort: e.HostPort, HostAddr: hostAddr,
Protocol: e.Protocol, Scope: e.Scope,
State: state, Reason: reason, Sessions: sessions, CreatedAt: e.CreatedAt,
}
}
// hostFacing returns the address to dial for exposures on hostID, and that
// host's live exposure reports. Both are best-effort: a host that has not
// reported yields an empty address and no statuses, which renders as a pending
// exposure with nowhere named yet.
func (a *API) hostFacing(hostID string) (string, []registry.ExposureStatus) {
var addr string
if h, err := a.st.GetHost(hostID); err == nil {
addr = h.UplinkAddr
}
if st, ok := a.reg.Get(hostID); ok {
return addr, st.Report.Exposures
}
return addr, nil
}
func (a *API) handleCreateExposure(w http.ResponseWriter, r *http.Request) {
vm, ok := a.exposureVM(w, r)
if !ok {
return
}
var req types.CreateExposureRequest
if !decodeJSON(w, r, &req) {
return
}
if msg, code := validateExposure(req); msg != "" {
http.Error(w, msg, code)
return
}
protocol := exposureProtocol(req)
// A UDP grant only goes to an agent that speaks it. An older agent's converge
// loop listens TCP unconditionally and reports the exposure active anyway, so
// the grant would read published and carry no datagrams. Refuse it here, where
// the operator can still upgrade the host — the exposure twin of the create's
// certified-host-key refusal. Only a connected, reporting host is judged: an
// offline or silent one takes the grant exactly as it would for any other
// reason it cannot serve one this moment (see refuseBelowFloor).
if protocol == "udp" && a.refuseBelowFloor(w, vm.HostID, release.DatagramExposures, false) {
return
}
e, err := a.st.CreateExposure(vm.ID, req.GuestPort, req.HostPort, protocol)
if err != nil {
switch {
case errors.Is(err, store.ErrExposureVMNotFound):
http.Error(w, "not found", http.StatusNotFound)
case errors.Is(err, store.ErrHostPortTaken):
http.Error(w, "host port already in use on that host for "+protocol, http.StatusConflict)
case errors.Is(err, store.ErrNoFreeHostPort):
http.Error(w, "no free host port in the reserved range on that host", http.StatusConflict)
default:
http.Error(w, "internal error", http.StatusInternalServerError)
}
return
}
a.audit(vm.Tenant, "exposure.create", map[string]string{
"vm_id": vm.ID, "name": vm.Name, "exposure_id": e.ID,
"guest_port": strconv.FormatInt(e.GuestPort, 10),
"host_port": strconv.FormatInt(e.HostPort, 10),
"protocol": e.Protocol,
})
a.hub.Poke(vm.HostID)
a.notif.notify()
addr, statuses := a.hostFacing(vm.HostID)
writeJSON(w, http.StatusCreated, toExposureResponse(e, addr, statuses))
}
func (a *API) handleListExposures(w http.ResponseWriter, r *http.Request) {
vm, ok := a.exposureVM(w, r)
if !ok {
return
}
exps, err := a.st.ListExposuresForVM(vm.ID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
addr, statuses := a.hostFacing(vm.HostID)
out := make([]types.Exposure, 0, len(exps))
for _, e := range exps {
out = append(out, toExposureResponse(e, addr, statuses))
}
writeJSON(w, http.StatusOK, out)
}
// handleDeleteExposure revokes a grant. There is no update verb: wrong port,
// delete it and issue another.
func (a *API) handleDeleteExposure(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
e, err := a.st.GetExposure(id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !mayActAs(principalFromContext(r), e.Tenant) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err := a.st.DeleteExposure(id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.audit(e.Tenant, "exposure.delete", map[string]string{
"vm_id": e.VMID, "exposure_id": e.ID,
"guest_port": strconv.FormatInt(e.GuestPort, 10),
"host_port": strconv.FormatInt(e.HostPort, 10),
"protocol": e.Protocol,
})
a.hub.Poke(e.HostID)
a.notif.notify()
w.WriteHeader(http.StatusNoContent)
}