156 lines
4.3 KiB
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()
|
|
}
|
|
}
|