237 lines
7.0 KiB
Go
237 lines
7.0 KiB
Go
// 假执行器:模拟客户端核心(配对 → WSS → 心跳 → 任务执行 → 挑战处理)。
|
|
// 用途:D4 本地闭环验收 + 联调诊断;D8 起由 client/core 真 Agent 取代。
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"flag"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
|
|
"everypublish/shared/proto"
|
|
)
|
|
|
|
var (
|
|
server = flag.String("server", "http://127.0.0.1:8090", "服务器地址")
|
|
user = flag.String("user", "demo@everypublish.io", "登录邮箱")
|
|
password = flag.String("pass", "demo-pass-2026", "登录密码")
|
|
devName = flag.String("name", "fake-agent-01", "设备名")
|
|
execMS = flag.Int("exec", 50, "模拟执行时长 ms")
|
|
keyPath = flag.String("key", "./data/fake-agent.ed25519", "设备私钥文件")
|
|
)
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
log.SetFlags(log.Ltime | log.Lmicroseconds)
|
|
|
|
access := login(*server, *user, *password)
|
|
code := pairCode(*server, access)
|
|
pub, priv := loadOrCreateKey(*keyPath)
|
|
deviceID := pair(*server, code, pub)
|
|
log.Printf("paired device %d", deviceID)
|
|
|
|
wsURL := "ws" + (*server)[4:] + "/ws/agent"
|
|
conn, _, err := websocket.Dial(context.Background(), wsURL, nil)
|
|
if err != nil {
|
|
log.Fatalf("dial: %v", err)
|
|
}
|
|
defer conn.Close(websocket.StatusNormalClosure, "bye")
|
|
|
|
nonce := randHex(16)
|
|
ts := time.Now().UnixMilli()
|
|
sig := ed25519.Sign(priv, []byte(nonce+"|"+strconv.FormatInt(ts, 10)))
|
|
hello := proto.NewEnvelope(randHex(8), proto.TypeHello, proto.DeviceHello{
|
|
DeviceID: strconv.FormatUint(deviceID, 10),
|
|
Nonce: nonce,
|
|
TS: ts,
|
|
Sig: base64.StdEncoding.EncodeToString(sig),
|
|
Version: "0.1.0-fake",
|
|
})
|
|
if err = send(conn, hello); err != nil {
|
|
log.Fatalf("hello: %v", err)
|
|
}
|
|
_, raw, err := conn.Read(context.Background())
|
|
if err != nil {
|
|
log.Fatalf("hello.ack read: %v", err)
|
|
}
|
|
var ackEnv proto.Envelope
|
|
_ = json.Unmarshal(raw, &ackEnv)
|
|
if ackEnv.Type != proto.TypeHelloAck {
|
|
log.Fatalf("expect hello.ack, got %s", ackEnv.Type)
|
|
}
|
|
var ack proto.HelloAck
|
|
_ = json.Unmarshal(ackEnv.Payload, &ack)
|
|
log.Printf("online (session %s)", ack.SessionID)
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
go heartbeatLoop(ctx, conn, deviceID)
|
|
|
|
for {
|
|
_, raw, err = conn.Read(ctx)
|
|
if err != nil {
|
|
log.Fatalf("read: %v", err)
|
|
}
|
|
var env proto.Envelope
|
|
if err = json.Unmarshal(raw, &env); err != nil {
|
|
continue
|
|
}
|
|
switch env.Type {
|
|
case proto.TypeTaskPush:
|
|
var push proto.TaskPush
|
|
_ = json.Unmarshal(env.Payload, &push)
|
|
log.Printf("TASK push %s platform=%s title=%s", push.TaskID, push.Platform, push.Title)
|
|
_ = send(conn, proto.NewEnvelope(env.ID, proto.TypeTaskAck, proto.TaskAck{TaskID: push.TaskID, Accept: true}))
|
|
time.Sleep(time.Duration(*execMS) * time.Millisecond)
|
|
result := proto.TaskResult{
|
|
TaskID: push.TaskID,
|
|
Status: "success",
|
|
PublishedURL: "https://example.com/fake/" + push.TaskID,
|
|
Receipts: []string{},
|
|
FinishedAt: time.Now().UnixMilli(),
|
|
}
|
|
_ = send(conn, proto.NewEnvelope(randHex(8), proto.TypeTaskResult, result))
|
|
log.Printf("TASK %s done (%dms)", push.TaskID, *execMS)
|
|
case proto.TypeChallenge:
|
|
var ch proto.Challenge
|
|
_ = json.Unmarshal(env.Payload, &ch)
|
|
log.Printf("CHALLENGE %s kind=%s prompt=%s", ch.ChallengeID, ch.Kind, ch.Prompt)
|
|
_ = send(conn, proto.NewEnvelope(env.ID, proto.TypeChallengeAck, proto.ChallengeAck{ChallengeID: ch.ChallengeID, Action: "accept"}))
|
|
time.Sleep(time.Duration(*execMS) * time.Millisecond)
|
|
_ = send(conn, proto.NewEnvelope(randHex(8), proto.TypeChallengeSolve, proto.ChallengeSolve{ChallengeID: ch.ChallengeID, Value: "fake-qr-token"}))
|
|
log.Printf("CHALLENGE %s solved", ch.ChallengeID)
|
|
case proto.TypeHeartbeatAck:
|
|
default:
|
|
log.Printf("ignore msg type %s", env.Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
func heartbeatLoop(ctx context.Context, conn *websocket.Conn, deviceID uint64) {
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
_ = send(conn, proto.NewEnvelope(randHex(8), proto.TypeHeartbeat, proto.Heartbeat{DeviceID: strconv.FormatUint(deviceID, 10), TS: time.Now().UnixMilli()}))
|
|
}
|
|
}
|
|
}
|
|
|
|
func send(conn *websocket.Conn, msg *proto.Envelope) error {
|
|
raw, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
return conn.Write(wctx, websocket.MessageText, raw)
|
|
}
|
|
|
|
// ---- REST 辅助 ----
|
|
|
|
func httpJSON(method, url, token string, body interface{}) (int, []byte) {
|
|
var buf bytes.Buffer
|
|
if body != nil {
|
|
raw, _ := json.Marshal(body)
|
|
buf.Write(raw)
|
|
}
|
|
req, _ := http.NewRequest(method, url, &buf)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
log.Fatalf("http %s %s: %v", method, url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, raw
|
|
}
|
|
|
|
func field(data []byte, key string) string {
|
|
var m map[string]interface{}
|
|
_ = json.Unmarshal(data, &m)
|
|
if d, ok := m["data"].(map[string]interface{}); ok {
|
|
if v, ok := d[key]; ok {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return t
|
|
case float64:
|
|
return strconv.FormatInt(int64(t), 10)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func login(base, email, password string) string {
|
|
code, raw := httpJSON("POST", base+"/api/v1/auth/login", "", ginH{"email": email, "password": password})
|
|
if code != 200 {
|
|
log.Fatalf("login failed: %s", string(raw))
|
|
}
|
|
log.Println("login ok")
|
|
return field(raw, "accessToken")
|
|
}
|
|
|
|
func pairCode(base, token string) string {
|
|
code, raw := httpJSON("POST", base+"/api/v1/agent/pair-code", token, nil)
|
|
if code != 200 {
|
|
log.Fatalf("pair-code failed: %s", string(raw))
|
|
}
|
|
return field(raw, "code")
|
|
}
|
|
|
|
func pair(base, code, pub string) uint64 {
|
|
httpCode, raw := httpJSON("POST", base+"/api/v1/agent/pair", "", ginH{"code": code, "deviceName": *devName, "os": "fake", "version": "0.1.0", "publicKey": pub})
|
|
if httpCode != 200 {
|
|
log.Fatalf("pair failed: %s", string(raw))
|
|
}
|
|
id, _ := strconv.ParseUint(field(raw, "deviceId"), 10, 64)
|
|
return id
|
|
}
|
|
|
|
type ginH map[string]interface{}
|
|
|
|
func loadOrCreateKey(path string) (string, ed25519.PrivateKey) {
|
|
if raw, err := os.ReadFile(path); err == nil {
|
|
seed, err := hex.DecodeString(string(raw))
|
|
if err == nil && len(seed) == ed25519.SeedSize {
|
|
priv := ed25519.NewKeyFromSeed(seed)
|
|
return base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)), priv
|
|
}
|
|
}
|
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
log.Fatalf("keygen: %v", err)
|
|
}
|
|
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
|
_ = os.WriteFile(path, []byte(hex.EncodeToString(priv.Seed())), 0o600)
|
|
return base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)), priv
|
|
}
|
|
|
|
func randHex(n int) string {
|
|
b := make([]byte, n)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|