@@ -90,6 +90,15 @@ type FileWriter struct {
9090 // 0600 by default.
9191 Mode fileMode `json:"mode,omitempty"`
9292
93+ // DirMode controls permissions for any directories created to reach Filename.
94+ // Default: 0700 (current behavior).
95+ //
96+ // Special values:
97+ // - "inherit" → copy the nearest existing parent directory's perms (with r→x normalization)
98+ // - "from_file" → derive from the file Mode (with r→x), e.g. 0644 → 0755, 0600 → 0700
99+ // Numeric octal strings (e.g. "0755") are also accepted. Subject to process umask.
100+ DirMode string `json:"dir_mode,omitempty"`
101+
93102 // Roll toggles log rolling or rotation, which is
94103 // enabled by default.
95104 Roll * bool `json:"roll,omitempty"`
@@ -177,11 +186,33 @@ func (fw FileWriter) OpenWriter() (io.WriteCloser, error) {
177186 // roll log files as a sensible default to avoid disk space exhaustion
178187 roll := fw .Roll == nil || * fw .Roll
179188
180- // create the file if it does not exist; create with the configured mode, or default
181- // to restrictive if not set. (timberjack will reuse the file mode across log rotation)
182- if err := os .MkdirAll (filepath .Dir (fw .Filename ), 0o700 ); err != nil {
183- return nil , err
189+ // Ensure directory exists before opening the file.
190+ dirPath := filepath .Dir (fw .Filename )
191+ switch strings .ToLower (strings .TrimSpace (fw .DirMode )) {
192+ case "" , "0" :
193+ // Preserve current behavior: locked-down directories by default.
194+ if err := os .MkdirAll (dirPath , 0o700 ); err != nil {
195+ return nil , err
196+ }
197+ case "inherit" :
198+ if err := mkdirAllInherit (dirPath ); err != nil {
199+ return nil , err
200+ }
201+ case "from_file" :
202+ if err := mkdirAllFromFile (dirPath , os .FileMode (fw .Mode )); err != nil {
203+ return nil , err
204+ }
205+ default :
206+ dm , err := parseFileMode (fw .DirMode )
207+ if err != nil {
208+ return nil , fmt .Errorf ("dir_mode: %w" , err )
209+ }
210+ if err := os .MkdirAll (dirPath , dm ); err != nil {
211+ return nil , err
212+ }
184213 }
214+
215+ // create/open the file
185216 file , err := os .OpenFile (fw .Filename , os .O_WRONLY | os .O_APPEND | os .O_CREATE , modeIfCreating )
186217 if err != nil {
187218 return nil , err
@@ -234,13 +265,70 @@ func (fw FileWriter) OpenWriter() (io.WriteCloser, error) {
234265 RotateAtMinutes : fw .RollAtMinutes ,
235266 RotateAt : fw .RollAt ,
236267 BackupTimeFormat : fw .BackupTimeFormat ,
268+ FileMode : os .FileMode (fw .Mode ),
237269 }, nil
238270}
239271
272+ // normalizeDirPerm ensures that read bits also have execute bits set.
273+ func normalizeDirPerm (p os.FileMode ) os.FileMode {
274+ if p & 0o400 != 0 {
275+ p |= 0o100
276+ }
277+ if p & 0o040 != 0 {
278+ p |= 0o010
279+ }
280+ if p & 0o004 != 0 {
281+ p |= 0o001
282+ }
283+ return p
284+ }
285+
286+ // mkdirAllInherit creates missing dirs using the nearest existing parent's
287+ // permissions, normalized with r→x.
288+ func mkdirAllInherit (dir string ) error {
289+ if fi , err := os .Stat (dir ); err == nil && fi .IsDir () {
290+ return nil
291+ }
292+ cur := dir
293+ var parent string
294+ for {
295+ next := filepath .Dir (cur )
296+ if next == cur {
297+ parent = next
298+ break
299+ }
300+ if fi , err := os .Stat (next ); err == nil {
301+ if ! fi .IsDir () {
302+ return fmt .Errorf ("path component %s exists and is not a directory" , next )
303+ }
304+ parent = next
305+ break
306+ }
307+ cur = next
308+ }
309+ perm := os .FileMode (0o700 )
310+ if fi , err := os .Stat (parent ); err == nil && fi .IsDir () {
311+ perm = fi .Mode ().Perm ()
312+ }
313+ perm = normalizeDirPerm (perm )
314+ return os .MkdirAll (dir , perm )
315+ }
316+
317+ // mkdirAllFromFile creates missing dirs using the file's mode (with r→x) so
318+ // 0644 → 0755, 0600 → 0700, etc.
319+ func mkdirAllFromFile (dir string , fileMode os.FileMode ) error {
320+ if fi , err := os .Stat (dir ); err == nil && fi .IsDir () {
321+ return nil
322+ }
323+ perm := normalizeDirPerm (fileMode .Perm ()) | 0o200 // ensure owner write on dir so files can be created
324+ return os .MkdirAll (dir , perm )
325+ }
326+
240327// UnmarshalCaddyfile sets up the module from Caddyfile tokens. Syntax:
241328//
242329// file <filename> {
243330// mode <mode>
331+ // dir_mode <mode|inherit|from_file>
244332// roll_disabled
245333// roll_size <size>
246334// roll_uncompressed
@@ -284,6 +372,22 @@ func (fw *FileWriter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
284372 }
285373 fw .Mode = fileMode (mode )
286374
375+ case "dir_mode" :
376+ var val string
377+ if ! d .AllArgs (& val ) {
378+ return d .ArgErr ()
379+ }
380+ val = strings .TrimSpace (val )
381+ switch strings .ToLower (val ) {
382+ case "inherit" , "from_file" :
383+ fw .DirMode = val
384+ default :
385+ if _ , err := parseFileMode (val ); err != nil {
386+ return d .Errf ("parsing dir_mode: %v" , err )
387+ }
388+ fw .DirMode = val
389+ }
390+
287391 case "roll_disabled" :
288392 var f bool
289393 fw .Roll = & f
@@ -352,31 +456,48 @@ func (fw *FileWriter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
352456 fw .RollInterval = duration
353457
354458 case "roll_minutes" :
355- var minutesArrayStr string
356- if ! d .AllArgs (& minutesArrayStr ) {
459+ // Accept either a single comma-separated argument or
460+ // multiple space-separated arguments. Collect all
461+ // remaining args on the line and split on commas.
462+ args := d .RemainingArgs ()
463+ if len (args ) == 0 {
357464 return d .ArgErr ()
358465 }
359- minutesStr := strings .Split (minutesArrayStr , "," )
360- minutes := make ([]int , len (minutesStr ))
361- for i := range minutesStr {
362- ms := strings .Trim (minutesStr [i ], " " )
363- m , err := strconv .Atoi (ms )
364- if err != nil {
365- return d .Errf ("parsing roll_minutes number: %v" , err )
466+ var minutes []int
467+ for _ , arg := range args {
468+ parts := strings .SplitSeq (arg , "," )
469+ for p := range parts {
470+ ms := strings .TrimSpace (p )
471+ if ms == "" {
472+ return d .Errf ("parsing roll_minutes: empty value" )
473+ }
474+ m , err := strconv .Atoi (ms )
475+ if err != nil {
476+ return d .Errf ("parsing roll_minutes number: %v" , err )
477+ }
478+ minutes = append (minutes , m )
366479 }
367- minutes [i ] = m
368480 }
369481 fw .RollAtMinutes = minutes
370482
371483 case "roll_at" :
372- var timeArrayStr string
373- if ! d .AllArgs (& timeArrayStr ) {
484+ // Accept either a single comma-separated argument or
485+ // multiple space-separated arguments. Collect all
486+ // remaining args on the line and split on commas.
487+ args := d .RemainingArgs ()
488+ if len (args ) == 0 {
374489 return d .ArgErr ()
375490 }
376- timeStr := strings .Split (timeArrayStr , "," )
377- times := make ([]string , len (timeStr ))
378- for i := range timeStr {
379- times [i ] = strings .Trim (timeStr [i ], " " )
491+ var times []string
492+ for _ , arg := range args {
493+ parts := strings .SplitSeq (arg , "," )
494+ for p := range parts {
495+ ts := strings .TrimSpace (p )
496+ if ts == "" {
497+ return d .Errf ("parsing roll_at: empty value" )
498+ }
499+ times = append (times , ts )
500+ }
380501 }
381502 fw .RollAt = times
382503
0 commit comments