diff --git a/auth/csvauth/csvauth.go b/auth/csvauth/csvauth.go index bfeedb8..355449c 100644 --- a/auth/csvauth/csvauth.go +++ b/auth/csvauth/csvauth.go @@ -376,9 +376,10 @@ func (a *Auth) Authenticate(name, secret string) (auth.BasicPrinciple, error) { } a.mux.Lock() - defer a.mux.Unlock() nameID := a.nameCacheID(name) c, ok := a.hashedCredentials[nameID] + a.mux.Unlock() + if ok { if err := c.Verify(name, secret); err != nil { return nil, err diff --git a/cmd/smsapid/main.go b/cmd/smsapid/main.go index 5f3e4ad..47b75ec 100644 --- a/cmd/smsapid/main.go +++ b/cmd/smsapid/main.go @@ -4,13 +4,11 @@ import ( "context" "encoding/hex" "encoding/json" - "errors" "fmt" "io" "log" "net/http" "os" - "slices" "strconv" "strings" "sync" @@ -37,23 +35,6 @@ var pingWriter jsonl.Writer 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() { jsonf.Indent = 3 @@ -127,68 +108,68 @@ func main() { mux.Handle("PUT /", LogRequest(http.HandlerFunc(HandleOK))) mux.Handle("DELETE /", LogRequest(http.HandlerFunc(HandleOK))) - // Protected routes under /api/smsgw, authenticated via csvauth with the "sms" role. - smsgw := mwpkg.WithMux(mux, smsAuthMiddleware) - smsgw.HandleFunc("GET /api/smsgw/received.csv", handlerReceivedCSV) - smsgw.HandleFunc("GET /api/smsgw/received.json", handlerReceivedJSON) - smsgw.HandleFunc("GET /api/smsgw/sent.csv", handlerSentCSV) - smsgw.HandleFunc("GET /api/smsgw/sent.json", handlerSentJSON) - smsgw.HandleFunc("GET /api/smsgw/ping.csv", handlerPingCSV) - smsgw.HandleFunc("GET /api/smsgw/ping.json", handlerPingJSON) + // Protected routes under /api/smsgw, each guarded by its specific sms:* permission. + smsgw := mwpkg.WithMux(mux) + smsgw.With(requireSMSPermission("sms:received")).HandleFunc("GET /api/smsgw/received.csv", handlerReceivedCSV) + smsgw.With(requireSMSPermission("sms:received")).HandleFunc("GET /api/smsgw/received.json", handlerReceivedJSON) + smsgw.With(requireSMSPermission("sms:sent")).HandleFunc("GET /api/smsgw/sent.csv", handlerSentCSV) + smsgw.With(requireSMSPermission("sms:sent")).HandleFunc("GET /api/smsgw/sent.json", handlerSentJSON) + smsgw.With(requireSMSPermission("sms:ping")).HandleFunc("GET /api/smsgw/ping.csv", handlerPingCSV) + smsgw.With(requireSMSPermission("sms:ping")).HandleFunc("GET /api/smsgw/ping.json", handlerPingJSON) addr := "localhost:8088" fmt.Printf("Listening on %s...\n\n", addr) log.Fatal(http.ListenAndServe(addr, chiMiddleware.Logger(chiMiddleware.Compress(5)(mux)))) } -// smsAuthMiddleware requires csvauth credentials with the "sms" role. -func smsAuthMiddleware(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 +// hasSMSPermission reports whether perms includes the wildcard "sms:*" or the specific permission. +func hasSMSPermission(perms []string, permission string) bool { + for _, p := range perms { + if p == "sms:*" || p == permission { + return true } - cred, err := authenticateSMS(r) - if err != nil || !slices.Contains(cred.Permissions(), "sms") { - w.Header().Set("WWW-Authenticate", `Basic`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - next.ServeHTTP(w, r) - }) + } + return false +} + +// requireSMSPermission returns a middleware that authenticates the request and enforces +// that the credential holds "sms:*" or the given specific permission. +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. 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 { - cred, err := 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) + return smsAuth.Authenticate(username, password) } - // 2. Try Authorization: + // 2. Authorization: if authHeader := r.Header.Get("Authorization"); authHeader != "" { parts := strings.SplitN(authHeader, " ", 2) if len(parts) == 2 { - token := strings.TrimSpace(parts[1]) - return smsAuth.Authenticate("", token) + return smsAuth.Authenticate("", strings.TrimSpace(parts[1])) } 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"} { if key := r.Header.Get(h); key != "" { return smsAuth.Authenticate("", key) @@ -347,29 +328,16 @@ func handlerPingCSV(w http.ResponseWriter, r *http.Request) { since, limit := parseSinceLimit(r) webhookMux.Lock() - rows := make([]pingRow, 0, min(len(pingEvents), limit)) + rows := make([]*androidsmsgateway.WebhookPing, 0, min(len(pingEvents), limit)) 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) { continue } - h := ping.Payload.Health - 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) + rows = append(rows, ping) if len(rows) >= limit { break } @@ -391,7 +359,10 @@ func handlerPingJSON(w http.ResponseWriter, r *http.Request) { webhookMux.Lock() rows := make([]*androidsmsgateway.WebhookPing, 0, min(len(pingEvents), limit)) 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) { continue } @@ -599,6 +570,7 @@ func handler(w http.ResponseWriter, r *http.Request) { switch h.GetEvent() { case "system:ping": ping := h.(*androidsmsgateway.WebhookPing) + ping.PingedAt = time.UnixMilli(webhook.XTimestamp).UTC() webhookMux.Lock() defer webhookMux.Unlock() if err := pingWriter.Write(ping); err != nil { diff --git a/http/androidsmsgateway/webhooks.go b/http/androidsmsgateway/webhooks.go index 0ef94cb..aec61c5 100644 --- a/http/androidsmsgateway/webhooks.go +++ b/http/androidsmsgateway/webhooks.go @@ -96,13 +96,14 @@ type WebhookReceivedPayload struct { // WebhookPing represents a system:ping webhook event. type WebhookPing struct { - DeviceID string `json:"deviceId"` - Event string `json:"event"` - ID string `json:"id"` - Payload WebhookPingPayload `json:"payload"` - WebhookID string `json:"webhookId"` - XSignature string `json:"x-signature"` - XTimestamp int64 `json:"x-timestamp"` + DeviceID string `json:"deviceId" csv:"deviceId"` + Event string `json:"event" csv:"event"` + ID string `json:"id" csv:"id"` + Payload WebhookPingPayload `json:"payload" csv:"payload"` + WebhookID string `json:"webhookId" csv:"webhookId"` + PingedAt time.Time `json:"pingedAt,omitempty" csv:"pingedAt"` + XSignature string `json:"x-signature" csv:"-"` + XTimestamp int64 `json:"x-timestamp" csv:"-"` } // 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. +// 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 { 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. +// Named fields use colon json tags matching the API key names inside "checks". type DeviceHealth struct { - Checks map[string]HealthCheck `json:"checks"` - ReleaseID int `json:"releaseId"` - Status string `json:"status"` - Version string `json:"version"` + Checks DeviceChecks `json:"checks"` + ReleaseID int `json:"releaseId"` + Status string `json:"status"` + 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.