@@ -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,129 @@ 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+ // Re-marshal to YAML and use file.DecodeContent so we stay within the
55+ // existing public paerser API without modifying the submodule.
56+ normalised , err := yaml .Marshal (data )
57+ if err != nil {
58+ return false , err
59+ }
60+
61+ if err = file .DecodeContent (string (normalised ), ext , cmd .Configuration ); err != nil {
62+ return false , err
63+ }
64+
65+ return true , nil
66+ }
67+
68+ if err = file .Decode (filePath , cmd .Configuration ); err != nil {
3869 return false , err
3970 }
4071
4172 return true , nil
4273}
74+
75+ // normalizeUsersInRawMap converts the legacy users list format to the map format that paerser
76+ // expects for map[string]UserConfig. Both of these YAML shapes are accepted:
77+ //
78+ // # legacy: list of "username:hash" or "username:hash:totp" strings
79+ // auth:
80+ // users:
81+ // - alice:$2a$...
82+ // - bob:$2a$...:TOTPSECRET
83+ //
84+ // # legacy: single-key map per entry { alice: "$2a$..." }
85+ // auth:
86+ // users:
87+ // - alice: "$2a$..."
88+ //
89+ // # new map form (passed through unchanged)
90+ // auth:
91+ // users:
92+ // alice:
93+ // password: "$2a$..."
94+ func normalizeUsersInRawMap (data map [string ]interface {}) {
95+ authRaw , ok := data ["auth" ]
96+ if ! ok {
97+ return
98+ }
99+
100+ authMap , ok := authRaw .(map [string ]interface {})
101+ if ! ok {
102+ return
103+ }
104+
105+ usersRaw , ok := authMap ["users" ]
106+ if ! ok {
107+ return
108+ }
109+
110+ // Already a map — the new structured form, leave it alone.
111+ if _ , isMap := usersRaw .(map [string ]interface {}); isMap {
112+ return
113+ }
114+
115+ slice , ok := usersRaw .([]interface {})
116+ if ! ok {
117+ return
118+ }
119+
120+ normalized := make (map [string ]interface {})
121+
122+ for _ , entry := range slice {
123+ switch v := entry .(type ) {
124+ case string :
125+ // "username:hash" or "username:hash:totp"
126+ if strings .Contains (v , "$$" ) {
127+ v = strings .ReplaceAll (v , "$$" , "$" )
128+ }
129+ parts := strings .SplitN (v , ":" , 4 )
130+ if len (parts ) < 2 || len (parts ) > 3 {
131+ continue
132+ }
133+ username := strings .TrimSpace (parts [0 ])
134+ password := strings .TrimSpace (parts [1 ])
135+ if username == "" || password == "" {
136+ continue
137+ }
138+ userMap := map [string ]interface {}{"password" : password }
139+ if len (parts ) == 3 {
140+ totp := strings .TrimSpace (parts [2 ])
141+ if totp != "" {
142+ userMap ["totpSecret" ] = totp
143+ }
144+ }
145+ normalized [username ] = userMap
146+
147+ case map [string ]interface {}:
148+ // Single-key map: { "username": "hash" } or { "username": { "password": "hash", ... } }
149+ for username , val := range v {
150+ switch pw := val .(type ) {
151+ case string :
152+ normalized [username ] = map [string ]interface {}{"password" : pw }
153+ case map [string ]interface {}:
154+ normalized [username ] = pw
155+ }
156+ }
157+ }
158+ }
159+
160+ if len (normalized ) > 0 {
161+ authMap ["users" ] = normalized
162+ }
163+ }
0 commit comments