Files
neural-hunt/cmd/loadtest/main.go
jbergner 185ccf1101
All checks were successful
release-tag / release-image (push) Successful in 2m10s
RC-6
2026-08-10 19:44:17 +02:00

271 lines
6.4 KiB
Go

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 leadingZeroBitsLoad(b []byte) int {
n := 0
for _, x := range b {
if x == 0 {
n += 8
continue
}
for m := byte(0x80); m != 0 && x&m == 0; m >>= 1 {
n++
}
break
}
return n
}
func solveProofLoad(challenge, cid string, bits int) string {
if bits <= 0 {
return ""
}
for i := uint64(0); ; i++ {
counter := fmt.Sprint(i)
h := sha256.Sum256([]byte("nh-pow-v1|" + challenge + "|" + cid + "|" + counter))
if leadingZeroBitsLoad(h[:]) >= bits {
return counter
}
}
}
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"`
ProofOfWorkBits int `json:"proof_of_work_bits"`
}
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"`
}
pow := solveProofLoad(ch.Challenge, c.cid, ch.ProofOfWorkBits)
if err = c.do("POST", "/api/auth/login", map[string]any{"public_jwk": j, "challenge": ch.Challenge, "signature": sig, "proof_of_work_counter": pow}, &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("max_nodes", fmt.Sprint(maxNodes))
wu := scheme + "://" + u.Host + "/api/ws?" + q.Encode()
h := http.Header{}
h.Set("Authorization", "Bearer "+c.token)
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wu, h)
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())
}
}
}