mirror of
https://github.com/therootcompany/golib.git
synced 2026-03-02 23:57:59 +00:00
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:
parent
bf3c4a48e4
commit
a708352634
@ -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
|
||||||
|
|||||||
@ -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,22 +108,34 @@ 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 {
|
||||||
|
for _, p := range perms {
|
||||||
|
if p == "sms:*" || p == permission {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if smsAuth == nil {
|
if smsAuth == nil {
|
||||||
w.Header().Set("WWW-Authenticate", `Basic`)
|
w.Header().Set("WWW-Authenticate", `Basic`)
|
||||||
@ -150,7 +143,7 @@ func smsAuthMiddleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cred, err := authenticateSMS(r)
|
cred, err := authenticateSMS(r)
|
||||||
if err != nil || !slices.Contains(cred.Permissions(), "sms") {
|
if err != nil || !hasSMSPermission(cred.Permissions(), permission) {
|
||||||
w.Header().Set("WWW-Authenticate", `Basic`)
|
w.Header().Set("WWW-Authenticate", `Basic`)
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@ -158,37 +151,25 @@ func smsAuthMiddleware(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r)
|
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 {
|
||||||
|
|||||||
@ -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,18 +112,44 @@ 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.
|
||||||
type HealthCheck struct {
|
type HealthCheck struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user