Files
neural-hunt/cmd/client/api.go
jbergner 005fd6ca51
Some checks failed
release-tag / release-image (push) Failing after 1m18s
RC-1
2026-08-10 05:48:55 +02:00

327 lines
9.1 KiB
Go

package main
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
"neuralhunt/internal/core"
)
func pad32Int(x *big.Int) []byte {
b := x.Bytes()
out := make([]byte, 32)
if len(b) > 32 {
b = b[len(b)-32:]
}
copy(out[32-len(b):], b)
return out
}
func signRaw(key *ecdsa.PrivateKey, message string) (string, error) {
h := sha256.Sum256([]byte(message))
r, s, err := ecdsa.Sign(rand.Reader, key, h[:])
if err != nil {
return "", err
}
raw := append(pad32Int(r), pad32Int(s)...)
return base64.RawURLEncoding.EncodeToString(raw), nil
}
type apiError struct {
Status int
Body string
}
func (e *apiError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.Status, strings.TrimSpace(e.Body))
}
func (e *apiError) Code() string {
var v struct {
Code string `json:"code"`
}
if json.Unmarshal([]byte(e.Body), &v) == nil {
return v.Code
}
return ""
}
type apiClient struct {
base string
hc *http.Client
token string
cid string
id identityFile
key *ecdsa.PrivateKey
}
func newAPI(base string, id identityFile, key *ecdsa.PrivateKey) *apiClient {
return &apiClient{
base: strings.TrimRight(base, "/"),
hc: &http.Client{Timeout: 15 * time.Second},
id: id,
key: key,
}
}
func (c *apiClient) do(ctx context.Context, method, path string, body, out any) error {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rd)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode/100 != 2 {
return &apiError{Status: resp.StatusCode, Body: string(b)}
}
if out != nil && len(bytes.TrimSpace(b)) != 0 {
if err := json.Unmarshal(b, out); err != nil {
return fmt.Errorf("decode %s: %w", path, err)
}
}
return nil
}
func (c *apiClient) login(ctx context.Context) error {
var ch struct {
ClientID string `json:"client_id"`
Challenge string `json:"challenge"`
}
if err := c.do(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)
if err != nil {
return err
}
var lg struct {
Token string `json:"token"`
ClientID string `json:"client_id"`
}
if err := c.do(ctx, http.MethodPost, "/api/auth/login", map[string]any{
"public_jwk": c.id.PublicJWK,
"challenge": ch.Challenge,
"signature": sig,
}, &lg); err != nil {
return fmt.Errorf("login: %w", err)
}
c.token, c.cid = lg.Token, lg.ClientID
return nil
}
type taskCard struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
RangeBits int `json:"range_bits"`
Paused bool `json:"paused"`
Revision int64 `json:"revision"`
PointCount int `json:"point_count"`
OwnScore float64 `json:"own_score"`
OwnRank int64 `json:"own_rank"`
Selected bool `json:"selected"`
GuessMinIntervalSec *int `json:"guess_min_interval_sec,omitempty"`
ClientSubmitIntervalSec *int `json:"client_submit_interval_sec,omitempty"`
}
type taskDTO struct {
ID string `json:"id"`
PublicSeed string `json:"public_seed"`
RangeBits int `json:"range_bits"`
NextSeq int64 `json:"next_seq"`
ServerMinIntervalSec int `json:"server_min_interval_sec"`
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
DefaultMaxNodes int `json:"default_max_nodes"`
Paused bool `json:"paused"`
Revision int64 `json:"revision"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
ParentTaskID *string `json:"parent_task_id"`
}
type point struct {
ClientID string `json:"client_id"`
Score float64 `json:"score"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
GuessCount int64 `json:"guess_count"`
Rank int64 `json:"rank"`
}
type meDTO struct {
ClientID string `json:"client_id"`
Score float64 `json:"score"`
Rank int64 `json:"rank"`
Wins int `json:"wins"`
Unlocks []string `json:"unlocks"`
}
type leader struct {
ClientID string `json:"client_id"`
Wins int `json:"wins"`
BestScore float64 `json:"best_score"`
LiveScore float64 `json:"live_score"`
GuessCount int64 `json:"guess_count"`
Connected bool `json:"connected"`
Unlocks []string `json:"unlocks"`
NFTCount int `json:"nft_count"`
NFTTaskID *string `json:"nft_task_id,omitempty"`
NFTPreviewURI *string `json:"nft_preview_uri,omitempty"`
}
type publicArtifact struct {
TaskID string `json:"task_id"`
WinnerClientID string `json:"winner_client_id"`
RangeBits int `json:"range_bits"`
CompletedAt time.Time `json:"completed_at"`
PreviewURI string `json:"preview_uri"`
}
func (c *apiClient) tasks(ctx context.Context) ([]taskCard, error) {
var out []taskCard
err := c.do(ctx, http.MethodGet, "/api/tasks", nil, &out)
return out, err
}
func (c *apiClient) selectTask(ctx context.Context, taskID string) (taskDTO, error) {
var out taskDTO
err := c.do(ctx, http.MethodPost, "/api/tasks/select", map[string]string{"task_id": taskID}, &out)
return out, err
}
func (c *apiClient) currentTask(ctx context.Context) (taskDTO, error) {
var out taskDTO
err := c.do(ctx, http.MethodGet, "/api/tasks/current", nil, &out)
return out, err
}
func (c *apiClient) points(ctx context.Context, taskID string, limit int) ([]point, error) {
var out []point
err := c.do(ctx, http.MethodGet, "/api/tasks/"+url.PathEscape(taskID)+"/points?limit="+strconv.Itoa(limit), nil, &out)
return out, err
}
func (c *apiClient) me(ctx context.Context) (meDTO, error) {
var out meDTO
err := c.do(ctx, http.MethodGet, "/api/me", nil, &out)
return out, err
}
func (c *apiClient) leaderboard(ctx context.Context) ([]leader, error) {
var out []leader
err := c.do(ctx, http.MethodGet, "/api/leaderboard", nil, &out)
return out, err
}
func (c *apiClient) artifacts(ctx context.Context, limit int) ([]publicArtifact, error) {
var out []publicArtifact
err := c.do(ctx, http.MethodGet, "/api/public/artifacts?limit="+strconv.Itoa(limit), nil, &out)
return out, err
}
func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64) (bool, error) {
guess := expectedGuess(t.ID, t.PublicSeed, c.cid, seq, t.RangeBits)
sig, err := signRaw(c.key, fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess))
if err != nil {
return false, err
}
var correct bool
err = c.do(ctx, http.MethodPost, "/api/tasks/"+url.PathEscape(t.ID)+"/guess", map[string]any{"seq": seq, "guess": guess, "signature": sig}, &correct)
return correct, err
}
func (c *apiClient) dialWS(ctx context.Context, maxNodes int) (*websocket.Conn, error) {
u, err := url.Parse(c.base)
if err != nil {
return nil, err
}
scheme := "ws"
if u.Scheme == "https" {
scheme = "wss"
}
q := url.Values{}
q.Set("token", c.token)
q.Set("max_nodes", strconv.Itoa(maxNodes))
wu := scheme + "://" + u.Host + "/api/ws?" + q.Encode()
conn, resp, err := websocket.DefaultDialer.DialContext(ctx, wu, nil)
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 conn, err
}
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)
if err != nil {
return err
}
resp, err := c.hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return &apiError{Status: resp.StatusCode, Body: string(b)}
}
if dir := filepath.Dir(dest); dir != "." {
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
}
f, err := os.Create(dest)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, io.LimitReader(resp.Body, 32<<20))
return err
}
func expectedGuess(taskID, seed, clientID string, seq int64, bits int) string {
// Keep this exactly aligned with internal/core.ExpectedGuess without importing
// implementation details into the terminal UX layer.
return core.ExpectedGuess(taskID, seed, clientID, seq, bits)
}
var errSwitching = errors.New("task switch in progress")