@marcel91200 / caddy Mirror · read-only / commits / 294dfff4
Fork 0

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 +}