Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 14:45 UTC
logging: add DirMode options and propagate FileMode to rotations (#7335)
Co-authored-by: Francis Lavoie <lavofr@gmail.com>
Dean Ruina committed
Feb 23, 2026 at 09:27 UTC
294dfff4435c31b7c8d20d26067365b0a0016610
4 files changed
+450
-25
caddytest/integration/caddyfile_adapt/log_roll_days.caddyfiletest
+49
-5
@@ -1,7 +1,9 @@
1
:80
2
3
-log {
3
+log one {
4
output file /var/log/access.log {
5
+ mode 0644
6
+ dir_mode 0755
7
roll_size 1gb
8
roll_uncompressed
9
roll_local_time
@@ -9,18 +11,33 @@ log {
11
roll_keep_for 90d
12
}
13
}
14
+log two {
15
+ output file /var/log/access-2.log {
16
+ mode 0777
17
+ dir_mode from_file
18
+ roll_size 1gib
19
+ roll_interval 12h
20
+ roll_at 00:00 06:00 12:00,18:00
21
+ roll_minutes 10 40 45,46
22
+ roll_keep 10
23
+ roll_keep_for 90d
24
+ }
25
+}
26
----------
27
{
28
"logging": {
29
"logs": {
30
"default": {
31
"exclude": [
18
- "http.log.access.log0"
32
+ "http.log.access.one",
33
+ "http.log.access.two"
34
]
35
},
21
- "log0": {
36
+ "one": {
37
"writer": {
38
+ "dir_mode": "0755",
39
"filename": "/var/log/access.log",
40
+ "mode": "0644",
41
"output": "file",
42
"roll_gzip": false,
43
"roll_keep": 5,
@@ -29,7 +46,34 @@ log {
46
"roll_size_mb": 954
47
},
48
"include": [
32
- "http.log.access.log0"
49
+ "http.log.access.one"
50
+ ]
51
+ },
52
+ "two": {
53
+ "writer": {
54
+ "dir_mode": "from_file",
55
+ "filename": "/var/log/access-2.log",
56
+ "mode": "0777",
57
+ "output": "file",
58
+ "roll_at": [
59
+ "00:00",
60
+ "06:00",
61
+ "12:00",
62
+ "18:00"
63
+ ],
64
+ "roll_interval": 43200000000000,
65
+ "roll_keep": 10,
66
+ "roll_keep_days": 90,
67
+ "roll_minutes": [
68
+ 10,
69
+ 40,
70
+ 45,
71
+ 46
72
+ ],
73
+ "roll_size_mb": 1024
74
+ },
75
+ "include": [
76
+ "http.log.access.two"
77
]
78
}
79
}
@@ -42,7 +86,7 @@ log {
86
":80"
87
],
88
"logs": {
45
- "default_logger_name": "log0"
89
+ "default_logger_name": "two"
90
}
91
}
92
}
modules/logging/filewriter.go
+141
-20
@@ -90,6 +90,15 @@ type FileWriter struct {
90
// 0600 by default.
91
Mode fileMode `json:"mode,omitempty"`
92
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
+
102
// Roll toggles log rolling or rotation, which is
103
// enabled by default.
104
Roll *bool `json:"roll,omitempty"`
@@ -177,11 +186,33 @@ func (fw FileWriter) OpenWriter() (io.WriteCloser, error) {
186
// roll log files as a sensible default to avoid disk space exhaustion
187
roll := fw.Roll == nil || *fw.Roll
188
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
+ }
213
}
214
+
215
+ // create/open the file
216
file, err := os.OpenFile(fw.Filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, modeIfCreating)
217
if err != nil {
218
return nil, err
@@ -234,13 +265,70 @@ func (fw FileWriter) OpenWriter() (io.WriteCloser, error) {
265
RotateAtMinutes: fw.RollAtMinutes,
266
RotateAt: fw.RollAt,
267
BackupTimeFormat: fw.BackupTimeFormat,
268
+ FileMode: os.FileMode(fw.Mode),
269
}, nil
270
}
271
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
+
327
// UnmarshalCaddyfile sets up the module from Caddyfile tokens. Syntax:
328
//
329
// file <filename> {
330
// mode <mode>
331
+// dir_mode <mode|inherit|from_file>
332
// roll_disabled
333
// roll_size <size>
334
// roll_uncompressed
@@ -284,6 +372,22 @@ func (fw *FileWriter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
372
}
373
fw.Mode = fileMode(mode)
374
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
+
391
case "roll_disabled":
392
var f bool
393
fw.Roll = &f
@@ -352,31 +456,48 @@ func (fw *FileWriter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
456
fw.RollInterval = duration
457
458
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 {
464
return d.ArgErr()
465
}
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)
479
}
367
- minutes[i] = m
480
}
481
fw.RollAtMinutes = minutes
482
483
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 {
489
return d.ArgErr()
490
}
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
+ }
501
}
502
fw.RollAt = times
503
modules/logging/filewriter_test.go
+222
@@ -385,3 +385,225 @@ func TestFileModeModification(t *testing.T) {
385
t.Errorf("file mode is %v, want %v", st.Mode(), want)
386
}
387
}
388
+
389
+func TestDirMode_Inherit(t *testing.T) {
390
+ m := syscall.Umask(0)
391
+ defer syscall.Umask(m)
392
+
393
+ parent := t.TempDir()
394
+ if err := os.Chmod(parent, 0o755); err != nil {
395
+ t.Fatal(err)
396
+ }
397
+
398
+ targetDir := filepath.Join(parent, "a", "b")
399
+ fw := &FileWriter{
400
+ Filename: filepath.Join(targetDir, "test.log"),
401
+ DirMode: "inherit",
402
+ Mode: 0o640,
403
+ Roll: func() *bool { f := false; return &f }(),
404
+ }
405
+ w, err := fw.OpenWriter()
406
+ if err != nil {
407
+ t.Fatal(err)
408
+ }
409
+ _ = w.Close()
410
+
411
+ st, err := os.Stat(targetDir)
412
+ if err != nil {
413
+ t.Fatal(err)
414
+ }
415
+ if got := st.Mode().Perm(); got != 0o755 {
416
+ t.Fatalf("dir perm = %o, want 0755", got)
417
+ }
418
+}
419
+
420
+func TestDirMode_FromFile(t *testing.T) {
421
+ m := syscall.Umask(0)
422
+ defer syscall.Umask(m)
423
+
424
+ base := t.TempDir()
425
+
426
+ dir1 := filepath.Join(base, "logs1")
427
+ fw1 := &FileWriter{
428
+ Filename: filepath.Join(dir1, "app.log"),
429
+ DirMode: "from_file",
430
+ Mode: 0o644, // => dir 0755
431
+ Roll: func() *bool { f := false; return &f }(),
432
+ }
433
+ w1, err := fw1.OpenWriter()
434
+ if err != nil {
435
+ t.Fatal(err)
436
+ }
437
+ _ = w1.Close()
438
+
439
+ st1, err := os.Stat(dir1)
440
+ if err != nil {
441
+ t.Fatal(err)
442
+ }
443
+ if got := st1.Mode().Perm(); got != 0o755 {
444
+ t.Fatalf("dir perm = %o, want 0755", got)
445
+ }
446
+
447
+ dir2 := filepath.Join(base, "logs2")
448
+ fw2 := &FileWriter{
449
+ Filename: filepath.Join(dir2, "app.log"),
450
+ DirMode: "from_file",
451
+ Mode: 0o600, // => dir 0700
452
+ Roll: func() *bool { f := false; return &f }(),
453
+ }
454
+ w2, err := fw2.OpenWriter()
455
+ if err != nil {
456
+ t.Fatal(err)
457
+ }
458
+ _ = w2.Close()
459
+
460
+ st2, err := os.Stat(dir2)
461
+ if err != nil {
462
+ t.Fatal(err)
463
+ }
464
+ if got := st2.Mode().Perm(); got != 0o700 {
465
+ t.Fatalf("dir perm = %o, want 0700", got)
466
+ }
467
+}
468
+
469
+func TestDirMode_ExplicitOctal(t *testing.T) {
470
+ m := syscall.Umask(0)
471
+ defer syscall.Umask(m)
472
+
473
+ base := t.TempDir()
474
+ dest := filepath.Join(base, "logs3")
475
+ fw := &FileWriter{
476
+ Filename: filepath.Join(dest, "app.log"),
477
+ DirMode: "0750",
478
+ Mode: 0o640,
479
+ Roll: func() *bool { f := false; return &f }(),
480
+ }
481
+ w, err := fw.OpenWriter()
482
+ if err != nil {
483
+ t.Fatal(err)
484
+ }
485
+ _ = w.Close()
486
+
487
+ st, err := os.Stat(dest)
488
+ if err != nil {
489
+ t.Fatal(err)
490
+ }
491
+ if got := st.Mode().Perm(); got != 0o750 {
492
+ t.Fatalf("dir perm = %o, want 0750", got)
493
+ }
494
+}
495
+
496
+func TestDirMode_Default0700(t *testing.T) {
497
+ m := syscall.Umask(0)
498
+ defer syscall.Umask(m)
499
+
500
+ base := t.TempDir()
501
+ dest := filepath.Join(base, "logs4")
502
+ fw := &FileWriter{
503
+ Filename: filepath.Join(dest, "app.log"),
504
+ Mode: 0o640,
505
+ Roll: func() *bool { f := false; return &f }(),
506
+ }
507
+ w, err := fw.OpenWriter()
508
+ if err != nil {
509
+ t.Fatal(err)
510
+ }
511
+ _ = w.Close()
512
+
513
+ st, err := os.Stat(dest)
514
+ if err != nil {
515
+ t.Fatal(err)
516
+ }
517
+ if got := st.Mode().Perm(); got != 0o700 {
518
+ t.Fatalf("dir perm = %o, want 0700", got)
519
+ }
520
+}
521
+
522
+func TestDirMode_UmaskInteraction(t *testing.T) {
523
+ _ = syscall.Umask(0o022) // typical umask; restore after
524
+ defer syscall.Umask(0)
525
+
526
+ base := t.TempDir()
527
+ dest := filepath.Join(base, "logs5")
528
+ fw := &FileWriter{
529
+ Filename: filepath.Join(dest, "app.log"),
530
+ DirMode: "0755",
531
+ Mode: 0o644,
532
+ Roll: func() *bool { f := false; return &f }(),
533
+ }
534
+ w, err := fw.OpenWriter()
535
+ if err != nil {
536
+ t.Fatal(err)
537
+ }
538
+ _ = w.Close()
539
+
540
+ st, err := os.Stat(dest)
541
+ if err != nil {
542
+ t.Fatal(err)
543
+ }
544
+ // 0755 &^ 0022 still 0755 for dirs; this just sanity-checks we didn't get stricter unexpectedly
545
+ if got := st.Mode().Perm(); got != 0o755 {
546
+ t.Fatalf("dir perm = %o, want 0755 (considering umask)", got)
547
+ }
548
+}
549
+
550
+func TestCaddyfile_DirMode_Inherit(t *testing.T) {
551
+ d := caddyfile.NewTestDispenser(`
552
+file /var/log/app.log {
553
+ dir_mode inherit
554
+ mode 0640
555
+}`)
556
+ var fw FileWriter
557
+ if err := fw.UnmarshalCaddyfile(d); err != nil {
558
+ t.Fatal(err)
559
+ }
560
+ if fw.DirMode != "inherit" {
561
+ t.Fatalf("got %q", fw.DirMode)
562
+ }
563
+ if fw.Mode != 0o640 {
564
+ t.Fatalf("mode = %o", fw.Mode)
565
+ }
566
+}
567
+
568
+func TestCaddyfile_DirMode_FromFile(t *testing.T) {
569
+ d := caddyfile.NewTestDispenser(`
570
+file /var/log/app.log {
571
+ dir_mode from_file
572
+ mode 0600
573
+}`)
574
+ var fw FileWriter
575
+ if err := fw.UnmarshalCaddyfile(d); err != nil {
576
+ t.Fatal(err)
577
+ }
578
+ if fw.DirMode != "from_file" {
579
+ t.Fatalf("got %q", fw.DirMode)
580
+ }
581
+ if fw.Mode != 0o600 {
582
+ t.Fatalf("mode = %o", fw.Mode)
583
+ }
584
+}
585
+
586
+func TestCaddyfile_DirMode_Octal(t *testing.T) {
587
+ d := caddyfile.NewTestDispenser(`
588
+file /var/log/app.log {
589
+ dir_mode 0755
590
+}`)
591
+ var fw FileWriter
592
+ if err := fw.UnmarshalCaddyfile(d); err != nil {
593
+ t.Fatal(err)
594
+ }
595
+ if fw.DirMode != "0755" {
596
+ t.Fatalf("got %q", fw.DirMode)
597
+ }
598
+}
599
+
600
+func TestCaddyfile_DirMode_Invalid(t *testing.T) {
601
+ d := caddyfile.NewTestDispenser(`
602
+file /var/log/app.log {
603
+ dir_mode nope
604
+}`)
605
+ var fw FileWriter
606
+ if err := fw.UnmarshalCaddyfile(d); err == nil {
607
+ t.Fatal("expected error for invalid dir_mode")
608
+ }
609
+}
modules/logging/filewriter_test_windows.go
+38
@@ -53,3 +53,41 @@ func TestFileCreationMode(t *testing.T) {
53
t.Fatalf("file mode is %v, want rw for user", st.Mode().Perm())
54
}
55
}
56
+
57
+func TestDirMode_Windows_CreateSucceeds(t *testing.T) {
58
+ dir, err := os.MkdirTemp("", "caddytest")
59
+ if err != nil {
60
+ t.Fatalf("failed to create tempdir: %v", err)
61
+ }
62
+ defer os.RemoveAll(dir)
63
+
64
+ tests := []struct {
65
+ name string
66
+ dirMode string
67
+ }{
68
+ {"inherit", "inherit"},
69
+ {"from_file", "from_file"},
70
+ {"octal", "0755"},
71
+ {"default", ""},
72
+ }
73
+
74
+ for _, tt := range tests {
75
+ t.Run(tt.name, func(t *testing.T) {
76
+ subdir := path.Join(dir, "logs-"+tt.name)
77
+ fw := &FileWriter{
78
+ Filename: path.Join(subdir, "test.log"),
79
+ DirMode: tt.dirMode,
80
+ Mode: 0o600,
81
+ }
82
+ w, err := fw.OpenWriter()
83
+ if err != nil {
84
+ t.Fatalf("failed to open writer: %v", err)
85
+ }
86
+ defer w.Close()
87
+
88
+ if _, err := os.Stat(fw.Filename); err != nil {
89
+ t.Fatalf("expected file to exist: %v", err)
90
+ }
91
+ })
92
+ }
93
+}