feat: ШАГ 1 — Bridge HTTP-сервер для браузерного расширения
- internal/core/bridge/ — лёгкий HTTP-сервер на 127.0.0.1 - POST /api/events — приём батча событий от расширения - GET /api/ping — healthcheck для расширения - X-Verstak-Secret — аутентификация по shared-secret - AutoGenPort — случайный порт если 9786 занят - config.BridgeConfig — порт, секрет, auto_gen_port - App: интеграция startBridge/stopBridge при open/close vault - bindings_bridge.go — BridgeInfo(), startBridge(), saveBridgeConfig() - Тесты: ping, auth, success, empty batch, secret gen, auto-port
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
// Package bridge provides a local HTTP API for browser extension
|
||||
// integration. The server is started when a vault is opened and
|
||||
// accepts events pushed by the browser extension.
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server is a lightweight HTTP server for browser extension events.
|
||||
type Server struct {
|
||||
mu sync.RWMutex
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
handler EventHandler
|
||||
secret string
|
||||
running bool
|
||||
}
|
||||
|
||||
// EventHandler is called when an event batch arrives.
|
||||
type EventHandler func(events []Event)
|
||||
|
||||
// Event represents a single browser event (page visit, note capture, etc.).
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // page_visit, note_capture, screenshot
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Domain string `json:"domain"`
|
||||
ActiveSeconds int `json:"active_seconds,omitempty"`
|
||||
TSStart string `json:"ts_start,omitempty"`
|
||||
TSEnd string `json:"ts_end,omitempty"`
|
||||
TS string `json:"ts,omitempty"`
|
||||
SelectedText string `json:"selected_text,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Screenshot string `json:"screenshot,omitempty"` // base64 data URI
|
||||
}
|
||||
|
||||
// EventBatch is the payload POSTed by the extension.
|
||||
type EventBatch struct {
|
||||
Version int `json:"version"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Events []Event `json:"events"`
|
||||
}
|
||||
|
||||
// Config holds bridge server settings.
|
||||
type Config struct {
|
||||
Port int `json:"port"`
|
||||
Secret string `json:"secret"` // auto-generated if empty
|
||||
AutoGenPort bool `json:"auto_gen_port"` // pick random available port
|
||||
}
|
||||
|
||||
// DefaultConfig returns sensible defaults.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Port: 9786,
|
||||
AutoGenPort: true,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateSecret creates a 32-char hex secret.
|
||||
func GenerateSecret() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// fallback to time-based
|
||||
return fmt.Sprintf("vs%x", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// NewServer creates a bridge server. If cfg.Secret is empty, one is generated.
|
||||
func NewServer(cfg Config, handler EventHandler) *Server {
|
||||
secret := cfg.Secret
|
||||
if secret == "" {
|
||||
secret = GenerateSecret()
|
||||
}
|
||||
return &Server{
|
||||
secret: secret,
|
||||
handler: handler,
|
||||
}
|
||||
}
|
||||
|
||||
// Port returns the actual listening port (useful when AutoGenPort is true).
|
||||
func (s *Server) Port() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.listener != nil {
|
||||
return s.listener.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Secret returns the shared secret.
|
||||
func (s *Server) Secret() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.secret
|
||||
}
|
||||
|
||||
// Running returns true if the server is listening.
|
||||
func (s *Server) Running() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.running
|
||||
}
|
||||
|
||||
// Start begins listening. Returns the actual port if AutoGenPort is used.
|
||||
func (s *Server) Start(cfg Config) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.running {
|
||||
// return existing port without re-reading (we already have the lock)
|
||||
if s.listener != nil {
|
||||
return s.listener.Addr().(*net.TCPAddr).Port, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", cfg.Port)
|
||||
if cfg.AutoGenPort {
|
||||
addr = "127.0.0.1:0"
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bridge listen: %w", err)
|
||||
}
|
||||
|
||||
s.listener = listener
|
||||
actualPort := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/ping", s.handlePing)
|
||||
mux.HandleFunc("/api/events", s.withAuth(s.handleEvents))
|
||||
|
||||
s.server = &http.Server{
|
||||
Handler: mux,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
s.running = true
|
||||
|
||||
go func() {
|
||||
if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[bridge] serve error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
secretPreview := s.secret
|
||||
if len(secretPreview) > 8 {
|
||||
secretPreview = secretPreview[:8] + "..."
|
||||
}
|
||||
log.Printf("[bridge] listening on 127.0.0.1:%d (secret=%s)", actualPort, secretPreview)
|
||||
return actualPort, nil
|
||||
}
|
||||
|
||||
// Stop shuts down the HTTP server.
|
||||
func (s *Server) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.running {
|
||||
return
|
||||
}
|
||||
|
||||
if s.server != nil {
|
||||
_ = s.server.Close()
|
||||
}
|
||||
s.server = nil
|
||||
s.listener = nil
|
||||
s.running = false
|
||||
log.Println("[bridge] stopped")
|
||||
}
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "ok",
|
||||
"port": s.Port(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
|
||||
var batch EventBatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&batch); err != nil {
|
||||
http.Error(w, fmt.Sprintf("bad request: %v", err), 400)
|
||||
return
|
||||
}
|
||||
|
||||
if batch.Version < 1 {
|
||||
http.Error(w, "unsupported version", 400)
|
||||
return
|
||||
}
|
||||
|
||||
if len(batch.Events) == 0 {
|
||||
w.WriteHeader(204)
|
||||
return
|
||||
}
|
||||
|
||||
if s.handler != nil {
|
||||
s.handler(batch.Events)
|
||||
}
|
||||
|
||||
log.Printf("[bridge] received %d events from device %s", len(batch.Events), batch.DeviceID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "ok",
|
||||
"count": len(batch.Events),
|
||||
"version": batch.Version,
|
||||
})
|
||||
}
|
||||
|
||||
// withAuth wraps a handler with shared-secret authentication.
|
||||
func (s *Server) withAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("X-Verstak-Secret")
|
||||
if auth != s.secret {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestServer_Ping(t *testing.T) {
|
||||
s := NewServer(DefaultConfig(), nil)
|
||||
port, err := s.Start(DefaultConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/ping", port))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
if body["status"] != "ok" {
|
||||
t.Errorf("expected status ok, got %v", body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_Events_AuthRequired(t *testing.T) {
|
||||
s := NewServer(Config{Secret: "test-secret"}, nil)
|
||||
port, err := s.Start(Config{Secret: "test-secret"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
// Without auth header
|
||||
body := EventBatch{Version: 1, DeviceID: "test", Events: []Event{{ID: "1", Type: "page_visit", URL: "https://example.com"}}}
|
||||
b, _ := json.Marshal(body)
|
||||
resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/api/events", port), "application/json", bytes.NewReader(b))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != 401 {
|
||||
t.Errorf("expected 401 without auth, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_Events_Success(t *testing.T) {
|
||||
received := make(chan []Event, 1)
|
||||
s := NewServer(Config{Secret: "test-secret"}, func(evts []Event) {
|
||||
received <- evts
|
||||
})
|
||||
port, err := s.Start(Config{Secret: "test-secret"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
events := []Event{
|
||||
{ID: "1", Type: "page_visit", URL: "https://example.com", Title: "Example", Domain: "example.com", ActiveSeconds: 120},
|
||||
}
|
||||
batch := EventBatch{Version: 1, DeviceID: "test-device", Events: events}
|
||||
b, _ := json.Marshal(batch)
|
||||
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1:%d/api/events", port), bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Verstak-Secret", "test-secret")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
select {
|
||||
case evts := <-received:
|
||||
if len(evts) != 1 {
|
||||
t.Errorf("expected 1 event, got %d", len(evts))
|
||||
}
|
||||
if evts[0].ID != "1" {
|
||||
t.Errorf("expected event ID '1', got %s", evts[0].ID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timeout waiting for event handler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_Events_EmptyBatch(t *testing.T) {
|
||||
s := NewServer(Config{Secret: "s"}, nil)
|
||||
port, err := s.Start(Config{Secret: "s"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
batch := EventBatch{Version: 1, DeviceID: "test", Events: []Event{}}
|
||||
b, _ := json.Marshal(batch)
|
||||
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1:%d/api/events", port), bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Verstak-Secret", "s")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != 204 {
|
||||
t.Errorf("expected 204 for empty batch, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSecret(t *testing.T) {
|
||||
s1 := GenerateSecret()
|
||||
s2 := GenerateSecret()
|
||||
if s1 == s2 {
|
||||
t.Error("secrets should be unique")
|
||||
}
|
||||
if len(s1) != 32 {
|
||||
t.Errorf("expected 32 chars, got %d", len(s1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_AutoPort(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
s := NewServer(cfg, nil)
|
||||
port, err := s.Start(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
if port == 0 {
|
||||
t.Error("expected non-zero port")
|
||||
}
|
||||
|
||||
if s.Port() != port {
|
||||
t.Errorf("Port() = %d, want %d", s.Port(), port)
|
||||
}
|
||||
|
||||
if !s.Running() {
|
||||
t.Error("expected server running")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user