RC-7
release-tag / release-image (push) Failing after 2m44s

This commit is contained in:
2026-08-11 16:58:07 +02:00
parent 185ccf1101
commit bcfbef390f
44 changed files with 4778 additions and 172 deletions
+246 -26
View File
@@ -2,6 +2,10 @@ package server
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -42,6 +46,7 @@ type Server struct {
artifactWorker *artifact.Worker
lottery *guessLottery
adminUser, adminPass, staticDir, artifactDir string
internalServiceSecret string
upgrader websocket.Upgrader
wsAllowedOrigins map[string]struct{}
maxUserWS, maxLeaderboardWS int64
@@ -51,20 +56,23 @@ type Server struct {
func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub, runtimeState *rtx.State, artifactDir string, artifactWorker *artifact.Worker) *Server {
s := &Server{
store: store,
auth: a,
settings: sm,
hub: hub,
runtime: runtimeState,
artifactWorker: artifactWorker,
lottery: newGuessLottery(),
adminUser: env("ADMIN_USER", "admin"),
adminPass: env("ADMIN_PASSWORD", "change-me"),
staticDir: env("STATIC_DIR", ""),
artifactDir: artifactDir,
wsAllowedOrigins: parseOriginAllowlist(os.Getenv("WS_ALLOWED_ORIGINS")),
maxUserWS: int64(envIntServer("WS_MAX_USER_CONNECTIONS", 5000)),
maxLeaderboardWS: int64(envIntServer("WS_MAX_LEADERBOARD_CONNECTIONS", 500)),
store: store,
auth: a,
settings: sm,
hub: hub,
runtime: runtimeState,
artifactWorker: artifactWorker,
lottery: newGuessLottery(func(d beaconDrawAudit) {
_ = store.RecordBeaconDraw(context.Background(), d.TaskID, d.WindowEnd, d.BeaconID, d.BeaconRound, d.Randomness, d.Signature, d.BoostedPath, d.Tickets, d.Selected)
}),
adminUser: env("ADMIN_USER", "admin"),
adminPass: env("ADMIN_PASSWORD", "change-me"),
staticDir: env("STATIC_DIR", ""),
artifactDir: artifactDir,
internalServiceSecret: strings.TrimSpace(os.Getenv("CUSTOMER_SERVICE_SHARED_SECRET")),
wsAllowedOrigins: parseOriginAllowlist(os.Getenv("WS_ALLOWED_ORIGINS")),
maxUserWS: int64(envIntServer("WS_MAX_USER_CONNECTIONS", 5000)),
maxLeaderboardWS: int64(envIntServer("WS_MAX_LEADERBOARD_CONNECTIONS", 500)),
}
s.upgrader = websocket.Upgrader{CheckOrigin: s.checkWSOrigin, Subprotocols: []string{"neuralhunt.v1"}}
return s
@@ -242,7 +250,7 @@ func (s *Server) PublicRoutes() http.Handler {
next := s.Routes()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.ToLower(r.URL.Path)
if p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") {
if p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") || p == "/api/internal" || strings.HasPrefix(p, "/api/internal/") {
http.NotFound(w, r)
return
}
@@ -260,7 +268,7 @@ func (s *Server) AdminRoutes() http.Handler {
http.Redirect(w, r, "/admin", http.StatusTemporaryRedirect)
return
}
allowed := p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/healthz" || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") || p == "/app.js" || p == "/styles.css" || p == "/index.html"
allowed := p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/healthz" || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") || p == "/api/internal" || strings.HasPrefix(p, "/api/internal/") || p == "/app.js" || p == "/styles.css" || p == "/index.html"
if !allowed {
http.NotFound(w, r)
return
@@ -290,6 +298,8 @@ func (s *Server) Routes() http.Handler {
r.Get("/api/healthz", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]bool{"ok": true}) })
r.Get("/api/public/leaderboard", s.publicLeaderboard)
r.Get("/api/public/artifacts", s.publicArtifacts)
r.Get("/api/public/beacon/{id}/latest", s.latestBeaconDraw)
r.Get("/api/public/tasks", s.publicTaskCatalog)
r.Get("/api/public/artifacts/{id}/preview", s.publicArtifactPreview)
r.Get("/api/public/tasks/{id}/style-reference", s.publicTaskStyleReference)
r.Get("/api/leaderboard/ws", s.leaderboardWS)
@@ -297,6 +307,9 @@ func (s *Server) Routes() http.Handler {
r.Post("/api/auth/login", s.login)
r.Post("/api/admin/login", s.adminLogin)
r.Post("/api/admin/logout", s.adminLogout)
r.Post("/api/internal/delegations", s.internalDelegation)
r.Post("/api/internal/identity-exists", s.internalIdentityExists)
r.Post("/api/internal/customer-link/consume", s.internalCustomerLinkConsume)
r.Group(func(r chi.Router) {
r.Use(func(n http.Handler) http.Handler { return s.require("user", n) })
r.Get("/api/tasks", s.clientTasks)
@@ -305,6 +318,9 @@ func (s *Server) Routes() http.Handler {
r.Post("/api/tasks/{id}/guess", s.guess)
r.Get("/api/tasks/{id}/points", s.points)
r.Get("/api/me", s.me)
r.Get("/api/me/artifacts", s.myArtifacts)
r.Get("/api/me/artifacts/{id}/download", s.myArtifactDownload)
r.Post("/api/me/customer-link", s.customerLinkCode)
r.Get("/api/leaderboard", s.leaderboard)
})
r.Group(func(r chi.Router) {
@@ -532,6 +548,9 @@ func taskDTO(t data.Task, next int64, sm settings.Runtime) map[string]any {
"client_submit_interval_sec": clientSubmit,
"guess_lottery_window_sec": sm.GuessLotteryWindowSec,
"guess_lottery_max_accepted": sm.GuessLotteryMaxAccepted,
"beacon_hunt_enabled": sm.BeaconHuntEnabled,
"beacon_bonus_weight": sm.BeaconBonusWeight,
"beacon_paths": beaconPaths,
"default_max_nodes": sm.DefaultMaxNodes,
"paused": t.Paused,
"revision": t.Revision,
@@ -620,7 +639,10 @@ func (s *Server) currentTask(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, taskDTO(t, g.NextSeq, s.settings.Get()))
}
func guessMsg(taskID string, seq int64, guess string) string {
func guessMsg(taskID string, seq int64, guess, beaconPath string, beaconEnabled bool) string {
if beaconEnabled {
return fmt.Sprintf("guess|%s|%d|%s|%s", taskID, seq, guess, normalizeBeaconPath(beaconPath))
}
return fmt.Sprintf("guess|%s|%d|%s", taskID, seq, guess)
}
@@ -645,9 +667,10 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
return
}
var in struct {
Seq int64 `json:"seq"`
Guess string `json:"guess"`
Signature string `json:"signature"`
Seq int64 `json:"seq"`
Guess string `json:"guess"`
Signature string `json:"signature"`
BeaconPath string `json:"beacon_path,omitempty"`
}
if decode(r, &in) != nil {
jsonOut(w, 400, false)
@@ -687,7 +710,12 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
return
}
pub, _ := auth.PublicKey(jwk)
if pub == nil || !auth.VerifyRaw(pub, guessMsg(id, in.Seq, in.Guess), in.Signature) {
beaconEnabled := s.settings.Get().BeaconHuntEnabled == 1 && s.settings.Get().GuessLotteryMaxAccepted > 0
if beaconEnabled && normalizeBeaconPath(in.BeaconPath) == "" {
jsonAPIError(w, http.StatusBadRequest, "beacon_path_required", "choose PULSE, FLUX or ORBIT before entering the draw", map[string]any{"paths": beaconPaths})
return
}
if pub == nil || !auth.VerifyRaw(pub, guessMsg(id, in.Seq, in.Guess, in.BeaconPath, beaconEnabled), in.Signature) {
jsonOut(w, 401, false)
return
}
@@ -714,14 +742,18 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
return
}
var draw lotteryResult
if cfg.GuessLotteryMaxAccepted > 0 {
selected, drawErr := s.lottery.enter(r.Context(), t.ID, c.ClientID, in.Seq, time.Duration(cfg.GuessLotteryWindowSec)*time.Second, cfg.GuessLotteryMaxAccepted)
drawResult, drawErr := s.lottery.enter(r.Context(), t.ID, c.ClientID, in.Seq, in.BeaconPath, time.Duration(cfg.GuessLotteryWindowSec)*time.Second, cfg.GuessLotteryMaxAccepted, cfg.BeaconHuntEnabled == 1, cfg.BeaconBonusWeight)
draw = drawResult
if drawErr != nil {
switch {
case errors.Is(drawErr, errLotteryDuplicate):
jsonAPIError(w, http.StatusConflict, "lottery_duplicate", "guess is already waiting for the current draw", nil)
case errors.Is(drawErr, errLotteryFull):
jsonAPIError(w, http.StatusTooManyRequests, "lottery_full", "guess lottery window is full", nil)
case errors.Is(drawErr, errBeaconUnavailable):
jsonAPIError(w, http.StatusServiceUnavailable, "beacon_unavailable", "external randomness beacon is temporarily unavailable; ticket was not evaluated", nil)
case errors.Is(drawErr, context.Canceled), errors.Is(drawErr, context.DeadlineExceeded):
return
default:
@@ -746,7 +778,7 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
return
}
t = fresh
if !selected {
if !draw.Selected {
next, skipErr := s.runtime.SkipLottery(t.Task, c.ClientID, in.Seq, minInterval)
if skipErr != nil {
if errors.Is(skipErr, rtx.ErrBadSequence) {
@@ -758,7 +790,15 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
}
return
}
jsonAPIError(w, http.StatusTooManyRequests, "lottery_not_selected", "guess was not selected in this lottery window", map[string]any{"next_seq": next})
extra := map[string]any{"next_seq": next}
if draw.BeaconEnabled {
extra["chosen_path"] = draw.ChosenPath
extra["boosted_path"] = draw.BoostedPath
extra["beacon_round"] = draw.BeaconRound
extra["beacon_id"] = draw.BeaconID
extra["weight"] = draw.Weight
}
jsonAPIError(w, http.StatusTooManyRequests, "lottery_not_selected", "guess was not selected in this lottery window", extra)
return
}
}
@@ -788,7 +828,15 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
}
// Losing tips are intentionally ephemeral: no SQLite write and no websocket event.
if accepted.Improved || correct {
p, err := s.store.PersistImprovement(r.Context(), t, c.ClientID, accepted.State.NextSeq, accepted.State.GuessCount, accepted.State.LastGuess, accepted.State.BestScore, in.Guess, in.Signature, correct)
rewardOwner := c.ClientID
if correct {
rewardOwner = s.store.RewardOwnerForWorker(r.Context(), c.ClientID)
}
beaconPath, beaconBoost, beaconRound := "", "", uint64(0)
if correct && draw.BeaconEnabled {
beaconPath, beaconBoost, beaconRound = draw.ChosenPath, draw.BoostedPath, draw.BeaconRound
}
p, err := s.store.PersistImprovement(r.Context(), t, c.ClientID, rewardOwner, accepted.State.NextSeq, accepted.State.GuessCount, accepted.State.LastGuess, accepted.State.BestScore, in.Guess, in.Signature, correct, beaconPath, beaconBoost, beaconRound)
if err != nil {
s.runtime.Restore(t.Task, c.ClientID, accepted.State.NextSeq, accepted.Previous)
switch {
@@ -806,7 +854,8 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
s.hub.PublishPoint(id, c.ClientID, p)
}
if correct {
dataOut := map[string]string{"winner_client_id": c.ClientID}
rewardOwner := s.store.RewardOwnerForWorker(r.Context(), c.ClientID)
dataOut := map[string]string{"winner_client_id": rewardOwner, "winner_worker_client_id": c.ClientID}
if successor, succErr := s.store.EnsureSuccessorTask(r.Context(), id, s.settings.Get().TaskRangeBits); succErr == nil {
dataOut["successor_task_id"] = successor.ID
s.runtime.ReplaceTaskSelection(id, successor.ID)
@@ -816,9 +865,139 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_completed", TaskID: id, Data: dataOut})
_ = s.store.EnsureActiveTasks(r.Context(), s.settings.Get().ActiveTaskCount, s.settings.Get().TaskRangeBits)
}
if draw.BeaconEnabled {
w.Header().Set("X-NeuralHunt-Beacon-Path", draw.BoostedPath)
w.Header().Set("X-NeuralHunt-Beacon-Round", strconv.FormatUint(draw.BeaconRound, 10))
}
jsonOut(w, 200, correct)
}
func serviceTokenOK(secret, header string) bool {
provided := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
return secret != "" && len(secret) == len(provided) && subtle.ConstantTimeCompare([]byte(secret), []byte(provided)) == 1
}
func customerLinkHash(code string) string {
h := sha256.Sum256([]byte("nh-customer-link-v1|" + strings.TrimSpace(code)))
return fmt.Sprintf("%x", h[:])
}
// customerLinkCode issues a short-lived one-shot proof that the authenticated
// browser/CLI controls this exact P-256 identity. Customer Service redeems the
// code over the private 8081 control plane; the private key never leaves the
// owner device.
func (s *Server) customerLinkCode(w http.ResponseWriter, r *http.Request) {
c := claims(r)
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
jsonOut(w, 500, map[string]string{"error": "could not create pairing code"})
return
}
code := "nhlink_" + base64.RawURLEncoding.EncodeToString(b)
expires := time.Now().UTC().Add(10 * time.Minute)
if err := s.store.CreateCustomerLinkToken(r.Context(), customerLinkHash(code), c.ClientID, expires); err != nil {
jsonOut(w, 500, map[string]string{"error": "could not store pairing code"})
return
}
w.Header().Set("Cache-Control", "no-store")
jsonOut(w, 201, map[string]any{"code": code, "client_id": c.ClientID, "expires_at": expires})
}
func (s *Server) internalCustomerLinkConsume(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(s.internalServiceSecret)
if !serviceTokenOK(secret, r.Header.Get("Authorization")) {
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
var in struct {
Code string `json:"code"`
}
if err := decode(r, &in); err != nil || !strings.HasPrefix(strings.TrimSpace(in.Code), "nhlink_") {
jsonOut(w, 400, map[string]string{"error": "valid pairing code required"})
return
}
cid, err := s.store.ConsumeCustomerLinkToken(r.Context(), customerLinkHash(in.Code))
if err != nil {
jsonOut(w, 404, map[string]string{"error": "pairing code expired, invalid, or already used"})
return
}
jsonOut(w, 200, map[string]string{"client_id": cid})
}
func (s *Server) internalIdentityExists(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(s.internalServiceSecret)
if !serviceTokenOK(secret, r.Header.Get("Authorization")) {
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
var in struct {
ClientID string `json:"client_id"`
}
if err := decode(r, &in); err != nil || strings.TrimSpace(in.ClientID) == "" {
jsonOut(w, 400, map[string]string{"error": "client_id required"})
return
}
jsonOut(w, 200, map[string]bool{"exists": s.store.ClientExists(r.Context(), strings.TrimSpace(in.ClientID))})
}
func (s *Server) internalDelegation(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(s.internalServiceSecret)
if !serviceTokenOK(secret, r.Header.Get("Authorization")) {
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
var in struct {
WorkerClientID string `json:"worker_client_id"`
OwnerClientID string `json:"owner_client_id"`
}
if err := decode(r, &in); err != nil {
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
return
}
if err := s.store.SetIdentityDelegation(r.Context(), in.WorkerClientID, in.OwnerClientID); err != nil {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, map[string]any{"ok": true, "worker_client_id": in.WorkerClientID, "owner_client_id": in.OwnerClientID})
}
func (s *Server) publicTaskCatalog(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ActiveTasksForClient(r.Context(), "")
if err != nil {
jsonOut(w, 500, map[string]string{"error": "tasks failed"})
return
}
cfg := s.settings.Get()
out := make([]map[string]any, 0, len(items))
for _, t := range items {
out = append(out, map[string]any{
"id": t.ID, "display_name": t.DisplayName, "description": t.Description,
"range_bits": t.RangeBits, "paused": t.Paused,
"guess_lottery_window_sec": cfg.GuessLotteryWindowSec,
"guess_lottery_max_accepted": cfg.GuessLotteryMaxAccepted,
"beacon_hunt_enabled": cfg.BeaconHuntEnabled,
"beacon_bonus_weight": cfg.BeaconBonusWeight,
"beacon_paths": beaconPaths,
"style_reference_uri": "/api/public/tasks/" + url.PathEscape(t.ID) + "/style-reference",
})
}
jsonOut(w, 200, out)
}
func (s *Server) latestBeaconDraw(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "id")
d, err := s.store.LatestBeaconDraw(r.Context(), taskID)
if err != nil {
if data.IsNoRows(err) {
jsonOut(w, 404, map[string]string{"error": "no beacon draw yet"})
return
}
jsonOut(w, 500, map[string]string{"error": "beacon draw lookup failed"})
return
}
jsonOut(w, 200, d)
}
func (s *Server) points(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
@@ -857,6 +1036,47 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, m)
}
func (s *Server) myArtifacts(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := s.store.OwnedArtifacts(r.Context(), claims(r).ClientID, limit)
if err != nil {
jsonOut(w, 500, map[string]string{"error": "owned artifacts failed"})
return
}
jsonOut(w, 200, items)
}
func (s *Server) myArtifactDownload(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
jsonOut(w, 400, map[string]string{"error": "task id required"})
return
}
uri, ok, err := s.store.OwnedArtifactSource(r.Context(), id, claims(r).ClientID)
if err != nil {
jsonOut(w, 500, map[string]string{"error": "artifact lookup failed"})
return
}
if !ok {
// Deliberately use 404 rather than revealing that another identity owns it.
jsonOut(w, 404, map[string]string{"error": "artifact not found"})
return
}
path, err := artifactLocalPath(s.artifactDir, uri)
if err != nil {
jsonOut(w, 404, map[string]string{"error": "artifact file unavailable"})
return
}
ext := filepath.Ext(path)
if ext == "" {
ext = ".bin"
}
name := "neuralhunt-" + id + ext
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, name))
http.ServeFile(w, r, path)
}
func (s *Server) leaderboard(w http.ResponseWriter, r *http.Request) {
l, err := s.store.Leaderboard(r.Context(), 100)
if err != nil {