From 12780d7803217243b024825fda935ff0e97a8855 Mon Sep 17 00:00:00 2001 From: eduard256 Date: Sat, 18 Apr 2026 08:36:48 +0000 Subject: [PATCH] Add credential extraction registry for generate Protocols like Xiaomi need credentials (tokens) in a separate top-level YAML section, not in the stream URL itself. Introduce a registry pattern mirroring streams.HandleFunc / tester.RegisterSource: - pkg/generate/registry.go: ExtractFunc + RegisterExtract - Extractors clean the URL (strip ?token=...) and return section/key/value - writeCredentials emits sorted sections between go2rtc: and cameras: - upsertCredentials in addToConfig merges into existing sections: * replaces value if key exists (token refresh) * inserts in sorted order if new * creates new top-level section before cameras: if missing Xiaomi registers its extractor from internal/xiaomi/xiaomi.go. Adding Tapo/Ring/Roborock later is one line + a small function in their internal/*/ module -- zero changes in pkg/generate/. --- internal/xiaomi/xiaomi.go | 24 ++++++ pkg/generate/config.go | 40 +++++++++- pkg/generate/insert.go | 154 ++++++++++++++++++++++++++++++++++++++ pkg/generate/registry.go | 24 ++++++ pkg/generate/writer.go | 33 ++++++++ 5 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 pkg/generate/registry.go diff --git a/internal/xiaomi/xiaomi.go b/internal/xiaomi/xiaomi.go index a8a1dc9..44f2a97 100644 --- a/internal/xiaomi/xiaomi.go +++ b/internal/xiaomi/xiaomi.go @@ -15,6 +15,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto" "github.com/eduard256/strix/internal/api" "github.com/eduard256/strix/internal/app" + "github.com/eduard256/strix/pkg/generate" "github.com/eduard256/strix/pkg/tester" "github.com/rs/zerolog" ) @@ -53,6 +54,29 @@ func Init() { }) api.HandleFunc("api/xiaomi", apiXiaomi) + + generate.RegisterExtract("xiaomi", extractForConfig) +} + +// extractForConfig strips ?token=... from xiaomi:// URL and returns +// userID + token for a top-level `xiaomi:` yaml section. +// ex. xiaomi://4161148305:cn@10.0.20.229?did=450924912&model=X&token=V1:... +// -> xiaomi://4161148305:cn@10.0.20.229?did=450924912&model=X, "xiaomi", "4161148305", "V1:..." +func extractForConfig(rawURL string) (cleaned, section, key, value string) { + u, err := url.Parse(rawURL) + if err != nil || u.User == nil { + return rawURL, "", "", "" + } + + q := u.Query() + token := q.Get("token") + if token == "" { + return rawURL, "", "", "" + } + q.Del("token") + u.RawQuery = q.Encode() + + return u.String(), "xiaomi", u.User.Username(), token } var log zerolog.Logger diff --git a/pkg/generate/config.go b/pkg/generate/config.go index eb7e623..e4b1664 100644 --- a/pkg/generate/config.go +++ b/pkg/generate/config.go @@ -54,10 +54,16 @@ func buildInfo(req *Request) *cameraInfo { streamBase = sanitized } + mainSource, mainSection, mainKey, mainValue := runExtract(req.MainStream) + info := &cameraInfo{ CameraName: base, MainStreamName: streamBase + "_main", - MainSource: req.MainStream, + MainSource: mainSource, + } + + if mainSection != "" { + info.addCredential(mainSection, mainKey, mainValue) } if req.Name != "" { @@ -70,7 +76,11 @@ func buildInfo(req *Request) *cameraInfo { info.MainStreamName = req.Go2RTC.MainStreamName } if req.Go2RTC.MainStreamSource != "" { - info.MainSource = req.Go2RTC.MainStreamSource + src, section, key, value := runExtract(req.Go2RTC.MainStreamSource) + info.MainSource = src + if section != "" { + info.addCredential(section, key, value) + } } } @@ -95,7 +105,12 @@ func buildInfo(req *Request) *cameraInfo { if req.Name != "" { subName = req.Name + "_sub" } - subSource := req.SubStream + + subSource, subSection, subKey, subValue := runExtract(req.SubStream) + if subSection != "" { + info.addCredential(subSection, subKey, subValue) + } + subPath := "rtsp://127.0.0.1:8554/" + subName if needMP4[subScheme] { subPath += "?mp4" @@ -107,7 +122,11 @@ func buildInfo(req *Request) *cameraInfo { subName = req.Go2RTC.SubStreamName } if req.Go2RTC.SubStreamSource != "" { - subSource = req.Go2RTC.SubStreamSource + src, section, key, value := runExtract(req.Go2RTC.SubStreamSource) + subSource = src + if section != "" { + info.addCredential(section, key, value) + } } } if req.Frigate != nil { @@ -137,6 +156,8 @@ func newConfig(info *cameraInfo, req *Request) string { b.WriteString("go2rtc:\n streams:\n") writeStreamLines(&b, info) + writeCredentials(&b, info.Credentials) + b.WriteString("cameras:\n") writeCameraBlock(&b, info, req) @@ -156,6 +177,17 @@ type cameraInfo struct { SubSource string SubPath string SubInputArgs string + Credentials map[string]map[string]string // section -> key -> value +} + +func (c *cameraInfo) addCredential(section, key, value string) { + if c.Credentials == nil { + c.Credentials = map[string]map[string]string{} + } + if c.Credentials[section] == nil { + c.Credentials[section] = map[string]string{} + } + c.Credentials[section][key] = value } func urlScheme(rawURL string) string { diff --git a/pkg/generate/insert.go b/pkg/generate/insert.go index d3f35d6..7a3d1c8 100644 --- a/pkg/generate/insert.go +++ b/pkg/generate/insert.go @@ -3,6 +3,7 @@ package generate import ( "fmt" "regexp" + "sort" "strings" ) @@ -64,6 +65,9 @@ func addToConfig(existing string, info *cameraInfo, req *Request) (*Response, er } result = append(result, rest[split:]...) + // upsert credential sections (xiaomi, tapo, ...) before cameras: + result, added = upsertCredentials(result, info.Credentials, added) + config := strings.Join(result, "\n") addedLines := make([]int, 0, len(added)) @@ -156,6 +160,156 @@ func findStreamInsertPoint(lines []string) int { return -1 } +// upsertCredentials merges creds into existing top-level sections. For each +// section: if a matching line ` "":` exists -- replace its value; else +// insert in sorted order. If the section itself doesn't exist -- create a new +// top-level block just before `cameras:`. +func upsertCredentials(lines []string, creds map[string]map[string]string, added map[int]bool) ([]string, map[int]bool) { + if len(creds) == 0 { + return lines, added + } + + sections := make([]string, 0, len(creds)) + for s := range creds { + sections = append(sections, s) + } + sort.Strings(sections) + + for _, section := range sections { + lines, added = upsertSection(lines, section, creds[section], added) + } + + return lines, added +} + +// upsertSection updates or appends a single top-level section. +var reCredKey = regexp.MustCompile(`^\s{2}"([^"]+)":`) + +func upsertSection(lines []string, section string, kv map[string]string, added map[int]bool) ([]string, map[int]bool) { + reHeader := regexp.MustCompile(`^` + regexp.QuoteMeta(section) + `:\s*$`) + + headerIdx := -1 + for i, line := range lines { + if reHeader.MatchString(line) { + headerIdx = i + break + } + } + + if headerIdx == -1 { + return insertNewSection(lines, section, kv, added) + } + + // section exists -- find last content line of the section (skip trailing blanks) + end := len(lines) + for i := headerIdx + 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "" || reTopLevel.MatchString(lines[i]) { + end = i + break + } + } + + keys := make([]string, 0, len(kv)) + for k := range kv { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + newLine := fmt.Sprintf(" %q: %s", k, kv[k]) + + // try replace -- no length change, just mark modified line as added + replaced := false + for i := headerIdx + 1; i < end; i++ { + if m := reCredKey.FindStringSubmatch(lines[i]); m != nil && m[1] == k { + if lines[i] != newLine { + lines[i] = newLine + added[i] = true + } + replaced = true + break + } + } + if replaced { + continue + } + + // insert in sorted order within section + insertAt := headerIdx + 1 + for i := headerIdx + 1; i < end; i++ { + if m := reCredKey.FindStringSubmatch(lines[i]); m != nil { + if m[1] < k { + insertAt = i + 1 + } else { + break + } + } else { + insertAt = i + 1 + } + } + + lines = append(lines[:insertAt], append([]string{newLine}, lines[insertAt:]...)...) + added = shiftAdded(added, insertAt) + added[insertAt] = true + end++ + } + + return lines, added +} + +func insertNewSection(lines []string, section string, kv map[string]string, added map[int]bool) ([]string, map[int]bool) { + camIdx := -1 + for i, line := range lines { + if reCamerasHeader.MatchString(line) { + camIdx = i + break + } + } + if camIdx == -1 { + camIdx = len(lines) + } + + // insert point: right before the blank line that precedes cameras: + // keep one blank line between blocks + insertAt := camIdx + for insertAt > 0 && strings.TrimSpace(lines[insertAt-1]) == "" { + insertAt-- + } + + block := []string{section + ":"} + keys := make([]string, 0, len(kv)) + for k := range kv { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + block = append(block, fmt.Sprintf(" %q: %s", k, kv[k])) + } + block = append(block, "") + + lines = append(lines[:insertAt], append(block, lines[insertAt:]...)...) + added = shiftAdded(added, insertAt) + for i := range block { + added[insertAt+i] = true + } + + return lines, added +} + +// shiftAdded moves all marks at index >= from by +1. Also used with from=len(lines) +// as a no-op shift (just return same map). +func shiftAdded(added map[int]bool, from int) map[int]bool { + out := make(map[int]bool, len(added)) + for i := range added { + if i >= from { + out[i+1] = true + } else { + out[i] = true + } + } + return out +} + func findCameraInsertPoint(lines []string) int { in := false last := -1 diff --git a/pkg/generate/registry.go b/pkg/generate/registry.go new file mode 100644 index 0000000..5c4aa27 --- /dev/null +++ b/pkg/generate/registry.go @@ -0,0 +1,24 @@ +package generate + +import "strings" + +// ExtractFunc cleans rawURL (ex. strips ?token=...) and returns a top-level +// YAML section name + key/value to upsert into the config. +// Returns empty section if the URL has nothing to extract -- cleaned URL +// is still used as-is. +type ExtractFunc func(rawURL string) (cleaned, section, key, value string) + +var extractors = map[string]ExtractFunc{} + +func RegisterExtract(scheme string, fn ExtractFunc) { + extractors[scheme] = fn +} + +func runExtract(rawURL string) (cleaned, section, key, value string) { + if i := strings.IndexByte(rawURL, ':'); i > 0 { + if fn := extractors[rawURL[:i]]; fn != nil { + return fn(rawURL) + } + } + return rawURL, "", "", "" +} diff --git a/pkg/generate/writer.go b/pkg/generate/writer.go index 17ab432..dfb830d 100644 --- a/pkg/generate/writer.go +++ b/pkg/generate/writer.go @@ -2,6 +2,7 @@ package generate import ( "fmt" + "sort" "strings" ) @@ -17,6 +18,38 @@ func writeStreamLines(b *strings.Builder, info *cameraInfo) { b.WriteByte('\n') } +// writeCredentials writes top-level credential sections (xiaomi, tapo, ring, ...) +// populated by registered ExtractFunc handlers. Sorted by section, then by key. +// ex. +// xiaomi: +// "4161148305": V1:9d2w... +func writeCredentials(b *strings.Builder, creds map[string]map[string]string) { + if len(creds) == 0 { + return + } + + sections := make([]string, 0, len(creds)) + for s := range creds { + sections = append(sections, s) + } + sort.Strings(sections) + + for _, section := range sections { + fmt.Fprintf(b, "%s:\n", section) + + keys := make([]string, 0, len(creds[section])) + for k := range creds[section] { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + fmt.Fprintf(b, " %q: %s\n", k, creds[section][k]) + } + b.WriteByte('\n') + } +} + func writeCameraBlock(b *strings.Builder, info *cameraInfo, req *Request) { fmt.Fprintf(b, " %s:\n", info.CameraName)