@marcel91200 / caddy Mirror · read-only
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, 01:00 UTC

master
go 571 lines 15.2 KB
Raw
1 // Copyright 2015 Matthew Holt and The Caddy Authors
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 //
7 // http://www.apache.org/licenses/LICENSE-2.0
8 //
9 // Unless required by applicable law or agreed to in writing, software
10 // distributed under the License is distributed on an "AS IS" BASIS,
11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 // See the License for the specific language governing permissions and
13 // limitations under the License.
14
15 package logging
16
17 import (
18 "encoding/json"
19 "fmt"
20 "io"
21 "math"
22 "os"
23 "path/filepath"
24 "strconv"
25 "strings"
26 "time"
27
28 "github.com/DeRuina/timberjack"
29 "github.com/dustin/go-humanize"
30
31 "github.com/caddyserver/caddy/v2"
32 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
33 )
34
35 func init() {
36 caddy.RegisterModule(FileWriter{})
37 }
38
39 // fileMode is a string made of 1 to 4 octal digits representing
40 // a numeric mode as specified with the `chmod` unix command.
41 // `"0777"` and `"777"` are thus equivalent values.
42 type fileMode os.FileMode
43
44 // UnmarshalJSON satisfies json.Unmarshaler.
45 func (m *fileMode) UnmarshalJSON(b []byte) error {
46 if len(b) == 0 {
47 return io.EOF
48 }
49
50 var s string
51 if err := json.Unmarshal(b, &s); err != nil {
52 return err
53 }
54
55 mode, err := parseFileMode(s)
56 if err != nil {
57 return err
58 }
59
60 *m = fileMode(mode)
61 return err
62 }
63
64 // MarshalJSON satisfies json.Marshaler.
65 func (m *fileMode) MarshalJSON() ([]byte, error) {
66 return fmt.Appendf(nil, "\"%04o\"", *m), nil
67 }
68
69 // parseFileMode parses a file mode string,
70 // adding support for `chmod` unix command like
71 // 1 to 4 digital octal values.
72 func parseFileMode(s string) (os.FileMode, error) {
73 modeStr := fmt.Sprintf("%04s", s)
74 mode, err := strconv.ParseUint(modeStr, 8, 32)
75 if err != nil {
76 return 0, err
77 }
78 return os.FileMode(mode), nil
79 }
80
81 // FileWriter can write logs to files. By default, log files
82 // are rotated ("rolled") when they get large, and old log
83 // files get deleted, to ensure that the process does not
84 // exhaust disk space.
85 type FileWriter struct {
86 // Filename is the name of the file to write.
87 Filename string `json:"filename,omitempty"`
88
89 // The file permissions mode.
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"`
105
106 // When a log file reaches approximately this size,
107 // it will be rotated.
108 RollSizeMB int `json:"roll_size_mb,omitempty"`
109
110 // Roll log file after some time
111 RollInterval caddy.Duration `json:"roll_interval,omitempty"`
112
113 // Roll log file at fix minutes
114 // For example []int{0, 30} will roll file at xx:00 and xx:30 each hour
115 // Invalid value are ignored with a warning on stderr
116 // See https://github.com/DeRuina/timberjack#%EF%B8%8F-rotation-notes--warnings for caveats
117 RollAtMinutes []int `json:"roll_minutes,omitempty"`
118
119 // Roll log file at fix time
120 // For example []string{"00:00", "12:00"} will roll file at 00:00 and 12:00 each day
121 // Invalid value are ignored with a warning on stderr
122 // See https://github.com/DeRuina/timberjack#%EF%B8%8F-rotation-notes--warnings for caveats
123 RollAt []string `json:"roll_at,omitempty"`
124
125 // Whether to compress rolled files.
126 // Default: true.
127 // Deprecated: Use RollCompression instead, setting it to "none".
128 RollCompress *bool `json:"roll_gzip,omitempty"`
129
130 // RollCompression selects the compression algorithm for rolled files.
131 // Accepted values: "none", "gzip", "zstd".
132 // Default: gzip
133 RollCompression string `json:"roll_compression,omitempty"`
134
135 // Whether to use local timestamps in rolled filenames.
136 // Default: false
137 RollLocalTime bool `json:"roll_local_time,omitempty"`
138
139 // The maximum number of rolled log files to keep.
140 // Default: 10
141 RollKeep int `json:"roll_keep,omitempty"`
142
143 // How many days to keep rolled log files. Default: 90
144 RollKeepDays int `json:"roll_keep_days,omitempty"`
145
146 // Rotated file will have format <logfilename>-<format>-<criterion>.log
147 // Optional. If unset or invalid, defaults to 2006-01-02T15-04-05.000 (with fallback warning)
148 // <format> must be a Go time compatible format, see https://pkg.go.dev/time#pkg-constants
149 BackupTimeFormat string `json:"backup_time_format,omitempty"`
150 }
151
152 // CaddyModule returns the Caddy module information.
153 func (FileWriter) CaddyModule() caddy.ModuleInfo {
154 return caddy.ModuleInfo{
155 ID: "caddy.logging.writers.file",
156 New: func() caddy.Module { return new(FileWriter) },
157 }
158 }
159
160 // Provision sets up the module
161 func (fw *FileWriter) Provision(ctx caddy.Context) error {
162 // Replace placeholder in filename
163 repl := caddy.NewReplacer()
164 filename, err := repl.ReplaceOrErr(fw.Filename, true, true)
165 if err != nil {
166 return fmt.Errorf("invalid filename for log file: %v", err)
167 }
168
169 fw.Filename = filename
170 return nil
171 }
172
173 func (fw FileWriter) String() string {
174 fpath, err := caddy.FastAbs(fw.Filename)
175 if err == nil {
176 return fpath
177 }
178 return fw.Filename
179 }
180
181 // WriterKey returns a unique key representing this fw.
182 func (fw FileWriter) WriterKey() string {
183 return "file:" + fw.Filename
184 }
185
186 // OpenWriter opens a new file writer.
187 func (fw FileWriter) OpenWriter() (io.WriteCloser, error) {
188 modeIfCreating := os.FileMode(fw.Mode)
189 if modeIfCreating == 0 {
190 modeIfCreating = 0o600
191 }
192
193 // roll log files as a sensible default to avoid disk space exhaustion
194 roll := fw.Roll == nil || *fw.Roll
195
196 // Ensure directory exists before opening the file.
197 dirPath := filepath.Dir(fw.Filename)
198 switch strings.ToLower(strings.TrimSpace(fw.DirMode)) {
199 case "", "0":
200 // Preserve current behavior: locked-down directories by default.
201 if err := os.MkdirAll(dirPath, 0o700); err != nil {
202 return nil, err
203 }
204 case "inherit":
205 if err := mkdirAllInherit(dirPath); err != nil {
206 return nil, err
207 }
208 case "from_file":
209 if err := mkdirAllFromFile(dirPath, modeIfCreating); err != nil {
210 return nil, err
211 }
212 default:
213 dm, err := parseFileMode(fw.DirMode)
214 if err != nil {
215 return nil, fmt.Errorf("dir_mode: %w", err)
216 }
217 if err := os.MkdirAll(dirPath, dm); err != nil {
218 return nil, err
219 }
220 }
221
222 // create/open the file
223 file, err := os.OpenFile(fw.Filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, modeIfCreating)
224 if err != nil {
225 return nil, err
226 }
227 closeFile := true
228 defer func() {
229 if closeFile {
230 _ = file.Close()
231 }
232 }()
233 info, err := file.Stat()
234 if roll {
235 _ = file.Close() // timberjack will reopen it on its own
236 closeFile = false
237 }
238
239 // Ensure already existing files have the right mode, since OpenFile will not set the mode in such case.
240 if configuredMode := os.FileMode(fw.Mode); configuredMode != 0 {
241 if err != nil {
242 return nil, fmt.Errorf("unable to stat log file to see if we need to set permissions: %v", err)
243 }
244 // only chmod if the configured mode is different
245 if info.Mode()&os.ModePerm != configuredMode&os.ModePerm {
246 if err = os.Chmod(fw.Filename, configuredMode); err != nil {
247 return nil, err
248 }
249 }
250 }
251
252 // if not rolling, then the plain file handle is all we need
253 if !roll {
254 closeFile = false
255 return file, nil
256 }
257
258 // otherwise, return a rolling log
259 if fw.RollSizeMB == 0 {
260 fw.RollSizeMB = 100
261 }
262 if fw.RollCompress == nil {
263 compress := true
264 fw.RollCompress = &compress
265 }
266 if fw.RollKeep == 0 {
267 fw.RollKeep = 10
268 }
269 if fw.RollKeepDays == 0 {
270 fw.RollKeepDays = 90
271 }
272
273 // Determine compression algorithm to use. Priority:
274 // 1) explicit RollCompression (none|gzip|zstd)
275 // 2) if RollCompress is unset or true -> gzip
276 // 3) if RollCompress is false -> none
277 var compression string
278 if fw.RollCompression != "" {
279 compression = strings.ToLower(strings.TrimSpace(fw.RollCompression))
280 if compression != "none" && compression != "gzip" && compression != "zstd" {
281 return nil, fmt.Errorf("invalid roll_compression: %s", fw.RollCompression)
282 }
283 } else {
284 if fw.RollCompress == nil || *fw.RollCompress {
285 compression = "gzip"
286 } else {
287 compression = "none"
288 }
289 }
290
291 return &timberjack.Logger{
292 Filename: fw.Filename,
293 MaxSize: fw.RollSizeMB,
294 MaxAge: fw.RollKeepDays,
295 MaxBackups: fw.RollKeep,
296 LocalTime: fw.RollLocalTime,
297 Compression: compression,
298 RotationInterval: time.Duration(fw.RollInterval),
299 RotateAtMinutes: fw.RollAtMinutes,
300 RotateAt: fw.RollAt,
301 BackupTimeFormat: fw.BackupTimeFormat,
302 FileMode: os.FileMode(fw.Mode),
303 }, nil
304 }
305
306 // normalizeDirPerm ensures that read bits also have execute bits set.
307 func normalizeDirPerm(p os.FileMode) os.FileMode {
308 if p&0o400 != 0 {
309 p |= 0o100
310 }
311 if p&0o040 != 0 {
312 p |= 0o010
313 }
314 if p&0o004 != 0 {
315 p |= 0o001
316 }
317 return p
318 }
319
320 // mkdirAllInherit creates missing dirs using the nearest existing parent's
321 // permissions, normalized with r→x.
322 func mkdirAllInherit(dir string) error {
323 if fi, err := os.Stat(dir); err == nil && fi.IsDir() {
324 return nil
325 }
326 cur := dir
327 var parent string
328 for {
329 next := filepath.Dir(cur)
330 if next == cur {
331 parent = next
332 break
333 }
334 if fi, err := os.Stat(next); err == nil {
335 if !fi.IsDir() {
336 return fmt.Errorf("path component %s exists and is not a directory", next)
337 }
338 parent = next
339 break
340 }
341 cur = next
342 }
343 perm := os.FileMode(0o700)
344 if fi, err := os.Stat(parent); err == nil && fi.IsDir() {
345 perm = fi.Mode().Perm()
346 }
347 perm = normalizeDirPerm(perm)
348 return os.MkdirAll(dir, perm)
349 }
350
351 // mkdirAllFromFile creates missing dirs using the file's mode (with r→x) so
352 // 0644 → 0755, 0600 → 0700, etc.
353 func mkdirAllFromFile(dir string, fileMode os.FileMode) error {
354 if fi, err := os.Stat(dir); err == nil && fi.IsDir() {
355 return nil
356 }
357 perm := normalizeDirPerm(fileMode.Perm()) | 0o200 // ensure owner write on dir so files can be created
358 return os.MkdirAll(dir, perm)
359 }
360
361 // UnmarshalCaddyfile sets up the module from Caddyfile tokens. Syntax:
362 //
363 // file <filename> {
364 // mode <mode>
365 // dir_mode <mode|inherit|from_file>
366 // roll_disabled
367 // roll_size <size>
368 // roll_uncompressed
369 // roll_compression <none|gzip|zstd>
370 // roll_local_time
371 // roll_keep <num>
372 // roll_keep_for <days>
373 // }
374 //
375 // The roll_size value has megabyte resolution.
376 // Fractional values are rounded up to the next whole megabyte (MiB).
377 //
378 // By default, compression is enabled, but can be turned off by setting
379 // the roll_uncompressed option.
380 //
381 // The roll_keep_for duration has day resolution.
382 // Fractional values are rounded up to the next whole number of days.
383 //
384 // If any of the mode, roll_size, roll_keep, or roll_keep_for subdirectives are
385 // omitted or set to a zero value, then Caddy's default value for that
386 // subdirective is used.
387 func (fw *FileWriter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
388 d.Next() // consume writer name
389 if !d.NextArg() {
390 return d.ArgErr()
391 }
392 fw.Filename = d.Val()
393 if d.NextArg() {
394 return d.ArgErr()
395 }
396
397 for d.NextBlock(0) {
398 switch d.Val() {
399 case "mode":
400 var modeStr string
401 if !d.AllArgs(&modeStr) {
402 return d.ArgErr()
403 }
404 mode, err := parseFileMode(modeStr)
405 if err != nil {
406 return d.Errf("parsing mode: %v", err)
407 }
408 fw.Mode = fileMode(mode)
409
410 case "dir_mode":
411 var val string
412 if !d.AllArgs(&val) {
413 return d.ArgErr()
414 }
415 val = strings.TrimSpace(val)
416 switch strings.ToLower(val) {
417 case "inherit", "from_file":
418 fw.DirMode = val
419 default:
420 if _, err := parseFileMode(val); err != nil {
421 return d.Errf("parsing dir_mode: %v", err)
422 }
423 fw.DirMode = val
424 }
425
426 case "roll_disabled":
427 var f bool
428 fw.Roll = &f
429 if d.NextArg() {
430 return d.ArgErr()
431 }
432
433 case "roll_size":
434 var sizeStr string
435 if !d.AllArgs(&sizeStr) {
436 return d.ArgErr()
437 }
438 size, err := humanize.ParseBytes(sizeStr)
439 if err != nil {
440 return d.Errf("parsing size: %v", err)
441 }
442 fw.RollSizeMB = int(math.Ceil(float64(size) / humanize.MiByte))
443
444 case "roll_uncompressed":
445 var f bool
446 fw.RollCompress = &f
447 if d.NextArg() {
448 return d.ArgErr()
449 }
450
451 case "roll_compression":
452 var comp string
453 if !d.AllArgs(&comp) {
454 return d.ArgErr()
455 }
456 comp = strings.ToLower(strings.TrimSpace(comp))
457 switch comp {
458 case "none", "gzip", "zstd":
459 fw.RollCompression = comp
460 default:
461 return d.Errf("parsing roll_compression: must be 'none', 'gzip' or 'zstd'")
462 }
463
464 case "roll_local_time":
465 fw.RollLocalTime = true
466 if d.NextArg() {
467 return d.ArgErr()
468 }
469
470 case "roll_keep":
471 var keepStr string
472 if !d.AllArgs(&keepStr) {
473 return d.ArgErr()
474 }
475 keep, err := strconv.Atoi(keepStr)
476 if err != nil {
477 return d.Errf("parsing roll_keep number: %v", err)
478 }
479 fw.RollKeep = keep
480
481 case "roll_keep_for":
482 var keepForStr string
483 if !d.AllArgs(&keepForStr) {
484 return d.ArgErr()
485 }
486 keepFor, err := caddy.ParseDuration(keepForStr)
487 if err != nil {
488 return d.Errf("parsing roll_keep_for duration: %v", err)
489 }
490 if keepFor < 0 {
491 return d.Errf("negative roll_keep_for duration: %v", keepFor)
492 }
493 fw.RollKeepDays = int(math.Ceil(keepFor.Hours() / 24))
494
495 case "roll_interval":
496 var durationStr string
497 if !d.AllArgs(&durationStr) {
498 return d.ArgErr()
499 }
500 duration, err := caddy.ParseDuration(durationStr)
501 if err != nil {
502 return d.Errf("parsing roll_interval duration: %v", err)
503 }
504 fw.RollInterval = caddy.Duration(duration)
505
506 case "roll_minutes":
507 // Accept either a single comma-separated argument or
508 // multiple space-separated arguments. Collect all
509 // remaining args on the line and split on commas.
510 args := d.RemainingArgs()
511 if len(args) == 0 {
512 return d.ArgErr()
513 }
514 var minutes []int
515 for _, arg := range args {
516 parts := strings.SplitSeq(arg, ",")
517 for p := range parts {
518 ms := strings.TrimSpace(p)
519 if ms == "" {
520 return d.Errf("parsing roll_minutes: empty value")
521 }
522 m, err := strconv.Atoi(ms)
523 if err != nil {
524 return d.Errf("parsing roll_minutes number: %v", err)
525 }
526 minutes = append(minutes, m)
527 }
528 }
529 fw.RollAtMinutes = minutes
530
531 case "roll_at":
532 // Accept either a single comma-separated argument or
533 // multiple space-separated arguments. Collect all
534 // remaining args on the line and split on commas.
535 args := d.RemainingArgs()
536 if len(args) == 0 {
537 return d.ArgErr()
538 }
539 var times []string
540 for _, arg := range args {
541 parts := strings.SplitSeq(arg, ",")
542 for p := range parts {
543 ts := strings.TrimSpace(p)
544 if ts == "" {
545 return d.Errf("parsing roll_at: empty value")
546 }
547 times = append(times, ts)
548 }
549 }
550 fw.RollAt = times
551
552 case "backup_time_format":
553 var format string
554 if !d.AllArgs(&format) {
555 return d.ArgErr()
556 }
557 fw.BackupTimeFormat = format
558
559 default:
560 return d.Errf("unrecognized subdirective '%s'", d.Val())
561 }
562 }
563 return nil
564 }
565
566 // Interface guards
567 var (
568 _ caddy.Provisioner = (*FileWriter)(nil)
569 _ caddy.WriterOpener = (*FileWriter)(nil)
570 _ caddyfile.Unmarshaler = (*FileWriter)(nil)
571 )