feat(api): add felis-api service with permissions, modpack lane, and fleet read
The dual-faced felis-api: internal (service) and external (public/app/admin) routes behind a Zero-Trust guard. Includes the access domain (whitelist, ban, and LuckPerms permission/group control over the owner-gated RCON path), the modpack submission endpoints, and the admin-tier SysAdmin fleet read. Structured access fields are charset-validated before assembly so no field can splice a second RCON command.
This commit is contained in:
36 files changed
+8645
No files matched your search
@@ -0,0 +1,176 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
)
|
||||
|
||||
// The internal-face wake (spec §9.1, §14) is what velocity drives for
|
||||
// domain-autostart: service-token auth, the joining player identified by their
|
||||
// verified online-mode UUID rather than a web Principal. These exercise the same
|
||||
// autostartPolicy gate as the external wake but keyed by UUID, plus the shared
|
||||
// cooldown and the phase reported back so velocity can decide whether to wait.
|
||||
|
||||
const wakeUUID = "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
func newInternalWakeAPI(policy string) (*API, *fakeCluster) {
|
||||
repo := newFakeRepo()
|
||||
cl := newFakeCluster()
|
||||
cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Stopped", AutostartPolicy: policy}
|
||||
return newTestAPI(repo, cl), cl
|
||||
}
|
||||
|
||||
func internalWake(api *API, body string) *httptest.ResponseRecorder {
|
||||
return do(api.InternalHandler(), "POST", "/api/v1/internal/servers/survival/wake", body, nil)
|
||||
}
|
||||
|
||||
func TestInternalWakeAutostartGate(t *testing.T) {
|
||||
body := `{"mc_uuid":"` + wakeUUID + `"}`
|
||||
|
||||
t.Run("public: any UUID wakes and gets phase back", func(t *testing.T) {
|
||||
api, cl := newInternalWakeAPI("public")
|
||||
w := internalWake(api, body)
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if cl.desired["survival"] != v1alpha1.DesiredRunning {
|
||||
t.Fatalf("desiredState = %q, want Running", cl.desired["survival"])
|
||||
}
|
||||
// velocity reads phase/ready to decide whether to hold the player.
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
if got["phase"] != "Stopped" {
|
||||
t.Fatalf("phase = %v, want Stopped", got["phase"])
|
||||
}
|
||||
if got["ready"] != false {
|
||||
t.Fatalf("ready = %v, want false", got["ready"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing mc_uuid is rejected", func(t *testing.T) {
|
||||
api, cl := newInternalWakeAPI("public")
|
||||
w := internalWake(api, `{}`)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("code = %d, want 400", w.Code)
|
||||
}
|
||||
if _, set := cl.desired["survival"]; set {
|
||||
t.Fatal("desiredState must not change when the UUID is missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ownerOnly: unlinked UUID forbidden", func(t *testing.T) {
|
||||
api, cl := newInternalWakeAPI("ownerOnly")
|
||||
api.Repo.(*fakeRepo).byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
|
||||
w := internalWake(api, body)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("code = %d, want 403", w.Code)
|
||||
}
|
||||
if _, set := cl.desired["survival"]; set {
|
||||
t.Fatal("desiredState must not change on a forbidden wake")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ownerOnly: owner's linked UUID wakes", func(t *testing.T) {
|
||||
api, cl := newInternalWakeAPI("ownerOnly")
|
||||
repo := api.Repo.(*fakeRepo)
|
||||
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
|
||||
repo.links[wakeUUID] = "owner1" // account_links: this UUID belongs to owner1
|
||||
if w := internalWake(api, body); w.Code != http.StatusAccepted {
|
||||
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if cl.desired["survival"] != v1alpha1.DesiredRunning {
|
||||
t.Fatalf("desiredState = %q, want Running", cl.desired["survival"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ownerOnly: a different linked user is still forbidden", func(t *testing.T) {
|
||||
api, _ := newInternalWakeAPI("ownerOnly")
|
||||
repo := api.Repo.(*fakeRepo)
|
||||
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
|
||||
repo.links[wakeUUID] = "someone-else"
|
||||
if w := internalWake(api, body); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("code = %d, want 403", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowlist: only a listed UUID wakes", func(t *testing.T) {
|
||||
api, _ := newInternalWakeAPI("allowlist")
|
||||
repo := api.Repo.(*fakeRepo)
|
||||
repo.allowUUID["survival"] = map[string]bool{wakeUUID: true}
|
||||
if w := internalWake(api, body); w.Code != http.StatusAccepted {
|
||||
t.Fatalf("listed UUID: code = %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
api2, _ := newInternalWakeAPI("allowlist") // a different, unlisted UUID
|
||||
other := `{"mc_uuid":"99999999-0000-0000-0000-000000000000"}`
|
||||
if w := internalWake(api2, other); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("unlisted UUID: code = %d, want 403", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowlist: owner bypasses the list even when not on it", func(t *testing.T) {
|
||||
api, cl := newInternalWakeAPI("allowlist")
|
||||
repo := api.Repo.(*fakeRepo)
|
||||
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
|
||||
repo.links[wakeUUID] = "owner1" // owner, but allowUUID is empty
|
||||
if w := internalWake(api, body); w.Code != http.StatusAccepted {
|
||||
t.Fatalf("owner: code = %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if cl.desired["survival"] != v1alpha1.DesiredRunning {
|
||||
t.Fatalf("desiredState = %q, want Running", cl.desired["survival"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInternalWakeCooldownIsShared(t *testing.T) {
|
||||
api, _ := newInternalWakeAPI("public")
|
||||
api.WakeCooldown = time.Minute
|
||||
body := `{"mc_uuid":"` + wakeUUID + `"}`
|
||||
|
||||
if w := internalWake(api, body); w.Code != http.StatusAccepted {
|
||||
t.Fatalf("first wake code = %d", w.Code)
|
||||
}
|
||||
// clock is frozen, so the second wake is inside the cooldown window; velocity
|
||||
// reads this 429 as "already waking, keep waiting", not a failure.
|
||||
if w := internalWake(api, body); w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("second wake code = %d, want 429", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInternalWakeRunningCap proves the §9.1 cap is shared by the velocity-driven
|
||||
// internal wake, not only the web wake: with one slot and another server already
|
||||
// desired-Running, a join-triggered wake of a stopped server is held with 503
|
||||
// at_capacity and leaves desiredState untouched. velocity reads this as "cluster
|
||||
// full, hold the player", distinct from the 429 cooldown's "already waking".
|
||||
func TestInternalWakeRunningCap(t *testing.T) {
|
||||
api, cl := newInternalWakeAPI("public")
|
||||
api.MaxRunningServers = 1
|
||||
cl.list = []ServerInfo{{Name: "other", DesiredState: string(v1alpha1.DesiredRunning)}}
|
||||
body := `{"mc_uuid":"` + wakeUUID + `"}`
|
||||
|
||||
w := internalWake(api, body)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("code = %d, want 503 at_capacity (body %s)", w.Code, w.Body.String())
|
||||
}
|
||||
if code := decodeErr(t, w); code != "at_capacity" {
|
||||
t.Fatalf("error code = %q, want at_capacity", code)
|
||||
}
|
||||
if _, set := cl.desired["survival"]; set {
|
||||
t.Fatal("desiredState must not change when the cluster is at capacity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalStatusServedOnInternalFace(t *testing.T) {
|
||||
api, _ := newInternalWakeAPI("public")
|
||||
w := do(api.InternalHandler(), "GET", "/api/v1/internal/servers/survival/status", "", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status code = %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user