@@ -2,11 +2,14 @@ package loaders
22
33import (
44 "os"
5+ "path/filepath"
6+ "strings"
57
68 "github.com/rs/zerolog/log"
79 "github.com/traefik/paerser/cli"
810 "github.com/traefik/paerser/file"
911 "github.com/traefik/paerser/flag"
12+ "gopkg.in/yaml.v3"
1013)
1114
1215type FileLoader struct {}
@@ -32,11 +35,122 @@ func (f *FileLoader) Load(args []string, cmd *cli.Command) (bool, error) {
3235
3336 log .Warn ().Msg ("Using experimental file config loader, this feature is experimental and may change or be removed in future releases" )
3437
35- err = file . Decode ( flags [configFileFlag ], cmd . Configuration )
38+ filePath := flags [configFileFlag ]
3639
37- if err != nil {
40+ ext := strings .ToLower (filepath .Ext (filePath ))
41+ if ext == ".yml" || ext == ".yaml" {
42+ content , err := os .ReadFile (filepath .Clean (filePath ))
43+ if err != nil {
44+ return false , err
45+ }
46+
47+ data := make (map [string ]interface {})
48+ if err = yaml .Unmarshal (content , data ); err != nil {
49+ return false , err
50+ }
51+
52+ normalizeUsersInRawMap (data )
53+
54+ if err = file .DecodeFromRawMap (data , cmd .Configuration ); err != nil {
55+ return false , err
56+ }
57+
58+ return true , nil
59+ }
60+
61+ if err = file .Decode (filePath , cmd .Configuration ); err != nil {
3862 return false , err
3963 }
4064
4165 return true , nil
4266}
67+
68+ // normalizeUsersInRawMap converts the legacy users list format to the map format that paerser
69+ // expects for map[string]UserConfig. Both of these YAML shapes are accepted:
70+ //
71+ // # legacy: list of "username:hash" or "username:hash:totp" strings
72+ // auth:
73+ // users:
74+ // - alice:$2a$...
75+ // - bob:$2a$...:TOTPSECRET
76+ //
77+ // # legacy: single-key map per entry { alice: "$2a$..." }
78+ // auth:
79+ // users:
80+ // - alice: "$2a$..."
81+ //
82+ // # new map form (passed through unchanged)
83+ // auth:
84+ // users:
85+ // alice:
86+ // password: "$2a$..."
87+ func normalizeUsersInRawMap (data map [string ]interface {}) {
88+ authRaw , ok := data ["auth" ]
89+ if ! ok {
90+ return
91+ }
92+
93+ authMap , ok := authRaw .(map [string ]interface {})
94+ if ! ok {
95+ return
96+ }
97+
98+ usersRaw , ok := authMap ["users" ]
99+ if ! ok {
100+ return
101+ }
102+
103+ // Already a map — the new structured form, leave it alone.
104+ if _ , isMap := usersRaw .(map [string ]interface {}); isMap {
105+ return
106+ }
107+
108+ slice , ok := usersRaw .([]interface {})
109+ if ! ok {
110+ return
111+ }
112+
113+ normalized := make (map [string ]interface {})
114+
115+ for _ , entry := range slice {
116+ switch v := entry .(type ) {
117+ case string :
118+ // "username:hash" or "username:hash:totp"
119+ if strings .Contains (v , "$$" ) {
120+ v = strings .ReplaceAll (v , "$$" , "$" )
121+ }
122+ parts := strings .SplitN (v , ":" , 4 )
123+ if len (parts ) < 2 || len (parts ) > 3 {
124+ continue
125+ }
126+ username := strings .TrimSpace (parts [0 ])
127+ password := strings .TrimSpace (parts [1 ])
128+ if username == "" || password == "" {
129+ continue
130+ }
131+ userMap := map [string ]interface {}{"password" : password }
132+ if len (parts ) == 3 {
133+ totp := strings .TrimSpace (parts [2 ])
134+ if totp != "" {
135+ userMap ["totpSecret" ] = totp
136+ }
137+ }
138+ normalized [username ] = userMap
139+
140+ case map [string ]interface {}:
141+ // Single-key map: { "username": "hash" } or { "username": { "password": "hash", ... } }
142+ for username , val := range v {
143+ switch pw := val .(type ) {
144+ case string :
145+ normalized [username ] = map [string ]interface {}{"password" : pw }
146+ case map [string ]interface {}:
147+ normalized [username ] = pw
148+ }
149+ }
150+ }
151+ }
152+
153+ if len (normalized ) > 0 {
154+ authMap ["users" ] = normalized
155+ }
156+ }
0 commit comments