MWSE/internal/bridge/bridge.go

156 lines
4.3 KiB
Go

// Package bridge implements the 3rd-party server bridge (#46): it lets an external
// application server talk to MWSE over plain HTTPS (get/post) without speaking
// WebSocket, and lets MWSE delegate connection approval to that application
// server.
//
// Three pieces, each independently testable:
//
// - Inbox : a bounded queue of client->application messages the app drains
// by polling an HTTP endpoint.
// - HTTPApprover : asks the application "Connect?" for each new client and accepts
// only on an explicit approval (fail-closed).
// - HTTPTrigger : pushes a suit-notification reply (#44) to the application,
// so the app is told the moment a reply arrives instead of polling.
package bridge
import (
"bytes"
"context"
"encoding/json"
"net/http"
"sync"
"time"
"git.saqut.com/saqut/mwse/internal/notify"
)
// Message is one client->application message held in the inbox.
type Message struct {
From string `json:"from"`
Pack any `json:"pack"`
At time.Time `json:"at"`
}
// Inbox is a bounded FIFO of messages awaiting collection by the application
// server. It is bounded so a never-polling application cannot make it grow without
// limit (oldest messages are dropped first).
type Inbox struct {
mu sync.Mutex
q []Message
max int
}
// NewInbox returns an inbox holding up to max messages (<=0 uses 10000).
func NewInbox(max int) *Inbox {
if max <= 0 {
max = 10000
}
return &Inbox{max: max}
}
// Push appends a message from a client, dropping the oldest if the cap is hit.
func (i *Inbox) Push(from string, pack any) {
i.mu.Lock()
defer i.mu.Unlock()
if len(i.q) >= i.max {
drop := len(i.q) - i.max + 1
i.q = i.q[drop:]
}
i.q = append(i.q, Message{From: from, Pack: pack, At: time.Now()})
}
// Drain returns and clears all queued messages.
func (i *Inbox) Drain() []Message {
i.mu.Lock()
defer i.mu.Unlock()
if len(i.q) == 0 {
return nil
}
out := i.q
i.q = nil
return out
}
// Len reports how many messages are queued.
func (i *Inbox) Len() int {
i.mu.Lock()
defer i.mu.Unlock()
return len(i.q)
}
// HTTPApprover delegates connection approval to an application server. For each
// new client it POSTs {id, meta} to URL and accepts only if the response is 200
// with a JSON body {"approve": true}. Any error (unreachable app, non-200,
// malformed body) denies the connection — fail-closed, matching "approves or it
// is rejected".
type HTTPApprover struct {
URL string
Client *http.Client
}
// NewHTTPApprover builds an approver with a sensible request timeout.
func NewHTTPApprover(url string, timeout time.Duration) *HTTPApprover {
if timeout <= 0 {
timeout = 3 * time.Second
}
return &HTTPApprover{URL: url, Client: &http.Client{Timeout: timeout}}
}
// Approve implements ws.Approver.
func (a *HTTPApprover) Approve(id string, meta map[string]any) bool {
body, _ := json.Marshal(map[string]any{"id": id, "meta": meta})
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, a.URL, bytes.NewReader(body))
if err != nil {
return false
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.Client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false
}
var out struct {
Approve bool `json:"approve"`
}
if json.NewDecoder(resp.Body).Decode(&out) != nil {
return false
}
return out.Approve
}
// HTTPTrigger pushes suit-notification replies (#44) to the application server.
// It structurally satisfies services.NotifyTrigger.
type HTTPTrigger struct {
URL string
Client *http.Client
}
// NewHTTPTrigger builds a trigger with a sensible request timeout.
func NewHTTPTrigger(url string, timeout time.Duration) *HTTPTrigger {
if timeout <= 0 {
timeout = 3 * time.Second
}
return &HTTPTrigger{URL: url, Client: &http.Client{Timeout: timeout}}
}
// NotifyReplied posts the reply to the application server (best effort).
func (t *HTTPTrigger) NotifyReplied(n notify.Notification) {
body, _ := json.Marshal(map[string]any{
"trace": n.Trace,
"from": n.From,
"to": n.To,
"reply": n.Reply,
})
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, t.URL, bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
if resp, err := t.Client.Do(req); err == nil {
resp.Body.Close()
}
}