diff --git a/internal/app/config.go b/internal/app/config.go index f0eb36e0..0f95894a 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -7,7 +7,7 @@ import ( "strings" "sync" - "github.com/AlexxIT/go2rtc/pkg/shell" + "github.com/AlexxIT/go2rtc/pkg/creds" "github.com/AlexxIT/go2rtc/pkg/yaml" ) @@ -71,13 +71,15 @@ func initConfig(confs flagConfig) { // config as file if ConfigPath == "" { ConfigPath = conf + initStorage() } if data, _ = os.ReadFile(conf); data == nil { continue } - data = []byte(shell.ReplaceEnvVars(string(data))) + loadEnv(data) + data = creds.ReplaceVars(data) configs = append(configs, data) } } diff --git a/internal/app/log.go b/internal/app/log.go index b8ca4aa5..9ec89a2c 100644 --- a/internal/app/log.go +++ b/internal/app/log.go @@ -6,6 +6,7 @@ import ( "strings" "sync" + "github.com/AlexxIT/go2rtc/pkg/creds" "github.com/mattn/go-isatty" "github.com/rs/zerolog" ) @@ -88,6 +89,8 @@ func initLogger() { writer = MemoryLog } + writer = creds.SecretWriter(writer) + lvl, _ := zerolog.ParseLevel(modules["level"]) Logger = zerolog.New(writer).Level(lvl) diff --git a/internal/app/storage.go b/internal/app/storage.go new file mode 100644 index 00000000..cfa1ca91 --- /dev/null +++ b/internal/app/storage.go @@ -0,0 +1,56 @@ +package app + +import ( + "sync" + + "github.com/AlexxIT/go2rtc/pkg/creds" + "github.com/AlexxIT/go2rtc/pkg/yaml" +) + +func initStorage() { + storage = &envStorage{data: make(map[string]string)} + creds.SetStorage(storage) +} + +func loadEnv(data []byte) { + var cfg struct { + Env map[string]string `yaml:"env"` + } + + if err := yaml.Unmarshal(data, &cfg); err != nil { + return + } + + storage.mu.Lock() + for name, value := range cfg.Env { + storage.data[name] = value + creds.AddSecret(value) + } + storage.mu.Unlock() +} + +var storage *envStorage + +type envStorage struct { + data map[string]string + mu sync.Mutex +} + +func (s *envStorage) SetValue(name, value string) error { + if err := PatchConfig([]string{"env", name}, value); err != nil { + return err + } + + s.mu.Lock() + s.data[name] = value + s.mu.Unlock() + + return nil +} + +func (s *envStorage) GetValue(name string) (value string, ok bool) { + s.mu.Lock() + value, ok = s.data[name] + s.mu.Unlock() + return +} diff --git a/internal/streams/api.go b/internal/streams/api.go index d162cdf9..28f09708 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -6,10 +6,13 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/creds" "github.com/AlexxIT/go2rtc/pkg/probe" ) func apiStreams(w http.ResponseWriter, r *http.Request) { + w = creds.SecretResponse(w) + query := r.URL.Query() src := query.Get("src") @@ -121,6 +124,8 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { } dot = append(dot, '}') + dot = []byte(creds.SecretString(string(dot))) + api.Response(w, dot, "text/vnd.graphviz") } diff --git a/pkg/creds/README.md b/pkg/creds/README.md new file mode 100644 index 00000000..1909a206 --- /dev/null +++ b/pkg/creds/README.md @@ -0,0 +1,7 @@ +# Credentials + +This module allows you to get variables: + +- from custom storage (ex. config file) +- from [credential files](https://systemd.io/CREDENTIALS/) +- from environment variables diff --git a/pkg/creds/creds.go b/pkg/creds/creds.go new file mode 100644 index 00000000..84bc275a --- /dev/null +++ b/pkg/creds/creds.go @@ -0,0 +1,79 @@ +package creds + +import ( + "errors" + "os" + "path/filepath" + "regexp" + "strings" +) + +type Storage interface { + SetValue(name, value string) error + GetValue(name string) (string, bool) +} + +var storage Storage + +func SetStorage(s Storage) { + storage = s +} + +func SetValue(name, value string) error { + if storage == nil { + return errors.New("credentials: storage not initialized") + } + if err := storage.SetValue(name, value); err != nil { + return err + } + AddSecret(value) + return nil +} + +func GetValue(name string) (value string, ok bool) { + value, ok = getValue(name) + AddSecret(value) + return +} + +func getValue(name string) (string, bool) { + if storage != nil { + if value, ok := storage.GetValue(name); ok { + return value, true + } + } + + if dir, ok := os.LookupEnv("CREDENTIALS_DIRECTORY"); ok { + if value, _ := os.ReadFile(filepath.Join(dir, name)); value != nil { + return strings.TrimSpace(string(value)), true + } + } + + return os.LookupEnv(name) +} + +// ReplaceVars - support format ${CAMERA_PASSWORD} and ${RTSP_USER:admin} +func ReplaceVars(data []byte) []byte { + re := regexp.MustCompile(`\${([^}{]+)}`) + return re.ReplaceAllFunc(data, func(match []byte) []byte { + key := string(match[2 : len(match)-1]) + + var def string + var defok bool + + if i := strings.IndexByte(key, ':'); i > 0 { + key, def = key[:i], key[i+1:] + defok = true + } + + if value, ok := GetValue(key); ok { + return []byte(value) + } + + if defok { + return []byte(def) + } + + return match + }) +} diff --git a/pkg/creds/secrets.go b/pkg/creds/secrets.go new file mode 100644 index 00000000..a9a0094e --- /dev/null +++ b/pkg/creds/secrets.go @@ -0,0 +1,83 @@ +package creds + +import ( + "io" + "net/http" + "slices" + "strings" + "sync" +) + +func AddSecret(value string) { + if value == "" { + return + } + + secretsMu.Lock() + defer secretsMu.Unlock() + + if slices.Contains(secrets, value) { + return + } + + secrets = append(secrets, value) + secretsReplacer = nil +} + +var secrets []string +var secretsMu sync.Mutex +var secretsReplacer *strings.Replacer + +func getReplacer() *strings.Replacer { + secretsMu.Lock() + defer secretsMu.Unlock() + + if secretsReplacer == nil { + oldnew := make([]string, 0, 2*len(secrets)) + for _, s := range secrets { + oldnew = append(oldnew, s, "***") + } + secretsReplacer = strings.NewReplacer(oldnew...) + } + + return secretsReplacer +} + +func SecretString(s string) string { + re := getReplacer() + return re.Replace(s) +} + +func SecretWriter(w io.Writer) io.Writer { + return &secretWriter{w} +} + +type secretWriter struct { + w io.Writer +} + +func (s *secretWriter) Write(b []byte) (int, error) { + re := getReplacer() + return re.WriteString(s.w, string(b)) +} + +type secretResponse struct { + w http.ResponseWriter +} + +func (s *secretResponse) Header() http.Header { + return s.w.Header() +} + +func (s *secretResponse) Write(b []byte) (int, error) { + re := getReplacer() + return re.WriteString(s.w, string(b)) +} + +func (s *secretResponse) WriteHeader(statusCode int) { + s.w.WriteHeader(statusCode) +} + +func SecretResponse(w http.ResponseWriter) http.ResponseWriter { + return &secretResponse{w} +} diff --git a/pkg/creds/secrets_test.go b/pkg/creds/secrets_test.go new file mode 100644 index 00000000..83f1908a --- /dev/null +++ b/pkg/creds/secrets_test.go @@ -0,0 +1,15 @@ +package creds + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestString(t *testing.T) { + AddSecret("admin") + AddSecret("pa$$word") + + s := SecretString("rtsp://admin:pa$$word@192.168.1.123/stream1") + require.Equal(t, "rtsp://***:***@192.168.1.123/stream1", s) +} diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index 75df671f..e04a58c4 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -3,8 +3,6 @@ package shell import ( "os" "os/signal" - "path/filepath" - "regexp" "strings" "syscall" ) @@ -38,39 +36,6 @@ func QuoteSplit(s string) []string { return a } -// ReplaceEnvVars - support format ${CAMERA_PASSWORD} and ${RTSP_USER:admin} -func ReplaceEnvVars(text string) string { - re := regexp.MustCompile(`\${([^}{]+)}`) - return re.ReplaceAllStringFunc(text, func(match string) string { - key := match[2 : len(match)-1] - - var def string - var dok bool - - if i := strings.IndexByte(key, ':'); i > 0 { - key, def = key[:i], key[i+1:] - dok = true - } - - if dir, vok := os.LookupEnv("CREDENTIALS_DIRECTORY"); vok { - value, err := os.ReadFile(filepath.Join(dir, key)) - if err == nil { - return strings.TrimSpace(string(value)) - } - } - - if value, vok := os.LookupEnv(key); vok { - return value - } - - if dok { - return def - } - - return match - }) -} - func RunUntilSignal() { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)