diff --git a/internal/remote/client.go b/internal/remote/client.go new file mode 100644 index 0000000..46b044c --- /dev/null +++ b/internal/remote/client.go @@ -0,0 +1,55 @@ +package remote + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +type ClientConfig struct { + URL string + Token string + Timeout time.Duration +} + +type Client struct { + cfg ClientConfig +} + +func NewClient(cfg ClientConfig) Client { + if cfg.Timeout <= 0 { + cfg.Timeout = 5 * time.Second + } + return Client{cfg: cfg} +} + +func (c Client) Report(ctx context.Context, report StatusReport) error { + body, err := json.Marshal(report) + if err != nil { + return err + } + url := strings.TrimRight(c.cfg.URL, "/") + "/api/v1/status" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if c.cfg.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.cfg.Token) + } + + client := &http.Client{Timeout: c.cfg.Timeout} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return fmt.Errorf("remote report failed: %s", resp.Status) + } + return nil +} diff --git a/internal/remote/report.go b/internal/remote/report.go new file mode 100644 index 0000000..9ba809a --- /dev/null +++ b/internal/remote/report.go @@ -0,0 +1,36 @@ +package remote + +import ( + "crypto/sha256" + "encoding/hex" + "net" + "time" +) + +type StatusReport struct { + Hostname string `json:"hostname"` + IPs []string `json:"ips,omitempty"` + Agent string `json:"agent"` + CWD string `json:"cwd"` + Status string `json:"status"` + Event string `json:"event,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + LastUser string `json:"last_user,omitempty"` + LastAgent string `json:"last_agent,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +func SessionKey(hostname, primaryIP, cwd, agent string) string { + sum := sha256.Sum256([]byte(hostname + "\x00" + primaryIP + "\x00" + cwd + "\x00" + agent)) + return hex.EncodeToString(sum[:])[:32] +} + +func PrimaryIP(ips []string) string { + for _, s := range ips { + ip := net.ParseIP(s) + if ip != nil && ip.To4() != nil { + return ip.To4().String() + } + } + return "" +} diff --git a/internal/remote/report_test.go b/internal/remote/report_test.go new file mode 100644 index 0000000..46a107c --- /dev/null +++ b/internal/remote/report_test.go @@ -0,0 +1,49 @@ +package remote + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestSessionKeyStable(t *testing.T) { + k1 := SessionKey("host", "10.0.0.1", "/proj", "Cursor") + k2 := SessionKey("host", "10.0.0.1", "/proj", "Cursor") + if k1 != k2 || len(k1) != 32 { + t.Fatalf("key=%q len=%d", k1, len(k1)) + } +} + +func TestClientPostStatus(t *testing.T) { + var got StatusReport + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if r.URL.Path != "/api/v1/status" { + http.Error(w, "path", http.StatusNotFound) + return + } + if r.Header.Get("Authorization") != "Bearer secret" { + http.Error(w, "auth", http.StatusUnauthorized) + return + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + http.Error(w, "decode", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + c := NewClient(ClientConfig{URL: srv.URL, Token: "secret", Timeout: time.Second}) + err := c.Report(context.Background(), StatusReport{Hostname: "h", Agent: "Cursor", CWD: "/p", Status: "waiting"}) + if err != nil || got.Hostname != "h" { + t.Fatalf("err=%v got=%+v", err, got) + } +}