+79
-18
@@ -18,6 +18,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -64,12 +65,15 @@ func (e *apiError) Code() string {
|
||||
}
|
||||
|
||||
type apiClient struct {
|
||||
base string
|
||||
hc *http.Client
|
||||
token string
|
||||
cid string
|
||||
id identityFile
|
||||
key *ecdsa.PrivateKey
|
||||
base string
|
||||
hc *http.Client
|
||||
|
||||
authMu sync.Mutex
|
||||
mu sync.RWMutex
|
||||
token string
|
||||
cid string
|
||||
id identityFile
|
||||
key *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
func newAPI(base string, id identityFile, key *ecdsa.PrivateKey) *apiClient {
|
||||
@@ -81,7 +85,19 @@ func newAPI(base string, id identityFile, key *ecdsa.PrivateKey) *apiClient {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *apiClient) do(ctx context.Context, method, path string, body, out any) error {
|
||||
func (c *apiClient) tokenValue() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.token
|
||||
}
|
||||
|
||||
func (c *apiClient) clientID() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.cid
|
||||
}
|
||||
|
||||
func (c *apiClient) doRaw(ctx context.Context, method, path string, body, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
@@ -97,8 +113,8 @@ func (c *apiClient) do(ctx context.Context, method, path string, body, out any)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
if token := c.tokenValue(); token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
@@ -117,6 +133,21 @@ func (c *apiClient) do(ctx context.Context, method, path string, body, out any)
|
||||
return nil
|
||||
}
|
||||
|
||||
// do retries authenticated API calls once after a fresh cryptographic login.
|
||||
// This matters for unattended hosted workers: a long network outage or an
|
||||
// expired JWT must not leave a healthy process permanently stuck on 401.
|
||||
func (c *apiClient) do(ctx context.Context, method, path string, body, out any) error {
|
||||
err := c.doRaw(ctx, method, path, body, out)
|
||||
var ae *apiError
|
||||
if strings.HasPrefix(path, "/api/auth/") || !errors.As(err, &ae) || ae.Status != http.StatusUnauthorized {
|
||||
return err
|
||||
}
|
||||
if loginErr := c.login(ctx); loginErr != nil {
|
||||
return fmt.Errorf("session refresh after HTTP 401: %w", loginErr)
|
||||
}
|
||||
return c.doRaw(ctx, method, path, body, out)
|
||||
}
|
||||
|
||||
func leadingZeroBitsClient(b []byte) int {
|
||||
n := 0
|
||||
for _, x := range b {
|
||||
@@ -145,12 +176,15 @@ func solveProofClient(challenge, cid string, bits int) string {
|
||||
}
|
||||
}
|
||||
func (c *apiClient) login(ctx context.Context) error {
|
||||
c.authMu.Lock()
|
||||
defer c.authMu.Unlock()
|
||||
|
||||
var ch struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Challenge string `json:"challenge"`
|
||||
ProofOfWorkBits int `json:"proof_of_work_bits"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPost, "/api/auth/challenge", map[string]any{"public_jwk": c.id.PublicJWK}, &ch); err != nil {
|
||||
if err := c.doRaw(ctx, http.MethodPost, "/api/auth/challenge", map[string]any{"public_jwk": c.id.PublicJWK}, &ch); err != nil {
|
||||
return fmt.Errorf("challenge: %w", err)
|
||||
}
|
||||
sig, err := signRaw(c.key, "login|"+ch.Challenge+"|"+ch.ClientID)
|
||||
@@ -162,7 +196,7 @@ func (c *apiClient) login(ctx context.Context) error {
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
pow := solveProofClient(ch.Challenge, ch.ClientID, ch.ProofOfWorkBits)
|
||||
if err := c.do(ctx, http.MethodPost, "/api/auth/login", map[string]any{
|
||||
if err := c.doRaw(ctx, http.MethodPost, "/api/auth/login", map[string]any{
|
||||
"public_jwk": c.id.PublicJWK,
|
||||
"challenge": ch.Challenge,
|
||||
"signature": sig,
|
||||
@@ -170,7 +204,12 @@ func (c *apiClient) login(ctx context.Context) error {
|
||||
}, &lg); err != nil {
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
if old := c.clientID(); old != "" && old != lg.ClientID {
|
||||
return fmt.Errorf("server returned a different client identity after re-login: %s != %s", lg.ClientID, old)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.token, c.cid = lg.Token, lg.ClientID
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -318,7 +357,7 @@ func (c *apiClient) ownedArtifacts(ctx context.Context, limit int) ([]ownedArtif
|
||||
}
|
||||
|
||||
func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64, beaconPath string) (bool, error) {
|
||||
guess := expectedGuess(t.ID, t.PublicSeed, c.cid, seq, t.RangeBits)
|
||||
guess := expectedGuess(t.ID, t.PublicSeed, c.clientID(), seq, t.RangeBits)
|
||||
msg := fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess)
|
||||
if t.BeaconHuntEnabled == 1 && t.GuessLotteryMaxAccepted > 0 {
|
||||
beaconPath = strings.ToUpper(strings.TrimSpace(beaconPath))
|
||||
@@ -333,7 +372,7 @@ func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64, beaconPath
|
||||
return correct, err
|
||||
}
|
||||
|
||||
func (c *apiClient) dialWS(ctx context.Context, maxNodes int) (*websocket.Conn, error) {
|
||||
func (c *apiClient) dialWSOnce(ctx context.Context, maxNodes int) (*websocket.Conn, error) {
|
||||
u, err := url.Parse(c.base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -346,16 +385,30 @@ func (c *apiClient) dialWS(ctx context.Context, maxNodes int) (*websocket.Conn,
|
||||
q.Set("max_nodes", strconv.Itoa(maxNodes))
|
||||
wu := scheme + "://" + u.Host + "/api/ws?" + q.Encode()
|
||||
h := http.Header{}
|
||||
h.Set("Authorization", "Bearer "+c.token)
|
||||
if token := c.tokenValue(); token != "" {
|
||||
h.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
conn, resp, err := websocket.DefaultDialer.DialContext(ctx, wu, h)
|
||||
if err != nil && resp != nil {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
_ = resp.Body.Close()
|
||||
return nil, fmt.Errorf("websocket %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
return nil, &apiError{Status: resp.StatusCode, Body: string(b)}
|
||||
}
|
||||
return conn, err
|
||||
}
|
||||
|
||||
func (c *apiClient) dialWS(ctx context.Context, maxNodes int) (*websocket.Conn, error) {
|
||||
conn, err := c.dialWSOnce(ctx, maxNodes)
|
||||
var ae *apiError
|
||||
if !errors.As(err, &ae) || ae.Status != http.StatusUnauthorized {
|
||||
return conn, err
|
||||
}
|
||||
if loginErr := c.login(ctx); loginErr != nil {
|
||||
return nil, fmt.Errorf("websocket session refresh: %w", loginErr)
|
||||
}
|
||||
return c.dialWSOnce(ctx, maxNodes)
|
||||
}
|
||||
|
||||
func (c *apiClient) downloadPreview(ctx context.Context, taskID, dest string) error {
|
||||
path := "/api/public/artifacts/" + url.PathEscape(taskID) + "/preview"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
|
||||
@@ -391,8 +444,8 @@ func (c *apiClient) downloadOwnedArtifact(ctx context.Context, taskID, dest stri
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
if token := c.tokenValue(); token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
@@ -439,7 +492,7 @@ func (c *apiClient) registerHostedWorker(ctx context.Context) error {
|
||||
if registerURL == "" || workerID == "" || token == "" {
|
||||
return errors.New("hosted worker registration requires NEURALHUNT_WORKER_REGISTER_URL, NEURALHUNT_WORKER_ID and NEURALHUNT_WORKER_REGISTER_TOKEN")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"worker_id": workerID, "client_id": c.cid})
|
||||
body, _ := json.Marshal(map[string]string{"worker_id": workerID, "client_id": c.clientID()})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, registerURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -492,6 +545,14 @@ func (c *apiClient) hostedWorkerLeaseLoop(ctx context.Context, cancel context.Ca
|
||||
failures = 0
|
||||
continue
|
||||
}
|
||||
// Explicit authentication/revocation responses are authoritative,
|
||||
// not transient connectivity failures. Stop immediately; Docker's
|
||||
// restart policy cannot bypass registration while the DB lease is
|
||||
// revoked.
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusConflict {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
failures++
|
||||
|
||||
+2
-2
@@ -61,13 +61,13 @@ func main() {
|
||||
fmt.Println("Neue Terminal-Identität erzeugt:", *identityPath)
|
||||
}
|
||||
fmt.Println("NEURAL HUNT SHELL")
|
||||
fmt.Println("Client-ID :", api.cid)
|
||||
fmt.Println("Client-ID :", api.clientID())
|
||||
fmt.Println("Identity-Datei:", *identityPath)
|
||||
fmt.Println("Server :", strings.TrimRight(*base, "/"))
|
||||
fmt.Println("Backup : identity export <datei> (verschlüsselt, im Browser importierbar)")
|
||||
fmt.Println("Hinweis : Dieselbe Identity darf nicht gleichzeitig im Browser verbunden sein.")
|
||||
|
||||
a := newApp(api, *identityPath, *passphrase, *maxNodes, *quiet, *nonInteractive)
|
||||
a := newApp(ctx, api, *identityPath, *passphrase, *maxNodes, *quiet, *nonInteractive)
|
||||
a.beaconPathMode = strings.ToLower(strings.TrimSpace(*beaconPath))
|
||||
initial, err := selectInitialTask(ctx, a, *taskSelector, !*nonInteractive)
|
||||
if err != nil {
|
||||
|
||||
+75
-16
@@ -25,6 +25,7 @@ type wsEvent struct {
|
||||
}
|
||||
|
||||
type app struct {
|
||||
rootCtx context.Context
|
||||
api *apiClient
|
||||
identityPath string
|
||||
passphrase string
|
||||
@@ -47,14 +48,14 @@ type app struct {
|
||||
beaconPathMode string
|
||||
}
|
||||
|
||||
func newApp(api *apiClient, identityPath, passphrase string, maxNodes int, quiet, unattended bool) *app {
|
||||
func newApp(rootCtx context.Context, api *apiClient, identityPath, passphrase string, maxNodes int, quiet, unattended bool) *app {
|
||||
if maxNodes < 50 {
|
||||
maxNodes = 50
|
||||
}
|
||||
if maxNodes > 10000 {
|
||||
maxNodes = 10000
|
||||
}
|
||||
return &app{api: api, identityPath: identityPath, passphrase: passphrase, maxNodes: maxNodes, quiet: quiet, unattended: unattended, points: make(map[string]point)}
|
||||
return &app{rootCtx: rootCtx, api: api, identityPath: identityPath, passphrase: passphrase, maxNodes: maxNodes, quiet: quiet, unattended: unattended, points: make(map[string]point)}
|
||||
}
|
||||
|
||||
func shortID(s string) string {
|
||||
@@ -301,6 +302,18 @@ func (a *app) forceWSReconnect() {
|
||||
}
|
||||
|
||||
func (a *app) readWSOnce(ctx context.Context, conn *websocket.Conn, taskID string) (bool, error) {
|
||||
// The server emits a WebSocket ping every 30 seconds. A client-side read
|
||||
// deadline is equally important: without one, a half-open TCP connection
|
||||
// (Wi-Fi/VPN/NAT outage) can block ReadMessage forever and wsConnected would
|
||||
// incorrectly remain true. Receiving either data or a server ping refreshes
|
||||
// the deadline; 90 seconds of silence forces the normal reconnect loop.
|
||||
const idleTimeout = 90 * time.Second
|
||||
_ = conn.SetReadDeadline(time.Now().Add(idleTimeout))
|
||||
defaultPing := conn.PingHandler()
|
||||
conn.SetPingHandler(func(appData string) error {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(idleTimeout))
|
||||
return defaultPing(appData)
|
||||
})
|
||||
for {
|
||||
_, b, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
@@ -309,6 +322,7 @@ func (a *app) readWSOnce(ctx context.Context, conn *websocket.Conn, taskID strin
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(idleTimeout))
|
||||
var ev wsEvent
|
||||
if json.Unmarshal(b, &ev) != nil {
|
||||
continue
|
||||
@@ -340,18 +354,40 @@ func (a *app) readWSOnce(ctx context.Context, conn *websocket.Conn, taskID strin
|
||||
if !a.quiet {
|
||||
fmt.Println("\n✓ Task abgeschlossen. Wechsle auf den Folge-Task …")
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(450 * time.Millisecond)
|
||||
if err := a.startTask(context.Background(), ""); err != nil && !a.quiet {
|
||||
fmt.Printf("[task] Folge-Task noch nicht bereit: %v\n", err)
|
||||
}
|
||||
}()
|
||||
go a.retryCurrentTaskAfterCompletion(taskID)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// retryCurrentTaskAfterCompletion closes a gap that previously left unattended
|
||||
// workers idle forever when the successor task was not yet queryable on the
|
||||
// first 450ms attempt. It keeps retrying with bounded backoff until either a
|
||||
// new active task is available or the whole client is shutting down.
|
||||
func (a *app) retryCurrentTaskAfterCompletion(completedTaskID string) {
|
||||
backoff := 500 * time.Millisecond
|
||||
for {
|
||||
select {
|
||||
case <-a.rootCtx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
if err := a.startTask(a.rootCtx, ""); err == nil {
|
||||
return
|
||||
} else if !errors.Is(err, errSwitching) && !a.quiet {
|
||||
fmt.Printf("[task] Folge-Task noch nicht bereit: %v; neuer Versuch folgt\n", err)
|
||||
}
|
||||
backoff = time.Duration(minInt64(int64(15*time.Second), int64(float64(backoff)*1.7)))
|
||||
a.mu.RLock()
|
||||
currentID := a.task.ID
|
||||
a.mu.RUnlock()
|
||||
if currentID != "" && currentID != completedTaskID {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) replacePoints(ps []point) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
@@ -368,7 +404,7 @@ func (a *app) upsertPoint(p point) {
|
||||
a.points = make(map[string]point)
|
||||
}
|
||||
a.points[p.ClientID] = p
|
||||
if p.ClientID == a.api.cid {
|
||||
if p.ClientID == a.api.clientID() {
|
||||
a.me.Score, a.me.Rank = p.Score, p.Rank
|
||||
}
|
||||
if len(a.points) > a.maxNodes*3 {
|
||||
@@ -390,7 +426,7 @@ func (a *app) trimPointsLocked() {
|
||||
for _, p := range ps[:keep] {
|
||||
next[p.ClientID] = p
|
||||
}
|
||||
if own, ok := a.points[a.api.cid]; ok {
|
||||
if own, ok := a.points[a.api.clientID()]; ok {
|
||||
next[own.ClientID] = own
|
||||
}
|
||||
a.points = next
|
||||
@@ -475,7 +511,7 @@ func (a *app) guessLoop(ctx context.Context, taskID string) {
|
||||
}
|
||||
path := ""
|
||||
if t.BeaconHuntEnabled == 1 && t.GuessLotteryMaxAccepted > 0 {
|
||||
path = chooseBeaconPath(a.beaconPathMode, a.api.cid, seq)
|
||||
path = chooseBeaconPath(a.beaconPathMode, a.api.clientID(), seq)
|
||||
if !a.quiet {
|
||||
fmt.Printf("[beacon] Pfad %s · Bonusgewicht bei Treffer ×%d\n", path, t.BeaconBonusWeight)
|
||||
}
|
||||
@@ -524,7 +560,7 @@ func (a *app) printStatus(ctx context.Context) {
|
||||
a.mu.RUnlock()
|
||||
fmt.Println("\nSTATUS")
|
||||
fmt.Println("────────────────────────────────────────────────────────")
|
||||
fmt.Printf("Identity : %s\n", a.api.cid)
|
||||
fmt.Printf("Identity : %s\n", a.api.clientID())
|
||||
fmt.Printf("Task : %s (%s)\n", dtoName(t), t.ID)
|
||||
fmt.Printf("Raum : %d bit Revision %d Paused %v\n", t.RangeBits, t.Revision, t.Paused)
|
||||
fmt.Printf("Score : %.4f Rank #%d Wins %d\n", me.Score, me.Rank, me.Wins)
|
||||
@@ -547,7 +583,7 @@ func (a *app) printMap(limit int) {
|
||||
for _, p := range a.points {
|
||||
ps = append(ps, p)
|
||||
}
|
||||
cid := a.api.cid
|
||||
cid := a.api.clientID()
|
||||
t := a.task
|
||||
a.mu.RUnlock()
|
||||
sort.Slice(ps, func(i, j int) bool {
|
||||
@@ -601,7 +637,7 @@ func (a *app) printLeaderboard(ctx context.Context, limit int) error {
|
||||
conn = "●"
|
||||
}
|
||||
self := ""
|
||||
if l.ClientID == a.api.cid {
|
||||
if l.ClientID == a.api.clientID() {
|
||||
self = "*"
|
||||
}
|
||||
fmt.Printf("#%-4d %-15s %6d %10.4f %10.4f %8d %5d\n", i+1, conn+self+shortID(l.ClientID), l.Wins, l.LiveScore, l.BestScore, l.GuessCount, l.NFTCount)
|
||||
@@ -806,7 +842,7 @@ func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
fmt.Println("Verschlüsselter Browser-Export geschrieben:", parts[2])
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Client-ID:", a.api.cid)
|
||||
fmt.Println("Client-ID:", a.api.clientID())
|
||||
fmt.Println("Identity :", a.identityPath)
|
||||
}
|
||||
case "hosted-code", "hosted-link":
|
||||
@@ -843,7 +879,30 @@ func selectInitialTask(ctx context.Context, a *app, selector string, interactive
|
||||
}
|
||||
if selector != "" || !interactive {
|
||||
t, err := resolveTask(ts, selector)
|
||||
return t.ID, err
|
||||
if err == nil {
|
||||
return t.ID, nil
|
||||
}
|
||||
// A hosted worker stores the task ID that was active when it was created.
|
||||
// After that task completes, the game moves the identity to its successor,
|
||||
// but the Customer-Service record may still contain the historical ID. On
|
||||
// a later container restart, falling back to the server-selected/current
|
||||
// active task lets the same persistent identity resume instead of crash-
|
||||
// looping forever on "task not found".
|
||||
if !interactive && strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_ID")) != "" {
|
||||
for _, candidate := range ts {
|
||||
if candidate.Selected {
|
||||
if !a.quiet {
|
||||
fmt.Printf("[task] konfigurierte Task-ID %q ist nicht mehr aktiv; setze mit %s fort\n", selector, taskName(candidate))
|
||||
}
|
||||
return candidate.ID, nil
|
||||
}
|
||||
}
|
||||
if !a.quiet {
|
||||
fmt.Printf("[task] konfigurierte Task-ID %q ist nicht mehr aktiv; verwende %s\n", selector, taskName(ts[0]))
|
||||
}
|
||||
return ts[0].ID, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
fmt.Print("Task auswählen [Nummer/ID/Name, Enter = markierter Task]: ")
|
||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
|
||||
Reference in New Issue
Block a user