RC-1
Some checks failed
release-tag / release-image (push) Failing after 1m18s

This commit is contained in:
2026-08-10 05:48:55 +02:00
parent 4f0ac14c49
commit 005fd6ca51
52 changed files with 9020 additions and 1 deletions

326
cmd/client/api.go Normal file
View File

@@ -0,0 +1,326 @@
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")

261
cmd/client/identity.go Normal file
View File

@@ -0,0 +1,261 @@
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math/big"
"os"
"path/filepath"
"neuralhunt/internal/auth"
)
var rawURL = base64.RawURLEncoding
type privateJWK struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
D string `json:"d"`
Ext bool `json:"ext,omitempty"`
KeyOps []string `json:"key_ops,omitempty"`
Alg string `json:"alg,omitempty"`
Use string `json:"use,omitempty"`
Kid string `json:"kid,omitempty"`
}
type identityFile struct {
Version int `json:"version"`
PublicJWK auth.PublicJWK `json:"publicJwk"`
PrivateJWK privateJWK `json:"privateJwk"`
}
type encryptedIdentity struct {
Version int `json:"version"`
Salt string `json:"salt"`
IV string `json:"iv"`
Ciphertext string `json:"ciphertext"`
}
func pad32Bytes(b []byte) []byte {
out := make([]byte, 32)
if len(b) > len(out) {
b = b[len(b)-len(out):]
}
copy(out[len(out)-len(b):], b)
return out
}
func generateIdentity() (identityFile, *ecdsa.PrivateKey, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return identityFile{}, nil, err
}
x := rawURL.EncodeToString(pad32Bytes(key.X.Bytes()))
y := rawURL.EncodeToString(pad32Bytes(key.Y.Bytes()))
d := rawURL.EncodeToString(pad32Bytes(key.D.Bytes()))
pub := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: x, Y: y, Ext: true, KeyOps: []string{"verify"}}
priv := privateJWK{Kty: "EC", Crv: "P-256", X: x, Y: y, D: d, Ext: true, KeyOps: []string{"sign"}}
return identityFile{Version: 1, PublicJWK: pub, PrivateJWK: priv}, key, nil
}
func privateKeyFromIdentity(id identityFile) (*ecdsa.PrivateKey, error) {
if id.Version != 1 || id.PrivateJWK.Kty != "EC" || id.PrivateJWK.Crv != "P-256" || id.PrivateJWK.D == "" {
return nil, errors.New("unsupported identity; expected version 1 P-256 JWK")
}
db, err := rawURL.DecodeString(id.PrivateJWK.D)
if err != nil {
return nil, fmt.Errorf("decode private JWK: %w", err)
}
d := new(big.Int).SetBytes(db)
curve := elliptic.P256()
if d.Sign() <= 0 || d.Cmp(curve.Params().N) >= 0 {
return nil, errors.New("invalid P-256 private scalar")
}
x, y := curve.ScalarBaseMult(pad32Bytes(db))
if rawURL.EncodeToString(pad32Bytes(x.Bytes())) != id.PublicJWK.X || rawURL.EncodeToString(pad32Bytes(y.Bytes())) != id.PublicJWK.Y {
return nil, errors.New("identity public/private key mismatch")
}
return &ecdsa.PrivateKey{PublicKey: ecdsa.PublicKey{Curve: curve, X: x, Y: y}, D: d}, nil
}
func defaultIdentityPath() string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "./neuralhunt-identity.json"
}
return filepath.Join(home, ".neuralhunt", "identity.json")
}
func saveIdentity(path string, id identityFile) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
b, err := json.MarshalIndent(id, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(path, b, 0o600); err != nil {
return err
}
return os.Chmod(path, 0o600)
}
func loadOrCreateIdentity(path string) (identityFile, *ecdsa.PrivateKey, bool, error) {
b, err := os.ReadFile(path)
if err == nil {
var id identityFile
if err := json.Unmarshal(b, &id); err != nil {
return identityFile{}, nil, false, fmt.Errorf("parse identity %q: %w", path, err)
}
key, err := privateKeyFromIdentity(id)
return id, key, false, err
}
if !os.IsNotExist(err) {
return identityFile{}, nil, false, err
}
id, key, err := generateIdentity()
if err != nil {
return identityFile{}, nil, false, err
}
if err := saveIdentity(path, id); err != nil {
return identityFile{}, nil, false, err
}
return id, key, true, nil
}
func readIdentityImport(path, passphrase string) (identityFile, error) {
b, err := os.ReadFile(path)
if err != nil {
return identityFile{}, err
}
var probe map[string]json.RawMessage
if err := json.Unmarshal(b, &probe); err != nil {
return identityFile{}, err
}
if _, encrypted := probe["ciphertext"]; !encrypted {
var id identityFile
if err := json.Unmarshal(b, &id); err != nil {
return identityFile{}, err
}
if _, err := privateKeyFromIdentity(id); err != nil {
return identityFile{}, err
}
return id, nil
}
if passphrase == "" {
return identityFile{}, errors.New("encrypted browser identity requires --passphrase or NEURALHUNT_IDENTITY_PASSPHRASE")
}
var enc encryptedIdentity
if err := json.Unmarshal(b, &enc); err != nil {
return identityFile{}, err
}
salt, err := rawURL.DecodeString(enc.Salt)
if err != nil {
return identityFile{}, err
}
iv, err := rawURL.DecodeString(enc.IV)
if err != nil {
return identityFile{}, err
}
ct, err := rawURL.DecodeString(enc.Ciphertext)
if err != nil {
return identityFile{}, err
}
key := pbkdf2SHA256([]byte(passphrase), salt, 250000, 32)
block, err := aes.NewCipher(key)
if err != nil {
return identityFile{}, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return identityFile{}, err
}
plain, err := gcm.Open(nil, iv, ct, nil)
if err != nil {
return identityFile{}, errors.New("identity decrypt failed (wrong passphrase or damaged export)")
}
var id identityFile
if err := json.Unmarshal(plain, &id); err != nil {
return identityFile{}, err
}
if _, err := privateKeyFromIdentity(id); err != nil {
return identityFile{}, err
}
return id, nil
}
func exportBrowserIdentity(path, passphrase string, id identityFile) error {
if passphrase == "" {
return errors.New("export requires --passphrase or NEURALHUNT_IDENTITY_PASSPHRASE")
}
plain, err := json.Marshal(id)
if err != nil {
return err
}
salt := make([]byte, 16)
iv := make([]byte, 12)
if _, err := rand.Read(salt); err != nil {
return err
}
if _, err := rand.Read(iv); err != nil {
return err
}
key := pbkdf2SHA256([]byte(passphrase), salt, 250000, 32)
block, err := aes.NewCipher(key)
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
ct := gcm.Seal(nil, iv, plain, nil)
enc := encryptedIdentity{Version: 1, Salt: rawURL.EncodeToString(salt), IV: rawURL.EncodeToString(iv), Ciphertext: rawURL.EncodeToString(ct)}
b, err := json.MarshalIndent(enc, "", " ")
if err != nil {
return err
}
if dir := filepath.Dir(path); dir != "." {
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
}
return os.WriteFile(path, b, 0o600)
}
func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
hLen := sha256.Size
blocks := (keyLen + hLen - 1) / hLen
out := make([]byte, 0, blocks*hLen)
for block := 1; block <= blocks; block++ {
mac := hmac.New(sha256.New, password)
mac.Write(salt)
var n [4]byte
binary.BigEndian.PutUint32(n[:], uint32(block))
mac.Write(n[:])
u := mac.Sum(nil)
t := append([]byte(nil), u...)
for i := 1; i < iterations; i++ {
mac = hmac.New(sha256.New, password)
mac.Write(u)
u = mac.Sum(nil)
for j := range t {
t[j] ^= u[j]
}
}
out = append(out, t...)
}
return out[:keyLen]
}

View File

@@ -0,0 +1,55 @@
package main
import (
"encoding/hex"
"os"
"path/filepath"
"testing"
)
func TestPBKDF2SHA256Vector(t *testing.T) {
got := hex.EncodeToString(pbkdf2SHA256([]byte("password"), []byte("salt"), 2, 32))
const want = "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43"
if got != want {
t.Fatalf("pbkdf2 mismatch: got %s want %s", got, want)
}
}
func TestBrowserIdentityExportImportRoundTrip(t *testing.T) {
id, _, err := generateIdentity()
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "identity-export.json")
if err := exportBrowserIdentity(path, "correct horse battery staple", id); err != nil {
t.Fatal(err)
}
got, err := readIdentityImport(path, "correct horse battery staple")
if err != nil {
t.Fatal(err)
}
if got.PublicJWK.X != id.PublicJWK.X || got.PublicJWK.Y != id.PublicJWK.Y || got.PrivateJWK.D != id.PrivateJWK.D {
t.Fatal("identity changed during export/import")
}
if _, err := os.Stat(path); err != nil {
t.Fatal(err)
}
}
func TestRawIdentityImport(t *testing.T) {
id, _, err := generateIdentity()
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "identity.json")
if err := saveIdentity(path, id); err != nil {
t.Fatal(err)
}
got, err := readIdentityImport(path, "")
if err != nil {
t.Fatal(err)
}
if got.PrivateJWK.D != id.PrivateJWK.D {
t.Fatal("raw identity import mismatch")
}
}

89
cmd/client/main.go Normal file
View File

@@ -0,0 +1,89 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
)
func main() {
base := flag.String("url", envOr("NEURALHUNT_URL", "http://127.0.0.1:8080"), "Neural Hunt server URL")
identityPath := flag.String("identity", envOr("NEURALHUNT_IDENTITY", defaultIdentityPath()), "persistent terminal identity file")
taskSelector := flag.String("task", "", "initial task: number, ID prefix or display name")
maxNodes := flag.Int("max-nodes", 500, "maximum target-field working set")
nonInteractive := flag.Bool("non-interactive", false, "run unattended without command prompt")
quiet := flag.Bool("quiet", false, "suppress connection/status chatter")
importPath := flag.String("import", "", "import browser/terminal identity JSON before login")
exportPath := flag.String("export", "", "export current identity in browser-compatible encrypted format and exit")
passphrase := flag.String("passphrase", os.Getenv("NEURALHUNT_IDENTITY_PASSPHRASE"), "identity import/export passphrase (prefer environment variable)")
flag.Parse()
if strings.TrimSpace(*importPath) != "" {
id, err := readIdentityImport(*importPath, *passphrase)
if err != nil {
log.Fatal("identity import: ", err)
}
if err := saveIdentity(*identityPath, id); err != nil {
log.Fatal("save imported identity: ", err)
}
fmt.Println("Identity importiert nach", *identityPath)
}
id, key, created, err := loadOrCreateIdentity(*identityPath)
if err != nil {
log.Fatal(err)
}
if *exportPath != "" {
if err := exportBrowserIdentity(*exportPath, *passphrase, id); err != nil {
log.Fatal("identity export: ", err)
}
fmt.Println("Browser-kompatibler verschlüsselter Export:", *exportPath)
return
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
api := newAPI(*base, id, key)
if err := api.login(ctx); err != nil {
log.Fatal(err)
}
if created {
fmt.Println("Neue Terminal-Identität erzeugt:", *identityPath)
}
fmt.Println("NEURAL HUNT SHELL")
fmt.Println("Identity:", api.cid)
fmt.Println("Server :", strings.TrimRight(*base, "/"))
fmt.Println("Hinweis : Dieselbe Identity darf nicht gleichzeitig im Browser verbunden sein.")
a := newApp(api, *identityPath, *passphrase, *maxNodes, *quiet, *nonInteractive)
initial, err := selectInitialTask(ctx, a, *taskSelector, !*nonInteractive)
if err != nil {
log.Fatal(err)
}
if err := a.startTask(ctx, initial); err != nil {
log.Fatal(err)
}
go a.watcher(ctx)
if *nonInteractive {
<-ctx.Done()
a.stopSession()
return
}
if err := a.commandLoop(ctx, os.Stdin); err != nil && ctx.Err() == nil {
log.Println(err)
}
a.stopSession()
}
func envOr(name, fallback string) string {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
return v
}
return fallback
}

782
cmd/client/ui.go Normal file
View File

@@ -0,0 +1,782 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
type wsEvent struct {
Type string `json:"type"`
TaskID string `json:"task_id"`
Data json.RawMessage `json:"data"`
}
type app struct {
api *apiClient
identityPath string
passphrase string
maxNodes int
quiet bool
unattended bool
mu sync.RWMutex
task taskDTO
seq int64
points map[string]point
me meDTO
ws *websocket.Conn
wsConnected bool
sessionCancel context.CancelFunc
switching bool
leaderWatch bool
lastGuessAt time.Time
lastGuessOK bool
}
func newApp(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)}
}
func shortID(s string) string {
if len(s) <= 12 {
return s
}
return s[:6] + "…" + s[len(s)-4:]
}
func taskName(t taskCard) string {
if strings.TrimSpace(t.DisplayName) != "" {
return strings.TrimSpace(t.DisplayName)
}
return "Task " + shortID(t.ID)
}
func dtoName(t taskDTO) string {
if strings.TrimSpace(t.DisplayName) != "" {
return strings.TrimSpace(t.DisplayName)
}
return "Task " + shortID(t.ID)
}
func (a *app) printTasks(ctx context.Context) ([]taskCard, error) {
ts, err := a.api.tasks(ctx)
if err != nil {
return nil, err
}
fmt.Println("\nACTIVE TASKS")
fmt.Println("────────────────────────────────────────────────────────────────────────────")
for i, t := range ts {
mark := " "
if t.Selected {
mark = "*"
}
state := "RUN"
if t.Paused {
state = "PAUSED"
}
rank := "—"
if t.OwnRank > 0 {
rank = fmt.Sprintf("#%d", t.OwnRank)
}
fmt.Printf("%s %2d %-24s %3dbit %-6s nodes=%-5d score=%7.3f rank=%s\n", mark, i+1, clip(taskName(t), 24), t.RangeBits, state, t.PointCount, t.OwnScore, rank)
if d := strings.TrimSpace(t.Description); d != "" {
fmt.Printf(" %s\n", clip(d, 70))
}
fmt.Printf(" id=%s\n", t.ID)
}
if len(ts) == 0 {
fmt.Println("(keine aktiven Tasks)")
}
fmt.Println()
return ts, nil
}
func clip(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= n {
return string(r)
}
if n < 2 {
return string(r[:n])
}
return string(r[:n-1]) + "…"
}
func resolveTask(tasks []taskCard, selector string) (taskCard, error) {
selector = strings.TrimSpace(selector)
if selector == "" {
for _, t := range tasks {
if t.Selected {
return t, nil
}
}
if len(tasks) > 0 {
return tasks[0], nil
}
return taskCard{}, fmt.Errorf("no active task")
}
if n, err := strconv.Atoi(selector); err == nil && n >= 1 && n <= len(tasks) {
return tasks[n-1], nil
}
low := strings.ToLower(selector)
var matches []taskCard
for _, t := range tasks {
if strings.EqualFold(t.ID, selector) || strings.HasPrefix(strings.ToLower(t.ID), low) || strings.Contains(strings.ToLower(t.DisplayName), low) {
matches = append(matches, t)
}
}
if len(matches) == 1 {
return matches[0], nil
}
if len(matches) > 1 {
return taskCard{}, fmt.Errorf("task selector %q is ambiguous", selector)
}
return taskCard{}, fmt.Errorf("task %q not found", selector)
}
func (a *app) stopSession() {
a.mu.Lock()
cancel := a.sessionCancel
conn := a.ws
a.sessionCancel = nil
a.ws = nil
a.wsConnected = false
a.mu.Unlock()
if cancel != nil {
cancel()
}
if conn != nil {
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "switch task"), time.Now().Add(200*time.Millisecond))
_ = conn.Close()
}
// Give the server's single-identity presence cleanup a short chance to run.
time.Sleep(120 * time.Millisecond)
}
func (a *app) startTask(ctx context.Context, taskID string) error {
a.mu.Lock()
if a.switching {
a.mu.Unlock()
return errSwitching
}
a.switching = true
a.mu.Unlock()
defer func() {
a.mu.Lock()
a.switching = false
a.mu.Unlock()
}()
a.stopSession()
var (
t taskDTO
err error
)
if taskID != "" {
t, err = a.api.selectTask(ctx, taskID)
} else {
t, err = a.api.currentTask(ctx)
}
if err != nil {
return err
}
ws, err := a.api.dialWS(ctx, a.maxNodes)
if err != nil {
return err
}
ps, _ := a.api.points(ctx, t.ID, a.maxNodes*3)
m, _ := a.api.me(ctx)
sctx, cancel := context.WithCancel(context.Background())
a.mu.Lock()
a.task, a.seq, a.ws, a.sessionCancel, a.me = t, t.NextSeq, ws, cancel, m
a.wsConnected = true
a.points = make(map[string]point, len(ps))
for _, p := range ps {
a.points[p.ClientID] = p
}
a.mu.Unlock()
go a.wsLoop(sctx, ws, t.ID)
go a.guessLoop(sctx, t.ID)
if !a.quiet {
fmt.Printf("\n▶ %s [%s] %d bit\n", dtoName(t), shortID(t.ID), t.RangeBits)
if t.Paused {
fmt.Println(" Task ist pausiert; automatische Tipps warten.")
}
}
return nil
}
func (a *app) wsLoop(ctx context.Context, initial *websocket.Conn, taskID string) {
conn := initial
backoff := 500 * time.Millisecond
for {
completed, err := a.readWSOnce(ctx, conn, taskID)
a.mu.Lock()
if a.ws == conn {
a.ws = nil
a.wsConnected = false
}
a.mu.Unlock()
if completed || ctx.Err() != nil {
return
}
if err != nil && !a.quiet {
fmt.Printf("\n[ws] Verbindung beendet: %v; Reconnect folgt automatisch\n", err)
}
for {
timer := time.NewTimer(backoff)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
next, dialErr := a.api.dialWS(ctx, a.maxNodes)
if dialErr != nil {
if !a.quiet {
fmt.Printf("[ws] Reconnect fehlgeschlagen: %v\n", dialErr)
}
backoff = time.Duration(minInt64(int64(8*time.Second), int64(float64(backoff)*1.7)))
continue
}
a.mu.Lock()
if a.task.ID != taskID || ctx.Err() != nil {
a.mu.Unlock()
_ = next.Close()
return
}
a.ws = next
a.wsConnected = true
a.mu.Unlock()
conn = next
backoff = 500 * time.Millisecond
go a.refreshTask(taskID)
if !a.quiet {
fmt.Println("[ws] wieder verbunden")
}
break
}
}
}
func minInt64(a, b int64) int64 {
if a < b {
return a
}
return b
}
func (a *app) forceWSReconnect() {
a.mu.Lock()
conn := a.ws
a.ws = nil
a.wsConnected = false
a.mu.Unlock()
if conn != nil {
_ = conn.Close()
}
}
func (a *app) readWSOnce(ctx context.Context, conn *websocket.Conn, taskID string) (bool, error) {
for {
_, b, err := conn.ReadMessage()
if err != nil {
if ctx.Err() != nil {
return false, ctx.Err()
}
return false, err
}
var ev wsEvent
if json.Unmarshal(b, &ev) != nil {
continue
}
switch ev.Type {
case "snapshot":
var ps []point
if json.Unmarshal(ev.Data, &ps) == nil {
a.replacePoints(ps)
}
case "point":
var p point
if json.Unmarshal(ev.Data, &p) == nil {
a.upsertPoint(p)
}
case "points":
var ps []point
if json.Unmarshal(ev.Data, &ps) == nil {
for _, p := range ps {
a.upsertPoint(p)
}
}
case "task_changed":
if ev.TaskID == "" || ev.TaskID == taskID {
go a.refreshTask(taskID)
}
case "task_completed":
if ev.TaskID == taskID {
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)
}
}()
return true, nil
}
}
}
}
func (a *app) replacePoints(ps []point) {
a.mu.Lock()
defer a.mu.Unlock()
a.points = make(map[string]point, len(ps))
for _, p := range ps {
a.points[p.ClientID] = p
}
}
func (a *app) upsertPoint(p point) {
a.mu.Lock()
defer a.mu.Unlock()
if a.points == nil {
a.points = make(map[string]point)
}
a.points[p.ClientID] = p
if p.ClientID == a.api.cid {
a.me.Score, a.me.Rank = p.Score, p.Rank
}
if len(a.points) > a.maxNodes*3 {
a.trimPointsLocked()
}
}
func (a *app) trimPointsLocked() {
ps := make([]point, 0, len(a.points))
for _, p := range a.points {
ps = append(ps, p)
}
sort.Slice(ps, func(i, j int) bool { return ps[i].Score > ps[j].Score })
keep := a.maxNodes * 3
if keep > len(ps) {
keep = len(ps)
}
next := make(map[string]point, keep+1)
for _, p := range ps[:keep] {
next[p.ClientID] = p
}
if own, ok := a.points[a.api.cid]; ok {
next[own.ClientID] = own
}
a.points = next
}
func (a *app) refreshTask(taskID string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
t, err := a.api.currentTask(ctx)
if err != nil || t.ID != taskID {
return
}
ps, _ := a.api.points(ctx, t.ID, a.maxNodes*3)
m, _ := a.api.me(ctx)
a.mu.Lock()
oldSeed := a.task.PublicSeed
a.task = t
// Preserve the hot sequence for same-seed changes (bits/intervals/config).
// A reroll changes public_seed and intentionally resets to the server state.
if oldSeed != t.PublicSeed {
a.seq = t.NextSeq
} else if t.NextSeq > a.seq {
a.seq = t.NextSeq
}
if m.ClientID != "" {
a.me = m
}
if ps != nil {
a.points = make(map[string]point, len(ps))
for _, p := range ps {
a.points[p.ClientID] = p
}
}
a.mu.Unlock()
if !a.quiet {
fmt.Printf("\n[task] Konfiguration aktualisiert: %d bit, Intervall %ds, paused=%v\n", t.RangeBits, t.ClientSubmitIntervalSec, t.Paused)
}
}
func (a *app) guessLoop(ctx context.Context, taskID string) {
for {
a.mu.RLock()
t := a.task
a.mu.RUnlock()
if t.ID != taskID {
return
}
sec := t.ClientSubmitIntervalSec
if sec <= 0 {
sec = 11
}
timer := time.NewTimer(time.Duration(sec) * time.Second)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
// Re-read state after sleeping. Admin actions may have changed seed/bits,
// sequence or pause state while this iteration was waiting.
a.mu.RLock()
t = a.task
seq := a.seq
connected := a.wsConnected
a.mu.RUnlock()
if t.ID != taskID {
return
}
if t.Paused || !connected {
continue
}
correct, err := a.api.guess(ctx, t, seq)
if err != nil {
if ctx.Err() != nil {
return
}
if ae := new(apiError); errorsAs(err, &ae) && (ae.Status == 409 || ae.Status == 423 || ae.Status == 429) {
a.refreshTask(taskID)
if ae.Status == 409 && ae.Code() == "presence_required" {
a.forceWSReconnect()
}
} else if !a.quiet {
fmt.Printf("\n[guess] %v\n", err)
}
continue
}
a.mu.Lock()
if a.task.ID == taskID && a.seq == seq {
a.seq++
}
a.lastGuessAt = time.Now()
a.lastGuessOK = true
a.mu.Unlock()
if correct {
fmt.Printf("\n★ GEWONNEN: %s ★\n", dtoName(t))
return
}
}
}
// errorsAs is a tiny wrapper to keep the UI file's imports readable.
func errorsAs(err error, target any) bool { return errors.As(err, target) }
func (a *app) printStatus(ctx context.Context) {
m, err := a.api.me(ctx)
if err == nil {
a.mu.Lock()
a.me = m
a.mu.Unlock()
}
a.mu.RLock()
t, seq, points, me, last := a.task, a.seq, len(a.points), a.me, a.lastGuessAt
a.mu.RUnlock()
fmt.Println("\nSTATUS")
fmt.Println("────────────────────────────────────────────────────────")
fmt.Printf("Identity : %s\n", a.api.cid)
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)
fmt.Printf("Sequence : %d Nodes im Working Set %d\n", seq, points)
if !last.IsZero() {
fmt.Printf("Letzter Tipp: %s\n", last.Format("15:04:05"))
}
if len(me.Unlocks) > 0 {
fmt.Printf("Unlocks : %s\n", strings.Join(me.Unlocks, ", "))
}
fmt.Println()
}
func (a *app) printMap(limit int) {
if limit <= 0 {
limit = 30
}
a.mu.RLock()
ps := make([]point, 0, len(a.points))
for _, p := range a.points {
ps = append(ps, p)
}
cid := a.api.cid
t := a.task
a.mu.RUnlock()
sort.Slice(ps, func(i, j int) bool {
if ps[i].Score == ps[j].Score {
return ps[i].ClientID < ps[j].ClientID
}
return ps[i].Score > ps[j].Score
})
if len(ps) > limit {
ps = ps[:limit]
}
fmt.Printf("\nTARGET FIELD — %s\n", dtoName(t))
fmt.Println("TASK ◉ ← höherer Score bedeutet näher am Zentrum")
fmt.Println("────────────────────────────────────────────────────────────────")
bands := []struct {
name string
min float64
max float64
}{{"99+ INNER CORE", 99, 101}, {"9599 NEAR", 95, 99}, {"9095 CLOSE", 90, 95}, {"7590 MID", 75, 90}, {"5075 FAR", 50, 75}, {"050 OUTER", 0, 50}}
for _, b := range bands {
var names []string
for _, p := range ps {
if p.Score >= b.min && p.Score < b.max {
mark := ""
if p.ClientID == cid {
mark = "*"
}
names = append(names, fmt.Sprintf("%s%s %.3f", mark, shortID(p.ClientID), p.Score))
}
}
fmt.Printf("%-14s │ %s\n", b.name, strings.Join(names, " "))
}
fmt.Println("* = deine Identität")
fmt.Println()
}
func (a *app) printLeaderboard(ctx context.Context, limit int) error {
ls, err := a.api.leaderboard(ctx)
if err != nil {
return err
}
if limit <= 0 || limit > len(ls) {
limit = minInt(25, len(ls))
}
fmt.Println("\nREALTIME LEADERBOARD")
fmt.Println("──────────────────────────────────────────────────────────────────────────")
fmt.Printf("%-5s %-15s %6s %10s %10s %8s %5s\n", "RANK", "CLIENT", "WINS", "LIVE", "BEST", "GUESSES", "NFT")
for i, l := range ls[:limit] {
conn := " "
if l.Connected {
conn = "●"
}
self := ""
if l.ClientID == a.api.cid {
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)
}
fmt.Println("● online * du")
fmt.Println()
return nil
}
func (a *app) printNFTs(ctx context.Context, limit int) error {
if limit <= 0 {
limit = 30
}
items, err := a.api.artifacts(ctx, limit)
if err != nil {
return err
}
fmt.Println("\nNFT / WINNER ARTIFACTS (öffentliche Wasserzeichen-Previews)")
fmt.Println("──────────────────────────────────────────────────────────────────────────")
for i, n := range items {
fmt.Printf("%2d task=%-16s winner=%-14s %3dbit %s\n", i+1, shortID(n.TaskID), shortID(n.WinnerClientID), n.RangeBits, n.CompletedAt.Local().Format("2006-01-02 15:04"))
fmt.Printf(" %s%s\n", a.api.base, n.PreviewURI)
}
if len(items) == 0 {
fmt.Println("(noch keine fertigen Artefakte)")
}
fmt.Println()
return nil
}
func (a *app) watcher(ctx context.Context) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
a.mu.RLock()
watch := a.leaderWatch
task := a.task
seq := a.seq
me := a.me
a.mu.RUnlock()
if watch {
_ = a.printLeaderboard(ctx, 15)
} else if a.unattended && !a.quiet {
fmt.Printf("%s task=%s score=%.4f rank=%d seq=%d paused=%v\n", time.Now().Format("15:04:05"), shortID(task.ID), me.Score, me.Rank, seq, task.Paused)
}
}
}
}
func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
s := bufio.NewScanner(in)
fmt.Println("Befehle: help, tasks, use <nr|id|name>, status, map [n], leaderboard [n|watch|stop], nfts [n], nft get <task-id> <datei>, identity, identity export <datei>, quit")
for {
fmt.Print("neuralhunt> ")
if !s.Scan() {
return s.Err()
}
line := strings.TrimSpace(s.Text())
if line == "" {
continue
}
parts := strings.Fields(line)
cmd := strings.ToLower(parts[0])
switch cmd {
case "help", "?":
fmt.Println(" tasks aktive Tasks anzeigen")
fmt.Println(" use <nr|id|name> Task wechseln")
fmt.Println(" status Score, Rank, Sequence, Task")
fmt.Println(" map [n] textuelles Target Field / Nähe")
fmt.Println(" leaderboard [n] Live-Rangliste")
fmt.Println(" leaderboard watch|stop Rangliste alle 5s ein/aus")
fmt.Println(" nfts [n] Wasserzeichen-NFTs anzeigen")
fmt.Println(" nft get <task-id> <datei> Wasserzeichen-Preview speichern")
fmt.Println(" identity Client-ID und Identity-Datei")
fmt.Println(" identity export <datei> browser-kompatiblen verschlüsselten Export schreiben")
fmt.Println(" quit beenden")
case "tasks":
if _, err := a.printTasks(ctx); err != nil {
fmt.Println("Fehler:", err)
}
case "use":
if len(parts) < 2 {
fmt.Println("use benötigt Nummer, ID-Präfix oder Namen")
continue
}
ts, err := a.api.tasks(ctx)
if err != nil {
fmt.Println("Fehler:", err)
continue
}
t, err := resolveTask(ts, strings.Join(parts[1:], " "))
if err != nil {
fmt.Println("Fehler:", err)
continue
}
if err := a.startTask(ctx, t.ID); err != nil {
fmt.Println("Fehler:", err)
}
case "status":
a.printStatus(ctx)
case "map":
n := 30
if len(parts) > 1 {
n, _ = strconv.Atoi(parts[1])
}
a.printMap(n)
case "leaderboard", "lb":
if len(parts) > 1 && strings.EqualFold(parts[1], "watch") {
a.mu.Lock()
a.leaderWatch = true
a.mu.Unlock()
fmt.Println("Leaderboard-Watch aktiviert.")
continue
}
if len(parts) > 1 && (strings.EqualFold(parts[1], "stop") || strings.EqualFold(parts[1], "off")) {
a.mu.Lock()
a.leaderWatch = false
a.mu.Unlock()
fmt.Println("Leaderboard-Watch deaktiviert.")
continue
}
n := 25
if len(parts) > 1 {
n, _ = strconv.Atoi(parts[1])
}
if err := a.printLeaderboard(ctx, n); err != nil {
fmt.Println("Fehler:", err)
}
case "nfts":
n := 30
if len(parts) > 1 {
n, _ = strconv.Atoi(parts[1])
}
if err := a.printNFTs(ctx, n); err != nil {
fmt.Println("Fehler:", err)
}
case "nft":
if len(parts) == 4 && strings.EqualFold(parts[1], "get") {
if err := a.api.downloadPreview(ctx, parts[2], parts[3]); err != nil {
fmt.Println("Fehler:", err)
} else {
fmt.Println("Wasserzeichen-Preview gespeichert:", parts[3])
}
} else {
fmt.Println("nft get <task-id> <datei>")
}
case "identity", "id":
if len(parts) >= 2 && strings.EqualFold(parts[1], "export") {
if len(parts) != 3 {
fmt.Println("identity export <datei>")
continue
}
if err := exportBrowserIdentity(parts[2], a.passphrase, a.api.id); err != nil {
fmt.Println("Fehler:", err)
} else {
fmt.Println("Verschlüsselter Browser-Export geschrieben:", parts[2])
}
} else {
fmt.Println("Client-ID:", a.api.cid)
fmt.Println("Identity :", a.identityPath)
}
case "quit", "exit", "q":
return nil
default:
fmt.Println("Unbekannter Befehl. 'help' zeigt die Befehle.")
}
}
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func selectInitialTask(ctx context.Context, a *app, selector string, interactive bool) (string, error) {
ts, err := a.printTasks(ctx)
if err != nil {
return "", err
}
if len(ts) == 0 {
return "", fmt.Errorf("server has no active tasks")
}
if selector != "" || !interactive {
t, err := resolveTask(ts, selector)
return t.ID, err
}
fmt.Print("Task auswählen [Nummer/ID/Name, Enter = markierter Task]: ")
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
t, err := resolveTask(ts, strings.TrimSpace(line))
return t.ID, err
}

239
cmd/loadtest/main.go Normal file
View File

@@ -0,0 +1,239 @@
package main
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"math/big"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"neuralhunt/internal/auth"
"neuralhunt/internal/core"
)
var b64 = base64.RawURLEncoding
type apiClient struct {
base string
hc *http.Client
token, cid string
key *ecdsa.PrivateKey
}
type taskDTO struct {
ID string `json:"id"`
PublicSeed string `json:"public_seed"`
RangeBits int `json:"range_bits"`
NextSeq int64 `json:"next_seq"`
SubmitSec int `json:"client_submit_interval_sec"`
Paused bool `json:"paused"`
}
func pad32(x *big.Int) []byte {
b := x.Bytes()
out := make([]byte, 32)
copy(out[32-len(b):], b)
return out
}
func signRaw(k *ecdsa.PrivateKey, msg string) (string, error) {
h := sha256.Sum256([]byte(msg))
r, s, err := ecdsa.Sign(rand.Reader, k, h[:])
if err != nil {
return "", err
}
raw := append(pad32(r), pad32(s)...)
return b64.EncodeToString(raw), nil
}
func (c *apiClient) do(method, path string, body any, out any) error {
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, c.base+path, rd)
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, 1<<20))
if resp.StatusCode/100 != 2 {
return fmt.Errorf("%s: %s", resp.Status, string(b))
}
if out != nil {
return json.Unmarshal(b, out)
}
return nil
}
func (c *apiClient) authn() error {
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
c.key = k
j := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: b64.EncodeToString(pad32(k.X)), Y: b64.EncodeToString(pad32(k.Y)), Ext: true}
var ch struct {
ClientID string `json:"client_id"`
Challenge string `json:"challenge"`
}
if err = c.do("POST", "/api/auth/challenge", map[string]any{"public_jwk": j}, &ch); err != nil {
return err
}
c.cid = ch.ClientID
sig, _ := signRaw(k, "login|"+ch.Challenge+"|"+c.cid)
var lg struct {
Token string `json:"token"`
}
if err = c.do("POST", "/api/auth/login", map[string]any{"public_jwk": j, "challenge": ch.Challenge, "signature": sig}, &lg); err != nil {
return err
}
c.token = lg.Token
return nil
}
func (c *apiClient) current() (taskDTO, error) {
var t taskDTO
err := c.do("GET", "/api/tasks/current", nil, &t)
return t, err
}
func (c *apiClient) ws(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", fmt.Sprint(maxNodes))
wu := scheme + "://" + u.Host + "/api/ws?" + q.Encode()
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wu, nil)
return conn, err
}
func main() {
base := flag.String("url", "http://127.0.0.1:8080", "server URL")
clients := flag.Int("clients", 1000, "virtual clients")
ramp := flag.Duration("ramp", 30*time.Second, "connection ramp")
duration := flag.Duration("duration", 2*time.Minute, "test duration after ramp")
nodes := flag.Int("max-nodes", 250, "snapshot budget per client")
flag.Parse()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var connected, guesses, errs atomic.Uint64
var wg sync.WaitGroup
start := time.Now()
step := time.Duration(0)
if *clients > 0 {
step = *ramp / time.Duration(*clients)
}
for i := 0; i < *clients; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
if step > 0 {
select {
case <-ctx.Done():
return
case <-time.After(step * time.Duration(i)):
}
}
c := &apiClient{base: strings.TrimRight(*base, "/"), hc: &http.Client{Timeout: 10 * time.Second}}
if err := c.authn(); err != nil {
errs.Add(1)
return
}
t, err := c.current()
if err != nil {
errs.Add(1)
return
}
ws, err := c.ws(ctx, *nodes)
if err != nil {
errs.Add(1)
return
}
defer ws.Close()
connected.Add(1)
go func() {
for {
if _, _, e := ws.ReadMessage(); e != nil {
return
}
}
}()
interval := time.Duration(t.SubmitSec) * time.Second
if interval <= 0 {
interval = 11 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
seq := t.NextSeq
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if t.Paused {
continue
}
guess := core.ExpectedGuess(t.ID, t.PublicSeed, c.cid, seq, t.RangeBits)
sig, _ := signRaw(c.key, fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess))
var ok bool
err := c.do("POST", "/api/tasks/"+t.ID+"/guess", map[string]any{"seq": seq, "guess": guess, "signature": sig}, &ok)
if err != nil {
errs.Add(1)
nt, e := c.current()
if e == nil {
t = nt
seq = t.NextSeq
}
continue
}
guesses.Add(1)
seq++
if ok {
nt, e := c.current()
if e == nil {
t = nt
seq = t.NextSeq
}
}
}
}
}(i)
}
tick := time.NewTicker(5 * time.Second)
defer tick.Stop()
end := time.After(*ramp + *duration)
for {
select {
case <-end:
cancel()
wg.Wait()
fmt.Printf("done connected=%d accepted_guesses=%d errors=%d elapsed=%s\n", connected.Load(), guesses.Load(), errs.Load(), time.Since(start).Round(time.Second))
return
case <-tick.C:
log.Printf("connected=%d guesses=%d errors=%d", connected.Load(), guesses.Load(), errs.Load())
}
}
}

109
cmd/server/main.go Normal file
View File

@@ -0,0 +1,109 @@
package main
import (
"bufio"
"context"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"neuralhunt/internal/artifact"
"neuralhunt/internal/auth"
"neuralhunt/internal/data"
rtx "neuralhunt/internal/runtime"
"neuralhunt/internal/server"
"neuralhunt/internal/settings"
wsx "neuralhunt/internal/ws"
)
func env(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func loadDotEnv(path string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
s := bufio.NewScanner(f)
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "export ") {
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k = strings.TrimSpace(k)
if k == "" {
continue
}
if _, exists := os.LookupEnv(k); exists {
continue
}
v = strings.TrimSpace(v)
if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) {
v = v[1 : len(v)-1]
}
_ = os.Setenv(k, v)
}
}
func main() {
loadDotEnv(".env")
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
db, err := data.OpenSQLite(ctx, env("SQLITE_PATH", "./data/neuralhunt.db"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
sm, err := settings.New(ctx, db)
if err != nil {
log.Fatal(err)
}
go sm.Run(ctx)
store := data.New(db)
a := auth.New(db, env("JWT_SECRET", "dev-secret-change-me"))
hub := wsx.New()
runtimeState := rtx.New()
go hub.Run(ctx)
artifactDir := env("ARTIFACT_DIR", "./data/artifacts")
aw, err := artifact.New(db, artifactDir, sm)
if err != nil {
log.Fatal(err)
}
go aw.Run(ctx)
srv := server.New(store, a, sm, hub, runtimeState, artifactDir, aw)
go srv.Scheduler(ctx)
httpSrv := &http.Server{Addr: env("HTTP_ADDR", ":8080"), Handler: srv.Routes(), ReadHeaderTimeout: 5 * time.Second}
go func() {
log.Printf("listening on %s", httpSrv.Addr)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-ctx.Done()
shutdown, done := context.WithTimeout(context.Background(), 10*time.Second)
defer done()
_ = httpSrv.Shutdown(shutdown)
}