fix: WebhookPing csv tags + JSON payload column; DeviceChecks struct; fix Authenticate deadlock

Co-authored-by: coolaj86 <122831+coolaj86@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-02 06:56:04 +00:00
parent bf3c4a48e4
commit a708352634
3 changed files with 92 additions and 92 deletions

View File

@ -376,9 +376,10 @@ func (a *Auth) Authenticate(name, secret string) (auth.BasicPrinciple, error) {
} }
a.mux.Lock() a.mux.Lock()
defer a.mux.Unlock()
nameID := a.nameCacheID(name) nameID := a.nameCacheID(name)
c, ok := a.hashedCredentials[nameID] c, ok := a.hashedCredentials[nameID]
a.mux.Unlock()
if ok { if ok {
if err := c.Verify(name, secret); err != nil { if err := c.Verify(name, secret); err != nil {
return nil, err return nil, err

View File

@ -4,13 +4,11 @@ import (
"context" "context"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"os" "os"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -37,23 +35,6 @@ var pingWriter jsonl.Writer
var smsAuth *csvauth.Auth var smsAuth *csvauth.Auth
// pingRow is a flat CSV row for a ping event (x-* headers omitted, health fields inlined).
type pingRow struct {
DeviceID string `csv:"deviceId"`
Event string `csv:"event"`
ID string `csv:"id"`
WebhookID string `csv:"webhookId"`
PingedAt time.Time `csv:"pingedAt"`
Status string `csv:"status"`
Version string `csv:"version"`
BatteryLevel float64 `csv:"batteryLevel"`
BatteryCharging float64 `csv:"batteryCharging"`
ConnectionStatus float64 `csv:"connectionStatus"`
ConnectionTransport float64 `csv:"connectionTransport"`
ConnectionCellular float64 `csv:"connectionCellular"`
MessagesFailed float64 `csv:"messagesFailed"`
}
func main() { func main() {
jsonf.Indent = 3 jsonf.Indent = 3
@ -127,68 +108,68 @@ func main() {
mux.Handle("PUT /", LogRequest(http.HandlerFunc(HandleOK))) mux.Handle("PUT /", LogRequest(http.HandlerFunc(HandleOK)))
mux.Handle("DELETE /", LogRequest(http.HandlerFunc(HandleOK))) mux.Handle("DELETE /", LogRequest(http.HandlerFunc(HandleOK)))
// Protected routes under /api/smsgw, authenticated via csvauth with the "sms" role. // Protected routes under /api/smsgw, each guarded by its specific sms:* permission.
smsgw := mwpkg.WithMux(mux, smsAuthMiddleware) smsgw := mwpkg.WithMux(mux)
smsgw.HandleFunc("GET /api/smsgw/received.csv", handlerReceivedCSV) smsgw.With(requireSMSPermission("sms:received")).HandleFunc("GET /api/smsgw/received.csv", handlerReceivedCSV)
smsgw.HandleFunc("GET /api/smsgw/received.json", handlerReceivedJSON) smsgw.With(requireSMSPermission("sms:received")).HandleFunc("GET /api/smsgw/received.json", handlerReceivedJSON)
smsgw.HandleFunc("GET /api/smsgw/sent.csv", handlerSentCSV) smsgw.With(requireSMSPermission("sms:sent")).HandleFunc("GET /api/smsgw/sent.csv", handlerSentCSV)
smsgw.HandleFunc("GET /api/smsgw/sent.json", handlerSentJSON) smsgw.With(requireSMSPermission("sms:sent")).HandleFunc("GET /api/smsgw/sent.json", handlerSentJSON)
smsgw.HandleFunc("GET /api/smsgw/ping.csv", handlerPingCSV) smsgw.With(requireSMSPermission("sms:ping")).HandleFunc("GET /api/smsgw/ping.csv", handlerPingCSV)
smsgw.HandleFunc("GET /api/smsgw/ping.json", handlerPingJSON) smsgw.With(requireSMSPermission("sms:ping")).HandleFunc("GET /api/smsgw/ping.json", handlerPingJSON)
addr := "localhost:8088" addr := "localhost:8088"
fmt.Printf("Listening on %s...\n\n", addr) fmt.Printf("Listening on %s...\n\n", addr)
log.Fatal(http.ListenAndServe(addr, chiMiddleware.Logger(chiMiddleware.Compress(5)(mux)))) log.Fatal(http.ListenAndServe(addr, chiMiddleware.Logger(chiMiddleware.Compress(5)(mux))))
} }
// smsAuthMiddleware requires csvauth credentials with the "sms" role. // hasSMSPermission reports whether perms includes the wildcard "sms:*" or the specific permission.
func smsAuthMiddleware(next http.Handler) http.Handler { func hasSMSPermission(perms []string, permission string) bool {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { for _, p := range perms {
if smsAuth == nil { if p == "sms:*" || p == permission {
w.Header().Set("WWW-Authenticate", `Basic`) return true
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
} }
cred, err := authenticateSMS(r) }
if err != nil || !slices.Contains(cred.Permissions(), "sms") { return false
w.Header().Set("WWW-Authenticate", `Basic`) }
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return // requireSMSPermission returns a middleware that authenticates the request and enforces
} // that the credential holds "sms:*" or the given specific permission.
next.ServeHTTP(w, r) func requireSMSPermission(permission string) func(http.Handler) http.Handler {
}) return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if smsAuth == nil {
w.Header().Set("WWW-Authenticate", `Basic`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
cred, err := authenticateSMS(r)
if err != nil || !hasSMSPermission(cred.Permissions(), permission) {
w.Header().Set("WWW-Authenticate", `Basic`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
} }
// authenticateSMS extracts and verifies credentials from Basic Auth, Authorization header, or API-Key header. // authenticateSMS extracts and verifies credentials from Basic Auth, Authorization header, or API-Key header.
func authenticateSMS(r *http.Request) (auth.BasicPrinciple, error) { func authenticateSMS(r *http.Request) (auth.BasicPrinciple, error) {
// 1. Try Basic Auth // 1. Basic Auth — Authenticate handles both login credentials and token-as-username/password.
if username, password, ok := r.BasicAuth(); ok { if username, password, ok := r.BasicAuth(); ok {
cred, err := smsAuth.Authenticate(username, password) return smsAuth.Authenticate(username, password)
if !errors.Is(err, csvauth.ErrNotFound) {
if err == nil {
return cred, err
}
return nil, err
}
// Basic Auth was used to carry a token (username is a token name like "", "api", "apikey").
token := password
if password == "" {
token = username
}
return smsAuth.Authenticate("", token)
} }
// 2. Try Authorization: <scheme> <token> // 2. Authorization: <scheme> <token>
if authHeader := r.Header.Get("Authorization"); authHeader != "" { if authHeader := r.Header.Get("Authorization"); authHeader != "" {
parts := strings.SplitN(authHeader, " ", 2) parts := strings.SplitN(authHeader, " ", 2)
if len(parts) == 2 { if len(parts) == 2 {
token := strings.TrimSpace(parts[1]) return smsAuth.Authenticate("", strings.TrimSpace(parts[1]))
return smsAuth.Authenticate("", token)
} }
return nil, csvauth.ErrUnauthorized return nil, csvauth.ErrUnauthorized
} }
// 3. Try API-Key / X-API-Key headers // 3. API-Key / X-API-Key headers
for _, h := range []string{"API-Key", "X-API-Key"} { for _, h := range []string{"API-Key", "X-API-Key"} {
if key := r.Header.Get(h); key != "" { if key := r.Header.Get(h); key != "" {
return smsAuth.Authenticate("", key) return smsAuth.Authenticate("", key)
@ -347,29 +328,16 @@ func handlerPingCSV(w http.ResponseWriter, r *http.Request) {
since, limit := parseSinceLimit(r) since, limit := parseSinceLimit(r)
webhookMux.Lock() webhookMux.Lock()
rows := make([]pingRow, 0, min(len(pingEvents), limit)) rows := make([]*androidsmsgateway.WebhookPing, 0, min(len(pingEvents), limit))
for _, ping := range pingEvents { for _, ping := range pingEvents {
pingedAt := time.UnixMilli(ping.XTimestamp).UTC() pingedAt := ping.PingedAt
if pingedAt.IsZero() {
pingedAt = time.UnixMilli(ping.XTimestamp).UTC()
}
if !since.IsZero() && !pingedAt.After(since) { if !since.IsZero() && !pingedAt.After(since) {
continue continue
} }
h := ping.Payload.Health rows = append(rows, ping)
row := pingRow{
DeviceID: ping.DeviceID,
Event: ping.Event,
ID: ping.ID,
WebhookID: ping.WebhookID,
PingedAt: pingedAt,
Status: h.Status,
Version: h.Version,
BatteryLevel: h.Checks["battery:level"].ObservedValue,
BatteryCharging: h.Checks["battery:charging"].ObservedValue,
ConnectionStatus: h.Checks["connection:status"].ObservedValue,
ConnectionTransport: h.Checks["connection:transport"].ObservedValue,
ConnectionCellular: h.Checks["connection:cellular"].ObservedValue,
MessagesFailed: h.Checks["messages:failed"].ObservedValue,
}
rows = append(rows, row)
if len(rows) >= limit { if len(rows) >= limit {
break break
} }
@ -391,7 +359,10 @@ func handlerPingJSON(w http.ResponseWriter, r *http.Request) {
webhookMux.Lock() webhookMux.Lock()
rows := make([]*androidsmsgateway.WebhookPing, 0, min(len(pingEvents), limit)) rows := make([]*androidsmsgateway.WebhookPing, 0, min(len(pingEvents), limit))
for _, ping := range pingEvents { for _, ping := range pingEvents {
pingedAt := time.UnixMilli(ping.XTimestamp).UTC() pingedAt := ping.PingedAt
if pingedAt.IsZero() {
pingedAt = time.UnixMilli(ping.XTimestamp).UTC()
}
if !since.IsZero() && !pingedAt.After(since) { if !since.IsZero() && !pingedAt.After(since) {
continue continue
} }
@ -599,6 +570,7 @@ func handler(w http.ResponseWriter, r *http.Request) {
switch h.GetEvent() { switch h.GetEvent() {
case "system:ping": case "system:ping":
ping := h.(*androidsmsgateway.WebhookPing) ping := h.(*androidsmsgateway.WebhookPing)
ping.PingedAt = time.UnixMilli(webhook.XTimestamp).UTC()
webhookMux.Lock() webhookMux.Lock()
defer webhookMux.Unlock() defer webhookMux.Unlock()
if err := pingWriter.Write(ping); err != nil { if err := pingWriter.Write(ping); err != nil {

View File

@ -96,13 +96,14 @@ type WebhookReceivedPayload struct {
// WebhookPing represents a system:ping webhook event. // WebhookPing represents a system:ping webhook event.
type WebhookPing struct { type WebhookPing struct {
DeviceID string `json:"deviceId"` DeviceID string `json:"deviceId" csv:"deviceId"`
Event string `json:"event"` Event string `json:"event" csv:"event"`
ID string `json:"id"` ID string `json:"id" csv:"id"`
Payload WebhookPingPayload `json:"payload"` Payload WebhookPingPayload `json:"payload" csv:"payload"`
WebhookID string `json:"webhookId"` WebhookID string `json:"webhookId" csv:"webhookId"`
XSignature string `json:"x-signature"` PingedAt time.Time `json:"pingedAt,omitempty" csv:"pingedAt"`
XTimestamp int64 `json:"x-timestamp"` XSignature string `json:"x-signature" csv:"-"`
XTimestamp int64 `json:"x-timestamp" csv:"-"`
} }
// GetEvent marks WebhookPing as part of the WebhookEvent interface. // GetEvent marks WebhookPing as part of the WebhookEvent interface.
@ -111,16 +112,42 @@ func (w *WebhookPing) GetEvent() string {
} }
// WebhookPingPayload contains the health data reported by a system:ping event. // WebhookPingPayload contains the health data reported by a system:ping event.
// MarshalText serialises the payload as a compact JSON string so that csvutil
// stores it as a single "payload" column rather than expanding every health check.
type WebhookPingPayload struct { type WebhookPingPayload struct {
Health DeviceHealth `json:"health"` Health DeviceHealth `json:"health"`
} }
// MarshalText implements encoding.TextMarshaler for CSV serialisation.
func (p WebhookPingPayload) MarshalText() ([]byte, error) {
return json.Marshal(p)
}
// MarshalJSON prevents MarshalText from being used during JSON serialisation,
// ensuring WebhookPingPayload is always encoded as a full JSON object.
func (p WebhookPingPayload) MarshalJSON() ([]byte, error) {
type alias WebhookPingPayload
return json.Marshal(alias(p))
}
// DeviceHealth is the top-level health object inside a system:ping payload. // DeviceHealth is the top-level health object inside a system:ping payload.
// Named fields use colon json tags matching the API key names inside "checks".
type DeviceHealth struct { type DeviceHealth struct {
Checks map[string]HealthCheck `json:"checks"` Checks DeviceChecks `json:"checks"`
ReleaseID int `json:"releaseId"` ReleaseID int `json:"releaseId"`
Status string `json:"status"` Status string `json:"status"`
Version string `json:"version"` Version string `json:"version"`
}
// DeviceChecks holds the individual health checks reported by the device.
// Go field names are camelCase; json tags carry the colon-delimited API key names.
type DeviceChecks struct {
BatteryCharging HealthCheck `json:"battery:charging"`
BatteryLevel HealthCheck `json:"battery:level"`
ConnectionCellular HealthCheck `json:"connection:cellular"`
ConnectionStatus HealthCheck `json:"connection:status"`
ConnectionTransport HealthCheck `json:"connection:transport"`
MessagesFailed HealthCheck `json:"messages:failed"`
} }
// HealthCheck represents a single named health check result. // HealthCheck represents a single named health check result.