client: Windows 客户端(WinUI3 壳 + agent-core;调试用 Electron 壳存档);localserver token 改为常数时间比较

This commit is contained in:
Qiufeng
2026-08-20 20:38:21 +08:00
parent 33b7d57498
commit 9abf7f8213
60 changed files with 10025 additions and 0 deletions
@@ -0,0 +1,163 @@
// localserver 本机控制服务:仅 127.0.0.1,token 鉴权,供 WinUI 壳调用。
// 端点:/status /challenges /challenges/{id} /pair;端口与 token 写入 data/local.json。
package localserver
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"everypublish/shared/proto"
)
// CoreState 核心状态接口(由 wsclient.Client 实现)
type CoreState interface {
Online() bool
SessionID() string
Challenges() []proto.Challenge
SolveChallenge(challengeID, value string) error
}
// PairFunc 配对回调(main 提供:配对成功保存身份并启动连接)
type PairFunc func(code, name string) error
// Info 本地服务信息(写 local.json 供壳读取)
type Info struct {
Port int `json:"port"`
Token string `json:"token"`
}
// ExtraFunc 额外状态字段(server/deviceId/name 等,闭包实时取值)
type ExtraFunc func() map[string]interface{}
// Server 本机控制服务
type Server struct {
state CoreState
pair PairFunc
token string
dir string
extra ExtraFunc
mu sync.Mutex
addr string
}
// New 构造控制服务(extra 可空)
func New(state CoreState, pair PairFunc, token, dir string, extra ExtraFunc) *Server {
return &Server{state: state, pair: pair, token: token, dir: dir, extra: extra}
}
// Listen 绑定随机端口并服务,同时写 data/local.json
func (s *Server) Listen() error {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
port := ln.Addr().(*net.TCPAddr).Port
s.mu.Lock()
s.addr = ln.Addr().String()
s.mu.Unlock()
raw, _ := json.Marshal(Info{Port: port, Token: s.token})
_ = os.WriteFile(filepath.Join(s.dir, "local.json"), raw, 0o600)
log.Printf("[local] control server %s (local.json written)", s.addr)
mux := http.NewServeMux()
mux.HandleFunc("/status", s.handleStatus)
mux.HandleFunc("/challenges", s.handleChallenges)
mux.HandleFunc("/challenges/", s.handleSolve)
mux.HandleFunc("/pair", s.handlePair)
return http.Serve(ln, mux)
}
// Addr 实际监听地址(Listen 后可用)
func (s *Server) Addr() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.addr
}
func (s *Server) auth(r *http.Request) bool {
// 仅接受请求头;token 不进 URL(避免泄露进日志/历史)。常数时间比较防时序侧信道。
got := r.Header.Get("X-Agent-Token")
if len(got) != len(s.token) {
return false
}
return subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) == 1
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
if !s.auth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
m := map[string]interface{}{
"online": s.state.Online(),
"session": s.state.SessionID(),
}
if s.extra != nil {
for k, v := range s.extra() {
m[k] = v
}
}
_ = json.NewEncoder(w).Encode(m)
}
func (s *Server) handleChallenges(w http.ResponseWriter, r *http.Request) {
if !s.auth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"list": s.state.Challenges()})
}
func (s *Server) handleSolve(w http.ResponseWriter, r *http.Request) {
if !s.auth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
id := strings.TrimPrefix(r.URL.Path, "/challenges/")
var req struct {
Value string `json:"value"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if err := s.state.SolveChallenge(id, req.Value); err != nil {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": err.Error()})
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0})
}
func (s *Server) handlePair(w http.ResponseWriter, r *http.Request) {
if !s.auth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req struct {
Code string `json:"code"`
Name string `json:"name"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Code == "" {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": "配对码必填"})
return
}
if err := s.pair(req.Code, req.Name); err != nil {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": err.Error()})
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0})
}
// RandToken 生成随机 token
func RandToken() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
@@ -0,0 +1,136 @@
package localserver
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"testing"
"time"
"everypublish/shared/proto"
)
type fakeState struct {
online bool
chs []proto.Challenge
err error
}
func (f *fakeState) Online() bool { return f.online }
func (f *fakeState) SessionID() string { return "s1" }
func (f *fakeState) Challenges() []proto.Challenge { return f.chs }
func (f *fakeState) SolveChallenge(id, value string) error { return f.err }
func startTestServer(t *testing.T, state CoreState, pair PairFunc) (*Server, string) {
t.Helper()
token := RandToken()
s := New(state, pair, token, t.TempDir(), nil)
go func() { _ = s.Listen() }()
for i := 0; i < 50; i++ {
if s.Addr() != "" {
break
}
time.Sleep(10 * time.Millisecond)
}
if s.Addr() == "" {
t.Fatal("server did not start")
}
return s, token
}
func get(t *testing.T, base, path, token string) (int, map[string]interface{}) {
t.Helper()
req, _ := http.NewRequest("GET", "http://"+base+path, nil)
if token != "" {
req.Header.Set("X-Agent-Token", token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
var m map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&m)
return resp.StatusCode, m
}
func TestStatusAuth(t *testing.T) {
s, token := startTestServer(t, &fakeState{online: true}, nil)
code, _ := get(t, s.Addr(), "/status", "")
if code != 401 {
t.Fatalf("no token should be 401, got %d", code)
}
code, m := get(t, s.Addr(), "/status", token)
if code != 200 || m["online"] != true {
t.Fatalf("status wrong: %d %v", code, m)
}
}
func TestPairCallback(t *testing.T) {
var gotCode, gotName string
s, token := startTestServer(t, &fakeState{}, func(code, name string) error {
gotCode, gotName = code, name
return nil
})
// 未带 token → 401
req, _ := http.NewRequest("POST", "http://"+s.Addr()+"/pair", bytes.NewBufferString(`{"code":"AB12CD","name":"PC"}`))
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("pair: %v", err)
}
if resp.StatusCode != 401 {
t.Fatalf("pair without token should 401, got %d", resp.StatusCode)
}
resp.Body.Close()
// 带 token → 回调被调用
req2, _ := http.NewRequest("POST", "http://"+s.Addr()+"/pair", bytes.NewBufferString(`{"code":"AB12CD","name":"PC"}`))
req2.Header.Set("X-Agent-Token", token)
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
t.Fatalf("pair: %v", err)
}
resp2.Body.Close()
if gotCode != "AB12CD" || gotName != "PC" {
t.Fatalf("callback not invoked: %q %q", gotCode, gotName)
}
}
func TestPairErrorPropagates(t *testing.T) {
s, token := startTestServer(t, &fakeState{}, func(code, name string) error {
return errors.New("配对码无效")
})
req, _ := http.NewRequest("POST", "http://"+s.Addr()+"/pair", bytes.NewBufferString(`{"code":"X"}`))
req.Header.Set("X-Agent-Token", token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("pair: %v", err)
}
defer resp.Body.Close()
var m map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&m)
if m["code"] != float64(1) {
t.Fatalf("expect error code 1, got %v", m)
}
}
func TestSolve(t *testing.T) {
s, token := startTestServer(t, &fakeState{err: nil}, nil)
code, m := get(t, s.Addr(), "/challenges", token)
if code != 200 {
t.Fatalf("challenges: %d", code)
}
_ = m
req, _ := http.NewRequest("POST", "http://"+s.Addr()+"/challenges/9", bytes.NewBufferString(`{"value":"123456"}`))
req.Header.Set("X-Agent-Token", token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("solve: %v", err)
}
defer resp.Body.Close()
var m2 map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&m2)
if m2["code"] != float64(0) {
t.Fatalf("solve should succeed, got %v", m2)
}
}