Complete Phase 5B pairing flow

Add agent pairing token lifecycle, APK download serving, MemoriC Google sign-in and pairing UX polish, and update implementation plans for Phase 5C.
This commit is contained in:
2026-05-21 22:37:01 +08:00
parent 2429c53985
commit 08cac42890
13 changed files with 856 additions and 180 deletions

View File

@@ -1,6 +1,7 @@
package main
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
@@ -41,6 +42,7 @@ type Config struct {
BindHost string `json:"bind_host"`
BindPort int `json:"bind_port"`
AllowedSubnets []string `json:"allowed_subnets"`
AuthMode string `json:"auth_mode"`
PairingToken string `json:"pairing_token"`
}
@@ -76,6 +78,8 @@ type StatusResponse struct {
StorageDir string `json:"storage_dir"`
BindAddr string `json:"bind_addr"`
AllowedSubnets []string `json:"allowed_subnets"`
AuthMode string `json:"auth_mode"`
PairingReady bool `json:"pairing_ready"`
UploadsHandled int64 `json:"uploads_handled"`
FetchesHandled int64 `json:"fetches_handled"`
LastError string `json:"last_error,omitempty"`
@@ -108,6 +112,18 @@ type MigrationStatus struct {
CompletedAt string `json:"completed_at,omitempty"`
}
type PairingStatusResponse struct {
Status string `json:"status"`
AuthMode string `json:"auth_mode"`
PairingReady bool `json:"pairing_ready"`
CanReveal bool `json:"can_reveal"`
}
type PairingTokenResponse struct {
Status string `json:"status"`
Token string `json:"token"`
}
func defaultStorageDir() string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
@@ -130,6 +146,7 @@ func loadConfig() Config {
BindHost: "0.0.0.0",
BindPort: 5051,
AllowedSubnets: append([]string(nil), defaultAllowedSubnets...),
AuthMode: "pairing_token",
}
if env := strings.TrimSpace(os.Getenv("STORAGE_DIR")); env != "" {
@@ -182,12 +199,38 @@ func loadConfig() Config {
if len(fileCfg.AllowedSubnets) > 0 {
cfg.AllowedSubnets = fileCfg.AllowedSubnets
}
if strings.TrimSpace(fileCfg.AuthMode) != "" {
cfg.AuthMode = strings.TrimSpace(fileCfg.AuthMode)
}
if strings.TrimSpace(fileCfg.PairingToken) != "" {
cfg.PairingToken = strings.TrimSpace(fileCfg.PairingToken)
}
return cfg
}
func generatePairingToken() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
func persistInitialConfig(cfg Config) error {
return writeConfigFile(configPath(), cfg)
}
func writeConfigFile(path string, cfg Config) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(b, '\n'), 0600)
}
func parseAllowedSubnets(values []string) ([]netip.Prefix, error) {
prefixes := make([]netip.Prefix, 0, len(values))
for _, raw := range values {
@@ -239,6 +282,8 @@ func (s *AgentState) status(allowed []string) StatusResponse {
StorageDir: s.storageDir,
BindAddr: s.bindAddr,
AllowedSubnets: append([]string(nil), allowed...),
AuthMode: s.config.AuthMode,
PairingReady: strings.TrimSpace(s.token) != "",
UploadsHandled: s.uploadsHandled,
FetchesHandled: s.fetchesHandled,
LastError: s.lastError,
@@ -272,14 +317,31 @@ func (s *AgentState) persistConfig() error {
path := s.configPath
s.mu.RUnlock()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
return writeConfigFile(path, cfg)
}
func (s *AgentState) persistConfigWithPairingToken(token string) error {
s.mu.RLock()
cfg := s.config
cfg.StorageDir = s.storageDir
cfg.PairingToken = token
if strings.TrimSpace(cfg.AuthMode) == "" {
cfg.AuthMode = "pairing_token"
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
path := s.configPath
s.mu.RUnlock()
return writeConfigFile(path, cfg)
}
func (s *AgentState) setPairingToken(token string) {
s.mu.Lock()
s.token = token
s.config.PairingToken = token
if strings.TrimSpace(s.config.AuthMode) == "" {
s.config.AuthMode = "pairing_token"
}
return os.WriteFile(path, append(b, '\n'), 0644)
s.mu.Unlock()
}
func validateStoragePath(raw string) StorageValidation {
@@ -538,6 +600,23 @@ func (s *AgentState) clientAllowed(r *http.Request) bool {
return false
}
func requestIP(r *http.Request) (netip.Addr, bool) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip, err := netip.ParseAddr(strings.Trim(host, "[]"))
if err != nil {
return netip.Addr{}, false
}
return ip, true
}
func (s *AgentState) clientLoopback(r *http.Request) bool {
ip, ok := requestIP(r)
return ok && ip.IsLoopback()
}
func (s *AgentState) requireSubnet(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.clientAllowed(r) {
@@ -568,6 +647,17 @@ func (s *AgentState) requireMutableAuth(next http.HandlerFunc) http.HandlerFunc
}
}
func (s *AgentState) pairingStatus(r *http.Request) PairingStatusResponse {
s.mu.RLock()
defer s.mu.RUnlock()
return PairingStatusResponse{
Status: "ok",
AuthMode: s.config.AuthMode,
PairingReady: strings.TrimSpace(s.token) != "",
CanReveal: s.clientLoopback(r),
}
}
func sanitizeFilename(name string) (string, error) {
cleaned := strings.TrimSpace(name)
if cleaned == "" {
@@ -596,6 +686,24 @@ func (s *AgentState) saveUpload(filename string, data []byte) error {
return nil
}
func candidateAPKPaths() []string {
paths := []string{"memoric.apk"}
if exe, err := os.Executable(); err == nil && exe != "" {
paths = append(paths, filepath.Join(filepath.Dir(exe), "memoric.apk"))
}
return paths
}
func findAPKPath() (string, bool) {
for _, path := range candidateAPKPaths() {
info, err := os.Stat(path)
if err == nil && !info.IsDir() {
return path, true
}
}
return "", false
}
func (s *AgentState) startHTTPServer(allowedSubnets []string) {
mux := http.NewServeMux()
@@ -607,6 +715,53 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
s.writeJSON(w, http.StatusOK, s.status(allowedSubnets))
}))
mux.HandleFunc("/api/pairing/status", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
s.writeJSON(w, http.StatusOK, s.pairingStatus(r))
}))
mux.HandleFunc("/api/pairing/reveal", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if !s.clientLoopback(r) {
s.writeJSON(w, http.StatusForbidden, map[string]string{"error": "pairing token can only be revealed from localhost"})
return
}
s.mu.RLock()
token := s.token
s.mu.RUnlock()
if strings.TrimSpace(token) == "" {
s.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "pairing token is not configured"})
return
}
s.writeJSON(w, http.StatusOK, PairingTokenResponse{Status: "ok", Token: token})
}))
mux.HandleFunc("/api/pairing/rotate", s.requireSubnet(s.requireMutableAuth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
token, err := generatePairingToken()
if err != nil {
s.setError(err)
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to generate pairing token"})
return
}
if err := s.persistConfigWithPairingToken(token); err != nil {
s.setError(err)
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to persist pairing token"})
return
}
s.setPairingToken(token)
s.writeJSON(w, http.StatusOK, PairingTokenResponse{Status: "ok", Token: token})
})))
mux.HandleFunc("/api/storage/validate", s.requireSubnet(s.requireMutableAuth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
@@ -762,6 +917,21 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
_, _ = w.Write(b)
})))
mux.HandleFunc("/download/memoric.apk", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
path, ok := findAPKPath()
if !ok {
s.writeJSON(w, http.StatusNotFound, map[string]string{"error": "memoric.apk not installed on agent"})
return
}
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
w.Header().Set("Content-Disposition", `attachment; filename="memoric.apk"`)
http.ServeFile(w, r, path)
}))
mux.HandleFunc("/", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
@@ -786,11 +956,21 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
</head>
<body>
<h1>MemoriC Agent</h1>
<div class="box">
<h3>Android App</h3>
<div class="row"><a href="/download/memoric.apk">Download MemoriC APK</a></div>
</div>
<div class="box">
<h3>Status</h3>
<div id="status">Loading...</div>
<div class="row"><button id="refresh">Refresh</button></div>
</div>
<div class="box">
<h3>Pairing</h3>
<div id="pairing">Loading...</div>
<div class="row"><button id="show-token">Show Token</button> <button id="rotate-token">Rotate Token</button></div>
<pre id="pairing-result"></pre>
</div>
<div class="box">
<h3>Storage Settings</h3>
<div class="row"><label>Storage Directory</label></div>
@@ -805,9 +985,19 @@ async function loadStatus() {
document.getElementById('status').innerText =
'Broker: ' + (data.broker_connected ? 'connected' : 'disconnected') +
'\nBind: ' + data.bind_addr +
'\nAuth: ' + data.auth_mode + ' (' + (data.pairing_ready ? 'paired' : 'not ready') + ')' +
'\nStorage: ' + data.storage_dir +
'\nLast Error: ' + (data.last_error || '-');
document.getElementById('path').value = data.storage_dir || '';
await loadPairing();
}
async function loadPairing() {
const res = await fetch('/api/pairing/status');
const data = await res.json();
document.getElementById('pairing').innerText =
'Mode: ' + data.auth_mode +
'\nReady: ' + (data.pairing_ready ? 'yes' : 'no') +
'\nLocal token reveal: ' + (data.can_reveal ? 'available' : 'localhost only');
}
async function postJSON(url, body) {
const token = prompt('Enter pairing token');
@@ -820,6 +1010,21 @@ async function postJSON(url, body) {
document.getElementById('result').innerText = JSON.stringify(data, null, 2);
await loadStatus();
}
async function showToken() {
const res = await fetch('/api/pairing/reveal');
const data = await res.json();
document.getElementById('pairing-result').innerText = JSON.stringify(data, null, 2);
}
async function rotateToken() {
const token = prompt('Enter current pairing token');
const res = await fetch('/api/pairing/rotate', {
method: 'POST',
headers: {'Authorization': 'Bearer ' + (token || '')}
});
const data = await res.json();
document.getElementById('pairing-result').innerText = JSON.stringify(data, null, 2);
await loadStatus();
}
async function loadMigration() {
const res = await fetch('/api/storage/migration');
const data = await res.json();
@@ -829,6 +1034,8 @@ async function loadMigration() {
}
}
document.getElementById('refresh').onclick = loadStatus;
document.getElementById('show-token').onclick = showToken;
document.getElementById('rotate-token').onclick = rotateToken;
document.getElementById('validate').onclick = () => postJSON('/api/storage/validate', {path: document.getElementById('path').value});
document.getElementById('change').onclick = () => postJSON('/api/storage/change', {path: document.getElementById('path').value});
loadStatus();
@@ -889,8 +1096,106 @@ func getLANEndpoints(bindHost string, bindPort int) []string {
return out
}
func (s *AgentState) runBrokerSession(cfg Config) {
wsURL := brokerURL
if s.token != "" {
wsURL = brokerURL + "?token=" + url.QueryEscape(s.token)
}
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
s.setConnected(false)
s.setError(err)
log.Printf("broker connect failed: %v", err)
return
}
defer conn.Close()
s.setConnected(true)
log.Println("connected to broker at", wsURL)
hello, _ := json.Marshal(Message{
Type: "agent_hello",
LANEndpoints: getLANEndpoints(cfg.BindHost, cfg.BindPort),
BindPort: cfg.BindPort,
})
if err := conn.WriteMessage(websocket.TextMessage, hello); err != nil {
s.setConnected(false)
s.setError(err)
log.Printf("failed sending agent_hello: %v", err)
return
}
for {
_, raw, err := conn.ReadMessage()
if err != nil {
s.setConnected(false)
s.setError(err)
log.Printf("broker read failed: %v", err)
return
}
var msg Message
if err := json.Unmarshal(raw, &msg); err != nil {
s.setError(err)
log.Println("bad message:", err)
continue
}
switch msg.Type {
case "upload":
data, err := base64.StdEncoding.DecodeString(msg.Data)
if err != nil {
s.setError(err)
log.Println("decode error:", err)
continue
}
if err := s.saveUpload(msg.Filename, data); err != nil {
s.setError(err)
log.Println("write error:", err)
continue
}
log.Printf("saved %s (%d bytes)", msg.Filename, len(data))
case "fetch":
path := filepath.Join(s.getStorageDir(), msg.Filename)
data, err := os.ReadFile(path)
if err != nil {
log.Printf("fetch %s: not found", msg.Filename)
resp, _ := json.Marshal(Message{
Type: "fetch_response",
RequestID: msg.RequestID,
Error: "not found",
})
conn.WriteMessage(websocket.TextMessage, resp)
continue
}
resp, _ := json.Marshal(Message{
Type: "fetch_response",
RequestID: msg.RequestID,
Data: base64.StdEncoding.EncodeToString(data),
})
conn.WriteMessage(websocket.TextMessage, resp)
s.incFetch()
log.Printf("served %s (%d bytes)", msg.Filename, len(data))
}
}
}
func main() {
cfg := loadConfig()
if strings.TrimSpace(cfg.AuthMode) == "" {
cfg.AuthMode = "pairing_token"
}
if strings.TrimSpace(cfg.PairingToken) == "" {
token, err := generatePairingToken()
if err != nil {
log.Fatalf("generate pairing token: %v", err)
}
cfg.PairingToken = token
if err := persistInitialConfig(cfg); err != nil {
log.Fatalf("persist generated pairing token: %v", err)
}
log.Printf("generated pairing token and saved config at %s", configPath())
}
prefixes, err := parseAllowedSubnets(cfg.AllowedSubnets)
if err != nil {
log.Fatalf("subnet config error: %v", err)
@@ -918,78 +1223,8 @@ func main() {
go state.startHTTPServer(cfg.AllowedSubnets)
wsURL := brokerURL
if state.token != "" {
wsURL = brokerURL + "?token=" + url.QueryEscape(state.token)
}
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
log.Fatal("connect:", err)
}
defer conn.Close()
state.setConnected(true)
log.Println("connected to broker at", wsURL)
hello, _ := json.Marshal(Message{
Type: "agent_hello",
LANEndpoints: getLANEndpoints(cfg.BindHost, cfg.BindPort),
BindPort: cfg.BindPort,
})
if err := conn.WriteMessage(websocket.TextMessage, hello); err != nil {
state.setError(err)
log.Printf("failed sending agent_hello: %v", err)
}
for {
_, raw, err := conn.ReadMessage()
if err != nil {
state.setConnected(false)
state.setError(err)
log.Fatal("read:", err)
}
var msg Message
if err := json.Unmarshal(raw, &msg); err != nil {
state.setError(err)
log.Println("bad message:", err)
continue
}
switch msg.Type {
case "upload":
data, err := base64.StdEncoding.DecodeString(msg.Data)
if err != nil {
state.setError(err)
log.Println("decode error:", err)
continue
}
if err := state.saveUpload(msg.Filename, data); err != nil {
state.setError(err)
log.Println("write error:", err)
continue
}
log.Printf("saved %s (%d bytes)", msg.Filename, len(data))
case "fetch":
path := filepath.Join(state.getStorageDir(), msg.Filename)
data, err := os.ReadFile(path)
if err != nil {
log.Printf("fetch %s: not found", msg.Filename)
resp, _ := json.Marshal(Message{
Type: "fetch_response",
RequestID: msg.RequestID,
Error: "not found",
})
conn.WriteMessage(websocket.TextMessage, resp)
continue
}
resp, _ := json.Marshal(Message{
Type: "fetch_response",
RequestID: msg.RequestID,
Data: base64.StdEncoding.EncodeToString(data),
})
conn.WriteMessage(websocket.TextMessage, resp)
state.incFetch()
log.Printf("served %s (%d bytes)", msg.Filename, len(data))
}
state.runBrokerSession(cfg)
time.Sleep(5 * time.Second)
}
}