This commit is contained in:
2026-09-16 06:26:16 +02:00
parent 95b0779db0
commit 4f8e0bbb04
51 changed files with 6290 additions and 0 deletions
+673
View File
@@ -0,0 +1,673 @@
package httpserver
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"github.com/example/notify-gateway/internal/config"
"github.com/example/notify-gateway/internal/gateway"
"github.com/example/notify-gateway/internal/model"
)
type editableConfig struct {
Version int `json:"version"`
Server editableServerConfig `json:"server"`
Divera config.DiveraConfig `json:"divera"`
Ingress config.IngressConfig `json:"ingress"`
Mappings []config.Mapping `json:"mappings"`
Outbounds []config.OutboundConfig `json:"outbounds,omitempty"`
}
type editableServerConfig struct {
Listen string `json:"listen"`
AdminUsername string `json:"admin_username"`
}
func makeEditable(c config.Config) editableConfig {
return editableConfig{
Version: c.Version,
Server: editableServerConfig{Listen: c.Server.Listen, AdminUsername: c.Server.AdminUsername},
Divera: c.Divera,
Ingress: c.Ingress,
Mappings: c.Mappings,
Outbounds: c.Outbounds,
}
}
func (s *Server) apiConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, makeEditable(s.store.Get()))
case http.MethodPut:
var in editableConfig
dec := json.NewDecoder(io.LimitReader(r.Body, 2<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Ungültige Konfiguration: " + err.Error()})
return
}
old := s.store.Get()
next := old
next.Version = config.CurrentVersion
next.Server.Listen = strings.TrimSpace(in.Server.Listen)
next.Server.AdminUsername = strings.TrimSpace(in.Server.AdminUsername)
next.Divera = in.Divera
next.Ingress = in.Ingress
next.Mappings = in.Mappings
next.Outbounds = in.Outbounds
if err := config.Validate(next); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
if err := s.store.Replace(next); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": makeEditable(s.store.Get())})
default:
w.Header().Set("Allow", "GET, PUT")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) apiDiveraCatalog(w http.ResponseWriter, r *http.Request) {
// Divera247 v2 pull/all is the primary catalog source. Besides the active
// unit it returns data.ucr, i.e. every UserClusterRelation the current
// account can switch to. We use those UCR ids to load each reachable unit
// and merge its cluster.consumer/group/vehicle catalog. This works with
// normal v2 access even when the v3 synchronization API is not enabled.
resp, err := s.divera.PullAll(r.Context(), nil)
if err != nil {
status := http.StatusBadGateway
if strings.Contains(strings.ToLower(err.Error()), "access_key") {
status = http.StatusBadRequest
}
writeJSON(w, status, map[string]any{"error": err.Error(), "status_code": resp.StatusCode, "response": string(resp.Body)})
return
}
var raw any
if err := json.Unmarshal(resp.Body, &raw); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "Divera247-Antwort ist kein JSON: " + err.Error()})
return
}
catalog := extractCatalog(raw)
refs := extractUCRRefs(raw)
activeUCR := extractActiveUCR(raw)
for _, ref := range refs {
catalog.Units = mergeCatalogItems(catalog.Units, []catalogItem{{ID: ref.ClusterID, Label: ref.Label}})
}
if ref, ok := findUCRRef(refs, activeUCR); ok {
annotateCatalogWithUnit(&catalog, ref)
}
warnings := []string{}
v2Calls, v2Successes := 1, 1
failedV2 := []string{}
// pull/all requests are independent. Limit concurrency so a PRO account with
// many units does not create a burst of dozens of simultaneous requests.
type pullResult struct {
ref ucrRef
catalog diveraCatalog
err error
}
resultCh := make(chan pullResult, len(refs))
sem := make(chan struct{}, 6)
var wg sync.WaitGroup
for _, ref := range refs {
if ref.UCRID <= 0 || ref.UCRID == activeUCR {
continue
}
ref := ref
v2Calls++
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-r.Context().Done():
resultCh <- pullResult{ref: ref, err: r.Context().Err()}
return
}
q := url.Values{}
q.Set("ucr", strconv.FormatInt(ref.UCRID, 10))
unitResp, unitErr := s.divera.PullAll(r.Context(), q)
if unitErr != nil {
resultCh <- pullResult{ref: ref, err: unitErr}
return
}
var unitRaw any
if err := json.Unmarshal(unitResp.Body, &unitRaw); err != nil {
resultCh <- pullResult{ref: ref, err: fmt.Errorf("ungültiges JSON: %w", err)}
return
}
unitCatalog := extractCatalog(unitRaw)
annotateCatalogWithUnit(&unitCatalog, ref)
resultCh <- pullResult{ref: ref, catalog: unitCatalog}
}()
}
go func() {
wg.Wait()
close(resultCh)
}()
for result := range resultCh {
if result.err != nil {
failedV2 = append(failedV2, fmt.Sprintf("%s: %v", result.ref.Label, result.err))
continue
}
v2Successes++
mergeDiveraCatalog(&catalog, result.catalog)
}
if len(failedV2) > 0 {
examples := failedV2
if len(examples) > 3 {
examples = examples[:3]
}
warnings = append(warnings, fmt.Sprintf("Stammdaten für %d von %d Divera247-Einheiten konnten über v2 pull/all nicht geladen werden. Beispiele: %s", len(failedV2), len(refs), strings.Join(examples, " | ")))
}
personSource := "v2 pull/all · cluster.consumer"
v3Calls, v3Successes := 0, 0
// v3 is a beta/synchronization API and can legitimately be forbidden for an
// otherwise valid access key. Only use it as a fallback when v2 did not yield
// any consumers. On an explicit 403 stop immediately instead of producing one
// warning per unit.
if len(catalog.Persons) == 0 {
personSource = "keine Personen verfügbar"
probeRefs := refs
if len(probeRefs) == 0 {
probeRefs = []ucrRef{{}}
}
v3Persons := []catalogItem{}
for _, ref := range probeRefs {
var clusterID *int64
if ref.ClusterID > 0 {
id := ref.ClusterID
clusterID = &id
}
v3Calls++
userResp, userErr := s.divera.ListUserClusterRelations(r.Context(), clusterID)
if userErr != nil {
if userResp.StatusCode == http.StatusForbidden {
warnings = append(warnings, "Divera247 v3 Benutzer-Synchronisation ist für diesen Accesskey nicht freigeschaltet (HTTP 403). Personen werden ausschließlich aus v2 pull/all gelesen.")
break
}
warnings = append(warnings, "Divera247-v3-Fallback für Personen fehlgeschlagen: "+userErr.Error())
break
}
persons, parseErr := parseV3Persons(userResp.Body)
if parseErr != nil {
warnings = append(warnings, "Divera247-v3-Benutzerantwort konnte nicht gelesen werden: "+parseErr.Error())
break
}
v3Successes++
v3Persons = mergeCatalogItems(v3Persons, persons)
}
if len(v3Persons) > 0 {
catalog.Persons = v3Persons
personSource = "v3 user-cluster-relations"
}
}
sortCatalog(&catalog)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"catalog": catalog,
"warnings": warnings,
"diagnostics": map[string]any{
"v2_ucr_calls": v2Calls,
"v2_ucr_successes": v2Successes,
"v3_user_calls": v3Calls,
"v3_user_successes": v3Successes,
"person_count": len(catalog.Persons),
"person_source": personSource,
"available_ucr_count": len(refs),
},
})
}
type ucrRef struct {
UCRID int64
ClusterID int64
Label string
}
func extractUCRRefs(raw any) []ucrRef {
root, ok := raw.(map[string]any)
if !ok {
return nil
}
data, ok := root["data"].(map[string]any)
if !ok {
return nil
}
var refs []ucrRef
add := func(m map[string]any, implicit int64) {
ucrID, _ := positiveInt64(m["id"])
if ucrID <= 0 {
ucrID = implicit
}
clusterID, _ := positiveInt64(m["cluster_id"])
if ucrID <= 0 || clusterID <= 0 {
return
}
label := anyString(m["name"])
short := anyString(m["shortname"])
if label == "" {
label = short
} else if short != "" && short != label {
label += " (" + short + ")"
}
if label == "" {
label = fmt.Sprintf("Einheit %d", clusterID)
}
refs = append(refs, ucrRef{UCRID: ucrID, ClusterID: clusterID, Label: label})
}
switch u := data["ucr"].(type) {
case []any:
for _, row := range u {
if m, ok := row.(map[string]any); ok {
add(m, 0)
}
}
case map[string]any:
for key, row := range u {
if m, ok := row.(map[string]any); ok {
implicit, _ := strconv.ParseInt(key, 10, 64)
add(m, implicit)
}
}
}
byUCR := map[int64]ucrRef{}
for _, ref := range refs {
byUCR[ref.UCRID] = ref
}
refs = refs[:0]
for _, ref := range byUCR {
refs = append(refs, ref)
}
sort.Slice(refs, func(i, j int) bool {
li, lj := strings.ToLower(refs[i].Label), strings.ToLower(refs[j].Label)
if li == lj {
return refs[i].UCRID < refs[j].UCRID
}
return li < lj
})
return refs
}
func extractActiveUCR(raw any) int64 {
root, ok := raw.(map[string]any)
if !ok {
return 0
}
data, ok := root["data"].(map[string]any)
if !ok {
return 0
}
id, _ := positiveInt64(data["ucr_active"])
return id
}
func findUCRRef(refs []ucrRef, id int64) (ucrRef, bool) {
for _, ref := range refs {
if ref.UCRID == id {
return ref, true
}
}
return ucrRef{}, false
}
func annotateCatalogWithUnit(c *diveraCatalog, ref ucrRef) {
if ref.ClusterID <= 0 {
return
}
unit := ref.Label
if unit == "" {
unit = fmt.Sprintf("Einheit %d", ref.ClusterID)
}
suffix := fmt.Sprintf("Einheit: %s · Cluster-ID %d", unit, ref.ClusterID)
annotate := func(items []catalogItem) {
for i := range items {
if !strings.Contains(items[i].Subtitle, "Cluster-ID ") {
if items[i].Subtitle == "" {
items[i].Subtitle = suffix
} else {
items[i].Subtitle += " · " + suffix
}
}
}
}
annotate(c.Groups)
annotate(c.Persons)
annotate(c.Vehicles)
}
func mergeDiveraCatalog(dst *diveraCatalog, src diveraCatalog) {
dst.Units = mergeCatalogItems(dst.Units, src.Units)
dst.Groups = mergeCatalogItems(dst.Groups, src.Groups)
dst.Persons = mergeCatalogItems(dst.Persons, src.Persons)
dst.Vehicles = mergeCatalogItems(dst.Vehicles, src.Vehicles)
}
type catalogItem struct {
ID int64 `json:"id"`
Label string `json:"label"`
Subtitle string `json:"subtitle,omitempty"`
}
type diveraCatalog struct {
Units []catalogItem `json:"units"`
Groups []catalogItem `json:"groups"`
Persons []catalogItem `json:"persons"`
Vehicles []catalogItem `json:"vehicles"`
}
// extractCatalog tolerates different pull/all response layouts. Numeric keys in
// collections are preserved separately because in cluster/consumer Divera247 can use
// that key as UCR id while the nested object's "id" is the global user id.
func extractCatalog(raw any) diveraCatalog {
out := diveraCatalog{}
seen := map[string]map[int64]bool{
"units": {}, "groups": {}, "persons": {}, "vehicles": {},
}
var walk func(v any, path string)
walk = func(v any, path string) {
switch x := v.(type) {
case map[string]any:
kind := classifyCatalogPath(path)
if id, ok := objectIDForKind(x, kind); ok && kind != "" && !seen[kind][id] {
seen[kind][id] = true
item := catalogItem{ID: id, Label: objectLabel(x, id), Subtitle: objectSubtitle(x)}
switch kind {
case "units":
out.Units = append(out.Units, item)
case "groups":
out.Groups = append(out.Groups, item)
case "persons":
out.Persons = append(out.Persons, item)
case "vehicles":
out.Vehicles = append(out.Vehicles, item)
}
}
for k, child := range x {
p := strings.ToLower(k)
if path != "" {
p = path + "/" + p
}
// pull/all commonly returns collections as objects keyed by numeric ID.
// Preserve the key even if the child has its own "id" field: for users,
// the child id may be the global User-ID while the collection key is UCR.
if cm, ok := child.(map[string]any); ok {
if implicit, err := strconv.ParseInt(k, 10, 64); err == nil && implicit > 0 {
copyMap := make(map[string]any, len(cm)+1)
for ck, cv := range cm {
copyMap[ck] = cv
}
copyMap["__collection_key_id"] = implicit
child = copyMap
}
}
walk(child, p)
}
case []any:
for _, child := range x {
walk(child, path)
}
}
}
walk(raw, "")
sortCatalog(&out)
return out
}
func classifyCatalogPath(path string) string {
p := strings.ToLower(path)
switch {
case strings.Contains(p, "vehicle") || strings.Contains(p, "fahrzeug"):
return "vehicles"
case strings.Contains(p, "user_cluster_relation") || strings.Contains(p, "user-cluster-relation") || strings.Contains(p, "userclusterrelation") || strings.Contains(p, "consumer") || strings.Contains(p, "member") || strings.Contains(p, "person") || strings.Contains(p, "/user") || strings.HasSuffix(p, "users"):
return "persons"
case strings.Contains(p, "group") || strings.Contains(p, "gruppen"):
return "groups"
case strings.Contains(p, "cluster") || strings.Contains(p, "unit") || strings.Contains(p, "standort") || strings.Contains(p, "einheit"):
return "units"
default:
return ""
}
}
func objectIDForKind(m map[string]any, kind string) (int64, bool) {
if kind == "persons" {
// Prefer explicit UCR fields. If pull/all uses cluster/consumer keyed by UCR,
// prefer the collection key before the nested global user id.
for _, k := range []string{"user_cluster_relation_id", "userClusterRelationId", "ucr_id", "ucr"} {
if id, ok := positiveInt64(m[k]); ok {
return id, true
}
}
if id, ok := positiveInt64(m["__collection_key_id"]); ok {
return id, true
}
}
for _, k := range []string{"id", "cluster_id", "group_id", "vehicle_id", "__collection_key_id"} {
if id, ok := positiveInt64(m[k]); ok {
return id, true
}
}
return 0, false
}
func positiveInt64(v any) (int64, bool) {
switch n := v.(type) {
case int64:
return n, n > 0
case int:
return int64(n), n > 0
case float64:
return int64(n), n > 0
case json.Number:
i, err := n.Int64()
return i, err == nil && i > 0
case string:
i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
return i, err == nil && i > 0
default:
return 0, false
}
}
func objectLabel(m map[string]any, id int64) string {
for _, k := range []string{"title", "name", "display_name", "fullname", "full_name", "label", "shortname", "callname", "number"} {
if s := anyString(m[k]); s != "" {
return s
}
}
first := anyString(m["firstname"])
last := anyString(m["lastname"])
if strings.TrimSpace(first+" "+last) != "" {
return strings.TrimSpace(first + " " + last)
}
if nested, ok := m["user"].(map[string]any); ok {
first = anyString(nested["firstname"])
last = anyString(nested["lastname"])
if strings.TrimSpace(first+" "+last) != "" {
return strings.TrimSpace(first + " " + last)
}
if email := anyString(nested["email"]); email != "" {
return email
}
}
if email := anyString(m["email"]); email != "" {
return email
}
return fmt.Sprintf("ID %d", id)
}
func objectSubtitle(m map[string]any) string {
vals := []string{}
appendUnique := func(s string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
for _, old := range vals {
if old == s {
return
}
}
vals = append(vals, s)
}
for _, k := range []string{"foreign_id", "ric", "issi", "opta", "number", "email"} {
appendUnique(anyString(m[k]))
}
if nested, ok := m["user"].(map[string]any); ok {
appendUnique(anyString(nested["email"]))
}
if clusterID, ok := positiveInt64(m["cluster_id"]); ok {
appendUnique(fmt.Sprintf("Einheit %d", clusterID))
}
return strings.Join(vals, " · ")
}
func anyString(v any) string {
switch x := v.(type) {
case string:
return strings.TrimSpace(x)
case int:
return strconv.Itoa(x)
case int64:
return strconv.FormatInt(x, 10)
case float64:
return strconv.FormatInt(int64(x), 10)
case json.Number:
return x.String()
default:
return ""
}
}
func parseV3Persons(body []byte) ([]catalogItem, error) {
var rows []struct {
ID int64 `json:"id"`
ClusterID int64 `json:"cluster_id"`
ForeignID string `json:"foreign_id"`
User struct {
ID int64 `json:"id"`
Firstname string `json:"firstname"`
Lastname string `json:"lastname"`
Email string `json:"email"`
} `json:"user"`
}
if err := json.Unmarshal(body, &rows); err != nil {
return nil, err
}
items := make([]catalogItem, 0, len(rows))
for _, row := range rows {
if row.ID <= 0 {
continue
}
label := strings.TrimSpace(row.User.Firstname + " " + row.User.Lastname)
if label == "" {
label = strings.TrimSpace(row.User.Email)
}
if label == "" {
label = fmt.Sprintf("UCR %d", row.ID)
}
parts := []string{}
if row.User.Email != "" {
parts = append(parts, row.User.Email)
}
if row.ClusterID > 0 {
parts = append(parts, fmt.Sprintf("Einheit %d", row.ClusterID))
}
if row.ForeignID != "" {
parts = append(parts, row.ForeignID)
}
items = append(items, catalogItem{ID: row.ID, Label: label, Subtitle: strings.Join(parts, " · ")})
}
return mergeCatalogItems(nil, items), nil
}
func mergeCatalogItems(base, add []catalogItem) []catalogItem {
byID := make(map[int64]catalogItem, len(base)+len(add))
for _, item := range base {
if item.ID > 0 {
byID[item.ID] = item
}
}
for _, item := range add {
if item.ID <= 0 {
continue
}
if old, ok := byID[item.ID]; ok {
// Prefer the richer v3/person label over generic fallback labels.
if strings.HasPrefix(old.Label, "ID ") || strings.HasPrefix(old.Label, "UCR ") || len(item.Label) > len(old.Label) {
old.Label = item.Label
}
if item.Subtitle != "" {
old.Subtitle = item.Subtitle
}
byID[item.ID] = old
} else {
byID[item.ID] = item
}
}
out := make([]catalogItem, 0, len(byID))
for _, item := range byID {
out = append(out, item)
}
sort.Slice(out, func(i, j int) bool {
li, lj := strings.ToLower(out[i].Label), strings.ToLower(out[j].Label)
if li == lj {
return out[i].ID < out[j].ID
}
return li < lj
})
return out
}
func sortCatalog(c *diveraCatalog) {
sorter := func(items []catalogItem) {
sort.Slice(items, func(i, j int) bool {
li, lj := strings.ToLower(items[i].Label), strings.ToLower(items[j].Label)
if li == lj {
return items[i].ID < items[j].ID
}
return li < lj
})
}
sorter(c.Units)
sorter(c.Groups)
sorter(c.Persons)
sorter(c.Vehicles)
}
type previewRequest struct {
Mapping config.Mapping `json:"mapping"`
Message model.InboundMessage `json:"message"`
}
func (s *Server) apiPreview(w http.ResponseWriter, r *http.Request) {
var in previewRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
payload, kind, matched, err := gateway.Preview(in.Mapping, in.Message)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error(), "matched": matched})
return
}
writeJSON(w, http.StatusOK, map[string]any{"matched": matched, "kind": kind, "payload": payload})
}
+271
View File
@@ -0,0 +1,271 @@
package httpserver
import (
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/example/notify-gateway/internal/config"
"github.com/example/notify-gateway/internal/divera"
)
func TestExtractCatalogFromKeyedPullData(t *testing.T) {
raw := map[string]any{
"data": map[string]any{
"cluster": map[string]any{"101": map[string]any{"title": "Löschzug 1"}},
"group": map[string]any{"202": map[string]any{"title": "Atemschutz"}},
"user_cluster_relation": map[string]any{"303": map[string]any{"firstname": "Max", "lastname": "Muster"}},
"vehicle": map[string]any{"404": map[string]any{"name": "HLF 20", "ric": "1234567"}},
},
}
got := extractCatalog(raw)
if len(got.Units) != 1 || got.Units[0].ID != 101 || got.Units[0].Label != "Löschzug 1" {
t.Fatalf("units = %#v", got.Units)
}
if len(got.Groups) != 1 || got.Groups[0].ID != 202 {
t.Fatalf("groups = %#v", got.Groups)
}
if len(got.Persons) != 1 || got.Persons[0].ID != 303 || got.Persons[0].Label != "Max Muster" {
t.Fatalf("persons = %#v", got.Persons)
}
if len(got.Vehicles) != 1 || got.Vehicles[0].ID != 404 {
t.Fatalf("vehicles = %#v", got.Vehicles)
}
}
func TestExtractCatalogPrefersUCRCollectionKeyOverGlobalUserID(t *testing.T) {
// Divera247 documents cluster.consumer as the user master-data collection.
// It can be keyed by UCR while a nested id refers to the global User-ID.
// Alarm recipients need the UCR key (9876).
raw := map[string]any{
"data": map[string]any{
"cluster": map[string]any{
"id": 42,
"consumer": map[string]any{
"9876": map[string]any{
"id": 1234,
"firstname": "Erika",
"lastname": "Mustermann",
"email": "erika@example.invalid",
},
},
},
},
}
got := extractCatalog(raw)
if len(got.Persons) != 1 {
t.Fatalf("persons = %#v", got.Persons)
}
if got.Persons[0].ID != 9876 {
t.Fatalf("expected UCR id 9876, got %#v", got.Persons[0])
}
if got.Persons[0].Label != "Erika Mustermann" {
t.Fatalf("unexpected label: %#v", got.Persons[0])
}
}
func TestParseV3PersonsUsesUserClusterRelationID(t *testing.T) {
body := []byte(`[
{"id":303,"cluster_id":42,"foreign_id":"ext-1","user":{"id":999,"firstname":"Max","lastname":"Muster","email":"max@example.invalid"}}
]`)
got, err := parseV3Persons(body)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ID != 303 || got[0].Label != "Max Muster" {
t.Fatalf("persons = %#v", got)
}
if !strings.Contains(got[0].Subtitle, "Einheit 42") {
t.Fatalf("subtitle = %q", got[0].Subtitle)
}
}
func TestExtractUCRRefsFromPullAll(t *testing.T) {
raw := map[string]any{
"data": map[string]any{
"ucr_active": float64(555),
"ucr": []any{
map[string]any{"id": float64(555), "cluster_id": float64(42), "name": "Einheit Nord", "shortname": "N"},
map[string]any{"id": float64(666), "cluster_id": float64(43), "name": "Einheit Süd"},
},
},
}
refs := extractUCRRefs(raw)
if len(refs) != 2 {
t.Fatalf("refs=%#v", refs)
}
if extractActiveUCR(raw) != 555 {
t.Fatalf("active=%d", extractActiveUCR(raw))
}
byID := map[int64]ucrRef{}
for _, ref := range refs {
byID[ref.UCRID] = ref
}
if byID[555].ClusterID != 42 || byID[666].ClusterID != 43 {
t.Fatalf("refs=%#v", refs)
}
}
func TestDiveraCatalogLoadsConsumersAcrossV2UCRsWithoutV3(t *testing.T) {
var mu sync.Mutex
pullCalls := []string{}
v3Calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/pull/all":
mu.Lock()
pullCalls = append(pullCalls, r.URL.Query().Get("ucr"))
mu.Unlock()
if r.URL.Query().Get("accesskey") != "test-key" {
t.Fatalf("missing accesskey: %s", r.URL.RawQuery)
}
switch r.URL.Query().Get("ucr") {
case "555":
_, _ = io.WriteString(w, `{"data":{"ucr_active":555,"ucr":[{"id":555,"cluster_id":42,"name":"Einheit 42"},{"id":666,"cluster_id":43,"name":"Einheit 43"}],"cluster":{"id":42,"consumer":{"777":{"id":12,"firstname":"Lisa","lastname":"Beispiel","email":"lisa@example.invalid"}},"group":{"11":{"title":"Gruppe Nord"}},"vehicle":{}}}}`)
case "666":
_, _ = io.WriteString(w, `{"data":{"ucr_active":666,"ucr":[{"id":555,"cluster_id":42,"name":"Einheit 42"},{"id":666,"cluster_id":43,"name":"Einheit 43"}],"cluster":{"id":43,"consumer":{"888":{"id":13,"firstname":"Max","lastname":"Süd"}},"group":{"22":{"title":"Gruppe Süd"}},"vehicle":{}}}}`)
default:
http.Error(w, "unexpected ucr", http.StatusBadRequest)
}
case "/api/v3/user-cluster-relations":
v3Calls++
http.Error(w, `{"message":"Nicht autorisiert"}`, http.StatusForbidden)
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
store, err := config.Open(filepath.Join(t.TempDir(), "config.json"))
if err != nil {
t.Fatal(err)
}
if err := store.Update(func(c *config.Config) error {
c.Divera.BaseURL = upstream.URL
c.Divera.AccessKey = "test-key"
c.Divera.UCR = 555
c.Divera.DryRun = true
return nil
}); err != nil {
t.Fatal(err)
}
client := divera.New(func() config.DiveraConfig { return store.Get().Divera })
srv := New(store, nil, client, log.New(io.Discard, "", 0))
req := httptest.NewRequest(http.MethodGet, "/ui/api/divera247/catalog", nil)
rr := httptest.NewRecorder()
srv.apiDiveraCatalog(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var out struct {
Catalog diveraCatalog `json:"catalog"`
Warnings []string `json:"warnings"`
Diagnostics struct {
V2Calls int `json:"v2_ucr_calls"`
V3Calls int `json:"v3_user_calls"`
Source string `json:"person_source"`
} `json:"diagnostics"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if len(out.Catalog.Persons) != 2 {
t.Fatalf("persons=%#v warnings=%#v", out.Catalog.Persons, out.Warnings)
}
ids := map[int64]bool{}
for _, person := range out.Catalog.Persons {
ids[person.ID] = true
if !strings.Contains(person.Subtitle, "Cluster-ID") {
t.Fatalf("missing unit subtitle: %#v", person)
}
}
if !ids[777] || !ids[888] {
t.Fatalf("persons=%#v", out.Catalog.Persons)
}
if v3Calls != 0 || out.Diagnostics.V3Calls != 0 {
t.Fatalf("v3 must not be called when v2 consumers exist: upstream=%d diagnostics=%d", v3Calls, out.Diagnostics.V3Calls)
}
if out.Diagnostics.Source != "v2 pull/all · cluster.consumer" {
t.Fatalf("source=%q", out.Diagnostics.Source)
}
mu.Lock()
defer mu.Unlock()
if len(pullCalls) != 2 {
t.Fatalf("pullCalls=%#v", pullCalls)
}
}
func TestDiveraCatalogStopsV3FallbackAfterForbidden(t *testing.T) {
v3Calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/pull/all":
ucr := r.URL.Query().Get("ucr")
if ucr == "555" {
_, _ = io.WriteString(w, `{"data":{"ucr_active":555,"ucr":[{"id":555,"cluster_id":42,"name":"Einheit 42"},{"id":666,"cluster_id":43,"name":"Einheit 43"}],"cluster":{"id":42,"consumer":{}}}}`)
} else {
_, _ = io.WriteString(w, `{"data":{"ucr_active":666,"ucr":[{"id":555,"cluster_id":42,"name":"Einheit 42"},{"id":666,"cluster_id":43,"name":"Einheit 43"}],"cluster":{"id":43,"consumer":{}}}}`)
}
case "/api/v3/user-cluster-relations":
v3Calls++
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, `{"name":"Forbidden","message":"Nicht autorisiert","code":0,"status":403}`)
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
store, err := config.Open(filepath.Join(t.TempDir(), "config.json"))
if err != nil {
t.Fatal(err)
}
if err := store.Update(func(c *config.Config) error {
c.Divera.BaseURL = upstream.URL
c.Divera.AccessKey = "test-key"
c.Divera.UCR = 555
c.Divera.DryRun = true
return nil
}); err != nil {
t.Fatal(err)
}
client := divera.New(func() config.DiveraConfig { return store.Get().Divera })
srv := New(store, nil, client, log.New(io.Discard, "", 0))
rr := httptest.NewRecorder()
srv.apiDiveraCatalog(rr, httptest.NewRequest(http.MethodGet, "/ui/api/divera247/catalog", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var out struct {
Warnings []string `json:"warnings"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if v3Calls != 1 {
t.Fatalf("expected one v3 probe, got %d", v3Calls)
}
if len(out.Warnings) != 1 || !strings.Contains(out.Warnings[0], "HTTP 403") {
t.Fatalf("warnings=%#v", out.Warnings)
}
}
func TestEditableConfigDoesNotExposeSessionSecrets(t *testing.T) {
c := config.Default()
c.Server.AdminPasswordHash = "secret-hash"
c.Server.SessionSecret = "session-secret"
b, err := json.Marshal(makeEditable(c))
if err != nil {
t.Fatal(err)
}
s := string(b)
if strings.Contains(s, "secret-hash") || strings.Contains(s, "session-secret") || strings.Contains(s, "admin_password_hash") || strings.Contains(s, "session_secret") {
t.Fatalf("editable config leaked internal auth data: %s", s)
}
}
+98
View File
@@ -0,0 +1,98 @@
package httpserver
import (
"context"
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"slices"
"strconv"
"time"
"github.com/example/notify-gateway/internal/model"
)
func (s *Server) discord(w http.ResponseWriter, r *http.Request) {
c := s.store.Get().Ingress.Discord
if !c.Enabled {
http.NotFound(w, r)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, (1<<20)+1))
if err != nil || len(body) > 1<<20 {
http.Error(w, "invalid request", 400)
return
}
stamp := r.Header.Get("X-Signature-Timestamp")
ts, err := strconv.ParseInt(stamp, 10, 64)
key, e1 := hex.DecodeString(c.PublicKey)
sig, e2 := hex.DecodeString(r.Header.Get("X-Signature-Ed25519"))
if err != nil || e1 != nil || e2 != nil || len(key) != ed25519.PublicKeySize || ts < time.Now().Add(-5*time.Minute).Unix() || ts > time.Now().Add(5*time.Minute).Unix() || !ed25519.Verify(key, append([]byte(stamp), body...), sig) {
http.Error(w, "invalid signature", 401)
return
}
var in struct {
ID string `json:"id"`
ApplicationID string `json:"application_id"`
Type int `json:"type"`
GuildID string `json:"guild_id"`
ChannelID string `json:"channel_id"`
Data struct {
Name string `json:"name"`
Type int `json:"type"`
Options []struct {
Name string `json:"name"`
Type int `json:"type"`
Value json.RawMessage `json:"value"`
} `json:"options"`
} `json:"data"`
}
if json.Unmarshal(body, &in) != nil || in.ApplicationID != c.ApplicationID {
http.Error(w, "invalid interaction", 400)
return
}
if in.Type == 1 {
writeJSON(w, 200, map[string]int{"type": 1})
return
}
if in.Type != 2 || in.Data.Type != 1 || in.Data.Name != c.Command || in.ID == "" || !slices.Contains(c.GuildIDs, in.GuildID) || !slices.Contains(c.ChannelIDs, in.ChannelID) {
http.Error(w, "interaction not allowed", 403)
return
}
msg := model.InboundMessage{Source: "discord", Channel: in.ChannelID, ReceivedAt: time.Now().UTC()}
for _, o := range in.Data.Options {
switch o.Name {
case "title":
err = json.Unmarshal(o.Value, &msg.Title)
case "message":
err = json.Unmarshal(o.Value, &msg.Message)
case "priority":
err = json.Unmarshal(o.Value, &msg.Priority)
default:
http.Error(w, "unsupported option", 400)
return
}
if err != nil {
http.Error(w, "invalid option", 400)
return
}
}
if msg.Message == "" {
http.Error(w, "message required", 400)
return
}
if s.queue == nil {
http.Error(w, "outbox unavailable", 503)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
receipt, err := s.queue.Accept(ctx, msg, "discord:"+in.ID)
if err != nil {
http.Error(w, "interaction could not be queued; check routing", 503)
return
}
writeJSON(w, 200, map[string]any{"type": 4, "data": map[string]any{"content": "Meldung angenommen. Referenz: " + receipt.ID, "flags": 64, "allowed_mentions": map[string]any{"parse": []string{}}}})
}
+118
View File
@@ -0,0 +1,118 @@
package httpserver
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"net/http"
"os"
"strconv"
"time"
"github.com/example/notify-gateway/internal/gateway"
"github.com/example/notify-gateway/internal/mailingress"
"github.com/example/notify-gateway/internal/outbox"
)
func (s *Server) UseQueue(q *gateway.Queue, p *mailingress.Poller) { s.queue = q; s.mail = p }
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if s.queue == nil || s.queue.Store.Ping(ctx) != nil {
writeJSON(w, 503, map[string]any{"ok": false})
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
func (s *Server) deliveries(w http.ResponseWriter, r *http.Request) {
if s.queue == nil {
http.Error(w, "outbox unavailable", 503)
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
jobs, err := s.queue.Store.List(r.Context(), 50, offset)
if err != nil {
http.Error(w, "outbox unavailable", 503)
return
}
counts, err := s.queue.Store.Counts(r.Context())
if err != nil {
http.Error(w, "outbox unavailable", 503)
return
}
statuses := []mailingress.Status{}
if s.mail != nil {
statuses = s.mail.Statuses()
}
writeJSON(w, 200, map[string]any{"deliveries": jobs, "counts": counts, "mail": statuses})
}
func (s *Server) deliveryHistory(w http.ResponseWriter, r *http.Request) {
if s.queue == nil {
http.Error(w, "outbox unavailable", 503)
return
}
rows, err := s.queue.Store.History(r.Context(), r.PathValue("id"))
if err != nil {
http.Error(w, "outbox unavailable", 503)
return
}
writeJSON(w, 200, rows)
}
func (s *Server) retryDelivery(w http.ResponseWriter, r *http.Request) {
if s.queue == nil {
http.Error(w, "outbox unavailable", 503)
return
}
err := s.queue.Store.Retry(r.Context(), r.PathValue("id"))
if errors.Is(err, outbox.ErrNotRetryable) {
http.Error(w, err.Error(), 409)
return
}
if err != nil {
http.Error(w, "outbox unavailable", 503)
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
func (s *Server) metrics(w http.ResponseWriter, r *http.Request) {
token := os.Getenv("GATEWAY_METRICS_TOKEN")
if token == "" {
s.requireAdmin(s.writeMetrics)(w, r)
return
}
got := r.Header.Get("Authorization")
if subtle.ConstantTimeCompare([]byte(got), []byte("Bearer "+token)) != 1 {
http.Error(w, "unauthorized", 401)
return
}
s.writeMetrics(w, r)
}
func (s *Server) writeMetrics(w http.ResponseWriter, r *http.Request) {
if s.queue == nil {
http.Error(w, "outbox unavailable", 503)
return
}
counts, err := s.queue.Store.Counts(r.Context())
if err != nil {
http.Error(w, "outbox unavailable", 503)
return
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
fmt.Fprintln(w, "# HELP notify_gateway_deliveries Persisted deliveries by current state.\n# TYPE notify_gateway_deliveries gauge")
for _, state := range []string{"pending", "sending", "succeeded", "dry_run", "dead"} {
fmt.Fprintf(w, "notify_gateway_deliveries{state=%q} %d\n", state, counts[state])
}
}
func csrfToken(secret, session string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte("csrf:" + session))
return hex.EncodeToString(mac.Sum(nil))
}
func (s *Server) csrf(w http.ResponseWriter, r *http.Request) {
c, _ := r.Cookie("ng_session")
writeJSON(w, 200, map[string]string{"token": csrfToken(s.store.Get().Server.SessionSecret, c.Value)})
}
+181
View File
@@ -0,0 +1,181 @@
package httpserver
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/example/notify-gateway/internal/auth"
"github.com/example/notify-gateway/internal/config"
"github.com/example/notify-gateway/internal/divera"
"github.com/example/notify-gateway/internal/gateway"
"github.com/example/notify-gateway/internal/outbox"
)
func queuedServer(t *testing.T) (*Server, *config.Store, *outbox.Store) {
t.Helper()
dir := t.TempDir()
store, err := config.Open(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
db, err := outbox.Open(filepath.Join(dir, "outbox.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
client := divera.New(func() config.DiveraConfig { return store.Get().Divera })
dispatcher := gateway.New(store, client)
s := New(store, dispatcher, client, log.New(io.Discard, "", 0))
s.UseQueue(&gateway.Queue{Store: db, Dispatcher: dispatcher}, nil)
return s, store, db
}
func TestAsyncIngressIdempotencyReadinessAndMetrics(t *testing.T) {
s, store, db := queuedServer(t)
c := store.Get()
c.Ingress.WebhookTokens = map[string]string{"ops": "secret"}
c.Mappings = []config.Mapping{{ID: "route", Enabled: true, Target: "news", TextTemplate: "{{.Message}}"}}
store.Replace(c)
h := s.Handler()
send := func(body string) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", "/in/webhook/ops", strings.NewReader(body))
r.Header.Set("Authorization", "Bearer secret")
r.Header.Set("Idempotency-Key", "same-request")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
for i := 0; i < 2; i++ {
if w := send("hello"); w.Code != 202 {
t.Fatalf("accepted=%d %s", w.Code, w.Body)
}
}
if w := send("different"); w.Code != 409 {
t.Fatal(w.Code)
}
rows, _ := db.List(context.Background(), 50, 0)
if len(rows) != 1 || rows[0].State != "pending" {
t.Fatal(rows)
}
for _, path := range []string{"/readyz", "/metrics"} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if path == "/readyz" && w.Code != 200 {
t.Fatal(w.Code)
}
if path == "/metrics" && w.Code != 303 {
t.Fatal("public metrics")
}
}
t.Setenv("GATEWAY_METRICS_TOKEN", "metric-token")
r := httptest.NewRequest("GET", "/metrics", nil)
r.Header.Set("Authorization", "Bearer metric-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 200 || !strings.Contains(w.Body.String(), `state="pending"} 1`) {
t.Fatalf("metrics %d %s", w.Code, w.Body)
}
}
func TestCSRFRequestLimitsAndLoginRateLimit(t *testing.T) {
s, store, _ := queuedServer(t)
c := store.Get()
h := s.Handler()
session := auth.SignSession(c.Server.SessionSecret, c.Server.AdminUsername, time.Now().Add(time.Hour))
for _, valid := range []bool{false, true} {
body, _ := json.Marshal(makeEditable(c))
r := httptest.NewRequest("PUT", "/ui/api/config", bytes.NewReader(body))
r.AddCookie(&http.Cookie{Name: "ng_session", Value: session})
if valid {
r.Header.Set("X-CSRF-Token", csrfToken(c.Server.SessionSecret, session))
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
want := 403
if valid {
want = 200
}
if w.Code != want {
t.Fatalf("CSRF valid=%v status=%d %s", valid, w.Code, w.Body)
}
}
r := httptest.NewRequest("POST", "/login", nil)
r.Header.Set("Origin", "https://evil.example")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal("cross-site login accepted")
}
w = httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("POST", "/ntfy/ops", strings.NewReader(strings.Repeat("x", (1<<20)+1))))
if w.Code != 413 {
t.Fatal("large body accepted")
}
for i := 0; i < 11; i++ {
w = httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("POST", "/login", nil))
}
if w.Code != 429 {
t.Fatalf("rate limit %d", w.Code)
}
}
func TestDiscordSignatureAllowlistAndReplay(t *testing.T) {
s, store, db := queuedServer(t)
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
c := store.Get()
c.Ingress.Discord = config.DiscordIngress{Enabled: true, PublicKey: hex.EncodeToString(pub), ApplicationID: "app", GuildIDs: []string{"guild"}, ChannelIDs: []string{"channel"}, Command: "notify"}
c.Mappings = []config.Mapping{{ID: "discord", Enabled: true, Source: "discord", Target: "news", TextTemplate: "{{.Message}}"}}
if err := store.Replace(c); err != nil {
t.Fatal(err)
}
h := s.Handler()
invoke := func(body string, stamp time.Time, valid bool) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", "/in/discord", strings.NewReader(body))
ts := strconv.FormatInt(stamp.Unix(), 10)
sig := ed25519.Sign(priv, []byte(ts+body))
if !valid {
sig[0] ^= 1
}
r.Header.Set("X-Signature-Timestamp", ts)
r.Header.Set("X-Signature-Ed25519", hex.EncodeToString(sig))
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
ping := `{"type":1,"application_id":"app"}`
if w := invoke(ping, time.Now(), true); w.Code != 200 || !strings.Contains(w.Body.String(), `"type":1`) {
t.Fatalf("ping %d %s", w.Code, w.Body)
}
if w := invoke(ping, time.Now(), false); w.Code != 401 {
t.Fatal("bad signature accepted")
}
if w := invoke(ping, time.Now().Add(-6*time.Minute), true); w.Code != 401 {
t.Fatal("stale request accepted")
}
body := `{"id":"interaction","application_id":"app","type":2,"guild_id":"guild","channel_id":"channel","data":{"name":"notify","type":1,"options":[{"name":"message","type":3,"value":"hello"}]}}`
for i := 0; i < 2; i++ {
if w := invoke(body, time.Now(), true); w.Code != 200 {
t.Fatalf("command %d %s", w.Code, w.Body)
}
}
if w := invoke(strings.Replace(body, `"guild"`, `"other"`, 1), time.Now(), true); w.Code != 403 {
t.Fatal("guild allowlist bypass")
}
rows, _ := db.List(context.Background(), 50, 0)
if len(rows) != 1 {
t.Fatalf("duplicate interaction: %+v", rows)
}
}
+106
View File
@@ -0,0 +1,106 @@
package httpserver
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/example/notify-gateway/internal/auth"
"github.com/example/notify-gateway/internal/config"
"github.com/example/notify-gateway/internal/divera"
"github.com/example/notify-gateway/internal/gateway"
)
func TestOutboundAdminRoundTripPreviewAndIngress(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
store, err := config.Open(configPath)
if err != nil {
t.Fatal(err)
}
client := divera.New(func() config.DiveraConfig { return store.Get().Divera })
h := New(store, gateway.New(store, client), client, log.New(io.Discard, "", 0)).Handler()
c := store.Get()
cookie := &http.Cookie{Name: "ng_session", Value: auth.SignSession(c.Server.SessionSecret, c.Server.AdminUsername, time.Now().Add(time.Hour))}
edit := makeEditable(c)
edit.Outbounds = []config.OutboundConfig{{ID: "discord-ops", Name: "Ops", Provider: "discord", URL: "https://discord.com/api/webhooks/123/secret"}}
edit.Ingress.NtfyTokens = map[string]string{"ops": "ingress-token"}
edit.Mappings = []config.Mapping{{ID: "route", Name: "Route", Enabled: true, Source: "ntfy", Target: "discord", OutboundID: "discord-ops", TitleTemplate: "{{.Title}}", TextTemplate: "{{.Message}}"}}
body, _ := json.Marshal(edit)
request := func(method, path string, body []byte, admin bool) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
if admin {
r.AddCookie(cookie)
r.Header.Set("X-CSRF-Token", csrfToken(c.Server.SessionSecret, cookie.Value))
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
if w := request("PUT", "/ui/api/config", body, false); w.Code != 303 {
t.Fatalf("unprotected admin API: %d", w.Code)
}
if w := request("PUT", "/ui/api/config", body, true); w.Code != 200 {
t.Fatalf("save: %d %s", w.Code, w.Body)
}
if store.Get().Server.SessionSecret != c.Server.SessionSecret {
t.Fatal("admin secret changed")
}
reopened, err := config.Open(configPath)
if err != nil {
t.Fatal(err)
}
if len(reopened.Get().Outbounds) != 1 || reopened.Get().Outbounds[0].ID != "discord-ops" {
t.Fatal("outbound was not persisted")
}
w := request("GET", "/ui/api/config", nil, true)
var loaded editableConfig
if err := json.Unmarshal(w.Body.Bytes(), &loaded); err != nil {
t.Fatal(err)
}
if len(loaded.Outbounds) != 1 || loaded.Outbounds[0].Live || loaded.Mappings[0].OutboundID != "discord-ops" {
t.Fatalf("round trip: %+v", loaded)
}
preview, _ := json.Marshal(map[string]any{"mapping": edit.Mappings[0], "message": map[string]any{"source": "ntfy", "title": "Hello", "message": "World"}})
w = request("POST", "/ui/api/preview", preview, true)
if w.Code != 200 || !strings.Contains(w.Body.String(), `"content":"Hello\nWorld"`) {
t.Fatalf("preview: %d %s", w.Code, w.Body)
}
for _, authorized := range []bool{false, true} {
r := httptest.NewRequest("POST", "/ntfy/ops", strings.NewReader("World"))
r.Header.Set("Title", "Hello")
if authorized {
r.Header.Set("Authorization", "Bearer ingress-token")
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if !authorized {
if w.Code != 401 {
t.Fatalf("ingress auth: %d", w.Code)
}
continue
}
if w.Code != 200 || !strings.Contains(w.Body.String(), `"outbound_id":"discord-ops"`) || !strings.Contains(w.Body.String(), `dry_run`) {
t.Fatalf("delivery: %d %s", w.Code, w.Body)
}
if strings.Contains(w.Body.String(), "secret") {
t.Fatal("destination token leaked")
}
}
// A destination still referenced by a mapping cannot be removed.
edit.Outbounds = nil
body, _ = json.Marshal(edit)
if w := request("PUT", "/ui/api/config", body, true); w.Code != 400 {
t.Fatalf("invalid reference: %d %s", w.Code, w.Body)
}
if len(store.Get().Outbounds) != 1 {
t.Fatal("invalid config was persisted")
}
}
+85
View File
@@ -0,0 +1,85 @@
package httpserver
import (
"bytes"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
type loginBucket struct {
Start time.Time
Count int
}
func (s *Server) allowLogin(address string) bool {
ip, _, err := net.SplitHostPort(address)
if err != nil {
ip = address
}
s.loginMu.Lock()
defer s.loginMu.Unlock()
if s.logins == nil {
s.logins = map[string]loginBucket{}
}
now := time.Now()
for k, v := range s.logins {
if now.Sub(v.Start) > time.Minute {
delete(s.logins, k)
}
}
b := s.logins[ip]
if b.Start.IsZero() {
if len(s.logins) >= 1024 {
return false
}
b.Start = now
}
b.Count++
s.logins[ip] = b
return b.Count <= 10
}
func requestProtection(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// No proxy headers are trusted. Configure the proxy to preserve the Host.
admin := strings.HasPrefix(r.URL.Path, "/ui/") || r.URL.Path == "/login" || r.URL.Path == "/logout"
if admin {
w.Header().Set("Cache-Control", "no-store")
}
if admin && r.Method != "GET" && r.Method != "HEAD" {
if origin := r.Header.Get("Origin"); origin != "" {
u, err := url.Parse(origin)
if err != nil || u.Host != r.Host || (u.Scheme != "http" && u.Scheme != "https") {
http.Error(w, "cross-origin request rejected", 403)
return
}
}
if r.Header.Get("Sec-Fetch-Site") == "cross-site" {
http.Error(w, "cross-site request rejected", 403)
return
}
}
if r.Body != nil {
limit := int64(1 << 20)
if admin {
limit = 2 << 20
}
b, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
r.Body.Close()
if err != nil {
http.Error(w, "invalid body", 400)
return
}
if int64(len(b)) > limit {
http.Error(w, "request too large", 413)
return
}
r.Body = io.NopCloser(bytes.NewReader(b))
}
next.ServeHTTP(w, r)
})
}
+513
View File
@@ -0,0 +1,513 @@
package httpserver
import (
"crypto/sha256"
"crypto/subtle"
_ "embed"
"encoding/base64"
"encoding/json"
"errors"
"html/template"
"io"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/example/notify-gateway/internal/auth"
"github.com/example/notify-gateway/internal/config"
"github.com/example/notify-gateway/internal/divera"
"github.com/example/notify-gateway/internal/gateway"
"github.com/example/notify-gateway/internal/mailingress"
"github.com/example/notify-gateway/internal/model"
"github.com/example/notify-gateway/internal/outbox"
)
type Server struct {
queue *gateway.Queue
mail *mailingress.Poller
loginMu sync.Mutex
logins map[string]loginBucket
store *config.Store
dispatcher *gateway.Dispatcher
divera *divera.Client
logger *log.Logger
}
func New(store *config.Store, dispatcher *gateway.Dispatcher, client *divera.Client, logger *log.Logger) *Server {
return &Server{store: store, dispatcher: dispatcher, divera: client, logger: logger}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("GET /readyz", s.ready)
mux.HandleFunc("GET /metrics", s.metrics)
mux.HandleFunc("GET /ui/api/csrf", s.requireAdmin(s.csrf))
mux.HandleFunc("GET /ui/api/deliveries", s.requireAdmin(s.deliveries))
mux.HandleFunc("GET /ui/api/deliveries/{id}/attempts", s.requireAdmin(s.deliveryHistory))
mux.HandleFunc("POST /ui/api/deliveries/{id}/retry", s.requireAdmin(s.retryDelivery))
mux.HandleFunc("POST /in/discord", s.discord)
mux.HandleFunc("POST /login", s.login)
mux.HandleFunc("GET /login", s.loginPage)
mux.HandleFunc("POST /logout", s.requireAdmin(s.logout))
mux.HandleFunc("GET /ui/", s.requireAdmin(s.ui))
mux.HandleFunc("GET /ui/api/config", s.requireAdmin(s.apiConfig))
mux.HandleFunc("PUT /ui/api/config", s.requireAdmin(s.apiConfig))
mux.HandleFunc("GET /ui/api/divera247/catalog", s.requireAdmin(s.apiDiveraCatalog))
mux.HandleFunc("POST /ui/api/preview", s.requireAdmin(s.apiPreview))
mux.HandleFunc("POST /ui/password", s.requireAdmin(s.changePassword))
mux.HandleFunc("POST /ui/test-divera", s.requireAdmin(s.testDivera))
mux.HandleFunc("POST /in/webhook/{channel}", s.webhook)
mux.HandleFunc("PUT /in/webhook/{channel}", s.webhook)
mux.HandleFunc("POST /in/ntfy", s.ntfyJSON)
mux.HandleFunc("PUT /in/ntfy", s.ntfyJSON)
mux.HandleFunc("POST /ntfy", s.ntfyJSON)
mux.HandleFunc("PUT /ntfy", s.ntfyJSON)
mux.HandleFunc("POST /in/ntfy/{topic}", s.ntfyTopic)
mux.HandleFunc("PUT /in/ntfy/{topic}", s.ntfyTopic)
mux.HandleFunc("GET /in/ntfy/{topic}/trigger", s.ntfyTrigger)
mux.HandleFunc("GET /in/ntfy/{topic}/send", s.ntfyTrigger)
mux.HandleFunc("GET /in/ntfy/{topic}/publish", s.ntfyTrigger)
mux.HandleFunc("POST /ntfy/{topic}", s.ntfyTopic)
mux.HandleFunc("PUT /ntfy/{topic}", s.ntfyTopic)
mux.HandleFunc("GET /ntfy/{topic}/trigger", s.ntfyTrigger)
mux.HandleFunc("GET /ntfy/{topic}/send", s.ntfyTrigger)
mux.HandleFunc("GET /ntfy/{topic}/publish", s.ntfyTrigger)
mux.HandleFunc("POST /in/gotify/message", s.gotify)
mux.HandleFunc("POST /message", s.gotify)
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/", http.StatusFound) })
return securityHeaders(requestProtection(mux))
}
func securityHeaders(next http.Handler) http.Handler {
script := strings.Split(strings.Split(uiHTML, "<script>")[1], "</script>")[0]
// HTML parsing normalizes CRLF before CSP hashes are checked.
sum := sha256.Sum256([]byte(strings.ReplaceAll(script, "\r\n", "\n")))
scriptHash := base64.StdEncoding.EncodeToString(sum[:])
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'sha256-"+scriptHash+"'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"ok": true, "time": time.Now().UTC()})
}
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { renderLogin(w, "") }
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if !s.allowLogin(r.RemoteAddr) {
w.Header().Set("Retry-After", "60")
http.Error(w, "too many login attempts", 429)
return
}
_ = r.ParseForm()
cfg := s.store.Get()
if r.Form.Get("username") != cfg.Server.AdminUsername || !auth.CheckPassword(cfg.Server.AdminPasswordHash, r.Form.Get("password")) {
renderLogin(w, "Login fehlgeschlagen")
return
}
if strings.HasPrefix(cfg.Server.AdminPasswordHash, "sha256$") && len(r.Form.Get("password")) <= 72 {
h, err := auth.HashPassword(r.Form.Get("password"))
if err != nil {
http.Error(w, "password migration failed", 500)
return
}
if err = s.store.Update(func(c *config.Config) error { c.Server.AdminPasswordHash = h; return nil }); err != nil {
http.Error(w, "password migration failed", 500)
return
}
}
token := auth.SignSession(cfg.Server.SessionSecret, cfg.Server.AdminUsername, time.Now().Add(12*time.Hour))
http.SetCookie(w, &http.Cookie{Name: "ng_session", Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: r.TLS != nil, MaxAge: 43200})
http.Redirect(w, r, "/ui/", http.StatusSeeOther)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "ng_session", Value: "", Path: "/", HttpOnly: true, MaxAge: -1, SameSite: http.SameSiteStrictMode})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg := s.store.Get()
c, err := r.Cookie("ng_session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
u, ok := auth.VerifySession(cfg.Server.SessionSecret, c.Value, time.Now())
if !ok || u != cfg.Server.AdminUsername {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if r.Method != "GET" && r.Method != "HEAD" {
token := r.Header.Get("X-CSRF-Token")
if token == "" && strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") {
_ = r.ParseForm()
token = r.Form.Get("csrf_token")
}
if subtle.ConstantTimeCompare([]byte(token), []byte(csrfToken(cfg.Server.SessionSecret, c.Value))) != 1 {
http.Error(w, "invalid CSRF token", 403)
return
}
}
next(w, r)
}
}
//go:embed ui.html
var uiHTML string
func (s *Server) ui(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, uiHTML)
}
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
p := r.Form.Get("password")
if len(p) < 10 || len(p) > 72 {
http.Error(w, "Passwort muss 10 bis 72 UTF-8-Bytes enthalten", http.StatusBadRequest)
return
}
h, err := auth.HashPassword(p)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.store.Update(func(c *config.Config) error {
c.Server.AdminPasswordHash = h
c.Server.SessionSecret = auth.RandomSecret()
return nil
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/ui/", http.StatusSeeOther)
}
func (s *Server) testDivera(w http.ResponseWriter, r *http.Request) {
resp, err := s.divera.PullAll(r.Context(), nil)
if err != nil {
http.Error(w, err.Error(), 502)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(resp.Body)
}
func (s *Server) authorized(source, channel string, r *http.Request) bool {
cfg := s.store.Get()
if cfg.Ingress.AllowUnauthenticated {
return true
}
token := bearerOrToken(r)
var allowed []string
switch source {
case "ntfy":
if t := cfg.Ingress.NtfyTokens[channel]; t != "" {
allowed = append(allowed, t)
}
if t := cfg.Ingress.NtfyTokens["*"]; t != "" {
allowed = append(allowed, t)
}
case "webhook":
if t := cfg.Ingress.WebhookTokens[channel]; t != "" {
allowed = append(allowed, t)
}
if t := cfg.Ingress.WebhookTokens["*"]; t != "" {
allowed = append(allowed, t)
}
case "gotify":
allowed = append(allowed, cfg.Ingress.GotifyTokens...)
}
for _, a := range allowed {
if len(a) == len(token) && subtle.ConstantTimeCompare([]byte(a), []byte(token)) == 1 {
return true
}
}
return false
}
func bearerOrToken(r *http.Request) string {
if t := r.URL.Query().Get("token"); t != "" {
return t
}
if t := r.Header.Get("X-Gotify-Key"); t != "" {
return t
}
a := r.Header.Get("Authorization")
if strings.HasPrefix(strings.ToLower(a), "bearer ") {
return strings.TrimSpace(a[7:])
}
if u, p, ok := r.BasicAuth(); ok {
if p != "" {
return p
}
return u
}
return ""
}
func (s *Server) webhook(w http.ResponseWriter, r *http.Request) {
channel := r.PathValue("channel")
if !s.authorized("webhook", channel, r) {
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
return
}
raw, body, err := decodeFlexible(r)
if err != nil {
writeJSON(w, 400, map[string]any{"error": err.Error()})
return
}
msg := model.InboundMessage{Source: "webhook", Channel: channel, Title: str(raw, "title", "subject"), Message: firstNonEmpty(str(raw, "message", "text", "body"), body), Address: str(raw, "address", "location"), Priority: intval(raw, "priority"), Raw: raw, ReceivedAt: time.Now().UTC()}
s.deliver(w, r, msg)
}
func (s *Server) ntfyJSON(w http.ResponseWriter, r *http.Request) {
var raw map[string]any
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&raw); err != nil {
writeJSON(w, 400, map[string]any{"error": "invalid ntfy json"})
return
}
topic := str(raw, "topic")
if topic == "" {
writeJSON(w, 400, map[string]any{"error": "topic required"})
return
}
if !s.authorized("ntfy", topic, r) {
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
return
}
msg := model.InboundMessage{Source: "ntfy", Channel: topic, Title: str(raw, "title"), Message: str(raw, "message"), Priority: intval(raw, "priority"), Raw: raw, ReceivedAt: time.Now().UTC()}
s.deliver(w, r, msg)
}
func (s *Server) ntfyTopic(w http.ResponseWriter, r *http.Request) {
topic := r.PathValue("topic")
if !s.authorized("ntfy", topic, r) {
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
return
}
raw, body, err := decodeNtfy(r)
if err != nil {
writeJSON(w, 400, map[string]any{"error": err.Error()})
return
}
title := firstNonEmpty(r.Header.Get("Title"), r.Header.Get("X-Title"), str(raw, "title"))
message := firstNonEmpty(str(raw, "message"), body)
priority := intval(raw, "priority")
if priority == 0 {
priority = parsePriority(firstNonEmpty(r.Header.Get("Priority"), r.Header.Get("X-Priority")))
}
msg := model.InboundMessage{Source: "ntfy", Channel: topic, Title: title, Message: message, Priority: priority, Raw: raw, ReceivedAt: time.Now().UTC()}
s.deliver(w, r, msg)
}
func (s *Server) ntfyTrigger(w http.ResponseWriter, r *http.Request) {
topic := r.PathValue("topic")
if !s.authorized("ntfy", topic, r) {
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
return
}
msg := model.InboundMessage{Source: "ntfy", Channel: topic, Title: r.URL.Query().Get("title"), Message: firstNonEmpty(r.URL.Query().Get("message"), "triggered"), Priority: parsePriority(r.URL.Query().Get("priority")), Raw: map[string]any{"query": r.URL.Query()}, ReceivedAt: time.Now().UTC()}
s.deliver(w, r, msg)
}
func (s *Server) gotify(w http.ResponseWriter, r *http.Request) {
if !s.authorized("gotify", "", r) {
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
return
}
raw, body, err := decodeFlexible(r)
if err != nil {
writeJSON(w, 400, map[string]any{"error": err.Error()})
return
}
if len(raw) == 0 {
_ = r.ParseForm()
raw = map[string]any{"title": r.Form.Get("title"), "message": r.Form.Get("message"), "priority": r.Form.Get("priority")}
}
msg := model.InboundMessage{Source: "gotify", Channel: firstNonEmpty(str(raw, "channel"), "default"), Title: str(raw, "title"), Message: firstNonEmpty(str(raw, "message"), body), Priority: intval(raw, "priority"), Raw: raw, ReceivedAt: time.Now().UTC()}
s.deliver(w, r, msg)
}
func (s *Server) deliver(w http.ResponseWriter, r *http.Request, msg model.InboundMessage) {
if s.queue != nil {
// Never accept a gateway-generated HTTP notification back into the gateway.
if r.Header.Get("X-Notify-Gateway-Delivery") != "" {
writeJSON(w, 409, map[string]any{"error": "gateway loop detected"})
return
}
receipt, err := s.queue.Accept(r.Context(), msg, r.Header.Get("Idempotency-Key"))
if err != nil {
status := 503
var input *gateway.InputError
if errors.As(err, &input) {
status = 400
}
if errors.Is(err, outbox.ErrConflict) {
status = 409
}
message := "outbox unavailable"
if status != 503 {
message = err.Error()
}
writeJSON(w, status, map[string]any{"error": message})
return
}
writeJSON(w, 202, map[string]any{"ok": true, "receipt": receipt})
return
}
results, err := s.dispatcher.Dispatch(r.Context(), msg)
if err != nil {
s.logger.Printf("delivery failed: %v", err)
writeJSON(w, 502, map[string]any{"error": err.Error(), "results": results})
return
}
writeJSON(w, 200, map[string]any{"ok": true, "results": results})
}
func decodeNtfy(r *http.Request) (map[string]any, string, error) {
b, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
return nil, "", err
}
raw := map[string]any{}
if strings.Contains(r.Header.Get("Content-Type"), "application/json") || (len(b) > 0 && b[0] == '{') {
if err := json.Unmarshal(b, &raw); err != nil {
return nil, "", err
}
return raw, "", nil
}
return raw, string(b), nil
}
func decodeFlexible(r *http.Request) (map[string]any, string, error) {
b, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
return nil, "", err
}
raw := map[string]any{}
ct := r.Header.Get("Content-Type")
if strings.Contains(ct, "application/json") || (len(b) > 0 && b[0] == '{') {
if err := json.Unmarshal(b, &raw); err != nil {
return nil, "", err
}
return raw, "", nil
}
if strings.Contains(ct, "application/x-www-form-urlencoded") {
vals, err := urlParse(string(b))
if err != nil {
return nil, "", err
}
for k, v := range vals {
if len(v) > 0 {
raw[k] = v[0]
}
}
return raw, "", nil
}
return raw, string(b), nil
}
func urlParse(s string) (map[string][]string, error) {
vals, err := netURLParseQuery(s)
return map[string][]string(vals), err
}
var netURLParseQuery = func(s string) (map[string][]string, error) {
out := map[string][]string{}
for _, p := range strings.Split(s, "&") {
if p == "" {
continue
}
kv := strings.SplitN(p, "=", 2)
k := strings.ReplaceAll(kv[0], "+", " ")
v := ""
if len(kv) > 1 {
v = strings.ReplaceAll(kv[1], "+", " ")
}
ku, err := urlQueryUnescape(k)
if err != nil {
return nil, err
}
vu, err := urlQueryUnescape(v)
if err != nil {
return nil, err
}
out[ku] = append(out[ku], vu)
}
return out, nil
}
var urlQueryUnescape = func(s string) (string, error) { return queryUnescape(s) }
func queryUnescape(s string) (string, error) { // minimal wrapper avoids exposing net/url in helpers
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '%' && i+2 < len(s) {
n, err := strconv.ParseUint(s[i+1:i+3], 16, 8)
if err != nil {
return "", err
}
b.WriteByte(byte(n))
i += 2
} else {
b.WriteByte(s[i])
}
}
return b.String(), nil
}
func str(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok {
switch x := v.(type) {
case string:
return x
case json.Number:
return x.String()
}
}
}
return ""
}
func intval(m map[string]any, k string) int {
v, ok := m[k]
if !ok {
return 0
}
switch x := v.(type) {
case float64:
return int(x)
case int:
return x
case string:
n, _ := strconv.Atoi(x)
return n
case json.Number:
n, _ := strconv.Atoi(x.String())
return n
}
return 0
}
func parsePriority(s string) int { n, _ := strconv.Atoi(s); return n }
func firstNonEmpty(v ...string) string {
for _, s := range v {
if s != "" {
return s
}
}
return ""
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
var loginTpl = template.Must(template.New("login").Parse(`<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Notify Gateway Login</title><style>body{font:16px system-ui;max-width:420px;margin:10vh auto;padding:1rem}input,button{width:100%;padding:.7rem;margin:.35rem 0;box-sizing:border-box}.err{color:#a00}</style></head><body><h1>Notify Gateway</h1>{{if .}}<p class="err">{{.}}</p>{{end}}<form method="post" action="/login"><input name="username" placeholder="Benutzer" autocomplete="username"><input type="password" name="password" placeholder="Passwort" autocomplete="current-password"><button>Anmelden</button></form></body></html>`))
func renderLogin(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = loginTpl.Execute(w, msg)
}
File diff suppressed because one or more lines are too long