package api import ( "crypto/rand" "encoding/base64" "encoding/json" "fmt" "image/color" "image/png" "log" "net/http" "strings" "sync" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" qrcode "github.com/skip2/go-qrcode" "golang.org/x/crypto/curve25519" ) // ── types ───────────────────────────────────────────────────────────────────── // HopStatus tracks one agent's progress in a trace session. type HopStatus string const ( HopPending HopStatus = "pending" HopReady HopStatus = "ready" HopFailed HopStatus = "failed" ) // HopInfo records what we know about each hop in the chain. type HopInfo struct { AgentID string `json:"agent_id"` AgentName string `json:"agent_name"` ExternalIP string `json:"external_ip"` Port int `json:"port"` PublicKey string `json:"public_key"` PrivateKey string `json:"-"` // never sent to client LocalAddr string `json:"local_addr"` Status HopStatus `json:"status"` Error string `json:"error,omitempty"` } // TraceSession holds all state for one active VPN session. type TraceSession struct { ID string `json:"id"` AgentIDs []string `json:"agent_ids"` Hops []*HopInfo `json:"hops"` Ready bool `json:"ready"` Error string `json:"error,omitempty"` CreatedAt time.Time `json:"created_at"` // Client WireGuard keypair — used to build the QR config. clientPrivKey string clientPubKey string } // PathTracerHandler manages on-demand WireGuard chain sessions. type PathTracerHandler struct { hub *WSHub mu sync.Mutex sessions map[string]*TraceSession } func NewPathTracerHandler(hub *WSHub) *PathTracerHandler { return &PathTracerHandler{ hub: hub, sessions: make(map[string]*TraceSession), } } // ── HTTP handlers ───────────────────────────────────────────────────────────── // POST /api/v1/pathtrace/start // Body: {"agent_ids": ["id1","id2",...]} func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) { var req struct { AgentIDs []string `json:"agent_ids"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.AgentIDs) == 0 { http.Error(w, "agent_ids required", http.StatusBadRequest) return } if len(req.AgentIDs) > 3 { http.Error(w, "max 3 hops supported", http.StatusBadRequest) return } clientPriv, clientPub, err := generateServerWGKeyPair() if err != nil { http.Error(w, "keygen failed", http.StatusInternalServerError) return } sess := &TraceSession{ ID: uuid.New().String(), AgentIDs: req.AgentIDs, CreatedAt: time.Now(), clientPrivKey: clientPriv, clientPubKey: clientPub, } // Build hops with placeholder names (agent name lookup below). for i, id := range req.AgentIDs { sess.Hops = append(sess.Hops, &HopInfo{ AgentID: id, AgentName: fmt.Sprintf("hop-%d", i+1), LocalAddr: fmt.Sprintf("10.66.0.%d/24", i+2), // .2, .3, .4 Port: 51820, Status: HopPending, }) } h.mu.Lock() h.sessions[sess.ID] = sess h.mu.Unlock() // Orchestrate asynchronously so the HTTP response returns quickly. go h.orchestrate(sess) writeJSON(w, map[string]interface{}{ "session_id": sess.ID, "hops": sess.Hops, }) } // GET /api/v1/pathtrace/{id}/status func (h *PathTracerHandler) Status(w http.ResponseWriter, r *http.Request) { sess := h.getSession(chi.URLParam(r, "id")) if sess == nil { http.Error(w, "session not found", http.StatusNotFound) return } h.mu.Lock() defer h.mu.Unlock() writeJSON(w, map[string]interface{}{ "session_id": sess.ID, "ready": sess.Ready, "error": sess.Error, "hops": sess.Hops, }) } // GET /api/v1/pathtrace/{id}/qr func (h *PathTracerHandler) QR(w http.ResponseWriter, r *http.Request) { sess := h.getSession(chi.URLParam(r, "id")) if sess == nil { http.Error(w, "session not found", http.StatusNotFound) return } h.mu.Lock() ready := sess.Ready h.mu.Unlock() if !ready { http.Error(w, "session not ready yet", http.StatusAccepted) return } cfg := h.buildClientConfig(sess) // Return format: ?format=png → PNG image, default → JSON with text+png. if r.URL.Query().Get("format") == "png" { qr, err := qrcode.New(cfg, qrcode.High) if err != nil { http.Error(w, "qr generation failed", http.StatusInternalServerError) return } qr.BackgroundColor = color.Black qr.ForegroundColor = color.RGBA{R: 0, G: 255, B: 170, A: 255} // neon green img := qr.Image(400) w.Header().Set("Content-Type", "image/png") _ = png.Encode(w, img) return } qr, err := qrcode.Encode(cfg, qrcode.High, 300) if err != nil { http.Error(w, "qr generation failed", http.StatusInternalServerError) return } writeJSON(w, map[string]interface{}{ "config": cfg, "qr_png_b64": base64.StdEncoding.EncodeToString(qr), }) } // DELETE /api/v1/pathtrace/{id} func (h *PathTracerHandler) Delete(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") sess := h.getSession(id) if sess == nil { http.Error(w, "session not found", http.StatusNotFound) return } // Tell all agents to tear down. for _, hop := range sess.Hops { _ = h.hub.writeAgentJSON(hop.AgentID, Message{ Type: "command", Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}), }) } h.mu.Lock() delete(h.sessions, id) h.mu.Unlock() writeJSON(w, map[string]interface{}{"ok": true}) } // ── orchestration ───────────────────────────────────────────────────────────── func (h *PathTracerHandler) orchestrate(sess *TraceSession) { log.Printf("[pathtrace] session %s: orchestrating %d hop(s)", sess.ID[:8], len(sess.Hops)) // Phase 1: send wg_setup to every hop in parallel, collect keypairs + IPs. type setupResp struct { hop *HopInfo pub string ip string port int err string } results := make(chan setupResp, len(sess.Hops)) for _, hop := range sess.Hops { hop := hop ch := h.hub.AwaitCommandResult(hop.AgentID, "wg_setup") err := h.hub.writeAgentJSON(hop.AgentID, Message{ Type: "command", Payload: mustMarshal(map[string]interface{}{"action": "wg_setup"}), }) if err != nil { h.hub.CancelAwait(hop.AgentID, "wg_setup") results <- setupResp{hop: hop, err: "agent not connected: " + err.Error()} continue } go func() { select { case payload := <-ch: msgStr, _ := payload["message"].(string) // message is JSON-encoded WGSetupResult var res struct { PublicKey string `json:"public_key"` ExternalIP string `json:"external_ip"` ExternalPort int `json:"external_port"` Error string `json:"error"` } if jerr := json.Unmarshal([]byte(msgStr), &res); jerr != nil { results <- setupResp{hop: hop, err: "parse error: " + jerr.Error()} return } if res.Error != "" { results <- setupResp{hop: hop, err: res.Error} return } p := res.ExternalPort if p == 0 { p = 51820 } // If UPnP failed, fall back to the IP the server saw. ip := res.ExternalIP if ip == "" { if agent := h.hub.getAgentConnByID(hop.AgentID); agent != nil { ip = hop.ExternalIP // pre-filled below } } results <- setupResp{hop: hop, pub: res.PublicKey, ip: ip, port: p} case <-time.After(20 * time.Second): results <- setupResp{hop: hop, err: "timeout waiting for wg_setup response"} } }() } // Collect Phase 1 results. for range sess.Hops { r := <-results h.mu.Lock() if r.err != "" { r.hop.Status = HopFailed r.hop.Error = r.err sess.Error = "hop " + r.hop.AgentID[:8] + " failed: " + r.err } else { r.hop.PublicKey = r.pub r.hop.ExternalIP = r.ip r.hop.Port = r.port r.hop.Status = HopReady } h.mu.Unlock() } // If any hop failed at setup, abort. h.mu.Lock() anyFailed := false for _, hop := range sess.Hops { if hop.Status == HopFailed { anyFailed = true break } } h.mu.Unlock() if anyFailed { log.Printf("[pathtrace] session %s: setup failed", sess.ID[:8]) return } // Phase 2: send wg_configure to each hop. // Build per-hop configs: // - Last hop: peer = none (it's the exit), just IP forwarding // - Middle hops: peer = next hop // - First hop: peer = next hop, or none if single-hop (client connects directly) // // The CLIENT config always points to the FIRST hop. type cfgResp struct { hop *HopInfo err string } cfgResults := make(chan cfgResp, len(sess.Hops)) for i, hop := range sess.Hops { hop := hop i := i payload := map[string]interface{}{ "session_id": sess.ID, "private_key": "", // agent uses its own generated key "local_address": hop.LocalAddr, "listen_port": hop.Port, "enable_ip_forwarding": true, } // Peers for this hop: only for relay hops (all except the exit/last hop). if i < len(sess.Hops)-1 { nextHop := sess.Hops[i+1] payload["peers"] = []map[string]interface{}{ { "public_key": nextHop.PublicKey, "endpoint": fmt.Sprintf("%s:%d", nextHop.ExternalIP, nextHop.Port), "allowed_ips": "0.0.0.0/0", "persistent_keepalive": 25, }, } } else { payload["peers"] = []map[string]interface{}{} } dataJSON, _ := json.Marshal(payload) ch := h.hub.AwaitCommandResult(hop.AgentID, "wg_configure") _ = h.hub.writeAgentJSON(hop.AgentID, Message{ Type: "command", Payload: mustMarshal(map[string]interface{}{ "action": "wg_configure", "data": string(dataJSON), }), }) go func() { select { case p := <-ch: success, _ := p["success"].(bool) if !success { msg, _ := p["message"].(string) cfgResults <- cfgResp{hop: hop, err: msg} return } cfgResults <- cfgResp{hop: hop} case <-time.After(60 * time.Second): cfgResults <- cfgResp{hop: hop, err: "timeout waiting for wg_configure"} } }() } for range sess.Hops { r := <-cfgResults h.mu.Lock() if r.err != "" { r.hop.Status = HopFailed r.hop.Error = r.err if sess.Error == "" { sess.Error = "configure failed on " + r.hop.AgentID[:8] + ": " + r.err } } h.mu.Unlock() } h.mu.Lock() allReady := true for _, hop := range sess.Hops { if hop.Status != HopReady { allReady = false } } if allReady { sess.Ready = true } h.mu.Unlock() log.Printf("[pathtrace] session %s: orchestration complete, ready=%v", sess.ID[:8], allReady) } // buildClientConfig generates the WireGuard config text the user scans/imports. func (h *PathTracerHandler) buildClientConfig(sess *TraceSession) string { h.mu.Lock() defer h.mu.Unlock() var sb strings.Builder sb.WriteString("[Interface]\n") sb.WriteString("PrivateKey = " + sess.clientPrivKey + "\n") sb.WriteString("Address = 10.66.0.1/24\n") sb.WriteString("DNS = 1.1.1.1\n\n") // Phone always connects to the first hop. first := sess.Hops[0] sb.WriteString("[Peer]\n") sb.WriteString("PublicKey = " + first.PublicKey + "\n") sb.WriteString(fmt.Sprintf("Endpoint = %s:%d\n", first.ExternalIP, first.Port)) sb.WriteString("AllowedIPs = 0.0.0.0/0\n") sb.WriteString("PersistentKeepalive = 25\n") return sb.String() } // ── helpers ─────────────────────────────────────────────────────────────────── func (h *PathTracerHandler) getSession(id string) *TraceSession { h.mu.Lock() defer h.mu.Unlock() return h.sessions[id] } // getAgentConnByID returns the AgentConnection for the given ID (nil if offline). func (h *WSHub) getAgentConnByID(id string) *AgentConnection { h.mu.RLock() defer h.mu.RUnlock() return h.agents[id] } func generateServerWGKeyPair() (privB64, pubB64 string, err error) { var priv [32]byte if _, err = rand.Read(priv[:]); err != nil { return } priv[0] &= 248 priv[31] &= 127 priv[31] |= 64 var pub [32]byte curve25519.ScalarBaseMult(&pub, &priv) privB64 = base64.StdEncoding.EncodeToString(priv[:]) pubB64 = base64.StdEncoding.EncodeToString(pub[:]) return }