a73x

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)
}