diff --git a/src/api.go b/src/api.go index eea3047..dba509a 100644 --- a/src/api.go +++ b/src/api.go @@ -386,7 +386,9 @@ func initAPIs(targetMux *http.ServeMux) { authRouter.HandleFunc("/api/log/read", LogViewer.HandleReadLog) authRouter.HandleFunc("/api/log/summary", LogViewer.HandleReadLogSummary) authRouter.HandleFunc("/api/log/errors", LogViewer.HandleLogErrorSummary) - authRouter.HandleFunc("/api/log/rotate/debug.trigger", SystemWideLogger.HandleDebugTriggerLogRotation) + authRouter.HandleFunc("/api/log/rotate/trigger", SystemWideLogger.HandleDebugTriggerLogRotation) + authRouter.HandleFunc("/api/logger/config", handleLoggerConfig) + //Debug authRouter.HandleFunc("/api/info/pprof", pprof.Index) } diff --git a/src/config.go b/src/config.go index 43b6886..6fcfb96 100644 --- a/src/config.go +++ b/src/config.go @@ -15,6 +15,7 @@ import ( "imuslab.com/zoraxy/mod/dynamicproxy" "imuslab.com/zoraxy/mod/dynamicproxy/loadbalance" + "imuslab.com/zoraxy/mod/info/logger" "imuslab.com/zoraxy/mod/tlscert" "imuslab.com/zoraxy/mod/utils" ) @@ -366,3 +367,13 @@ func ImportConfigFromZip(w http.ResponseWriter, r *http.Request) { } } + +func handleLoggerConfig(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + logger.HandleGetLogConfig(CONF_LOG_CONFIG)(w, r) + } else if r.Method == http.MethodPost { + logger.HandleUpdateLogConfig(CONF_LOG_CONFIG, SystemWideLogger)(w, r) + } else { + utils.SendErrorResponse(w, "Method not allowed") + } +} diff --git a/src/def.go b/src/def.go index a8a801e..aac957d 100644 --- a/src/def.go +++ b/src/def.go @@ -76,6 +76,7 @@ const ( CONF_PATH_RULE = CONF_FOLDER + "/rules/pathrules" CONF_PLUGIN_GROUPS = CONF_FOLDER + "/plugin_groups.json" CONF_GEODB_PATH = CONF_FOLDER + "/geodb" + CONF_LOG_CONFIG = CONF_FOLDER + "/log_conf.json" ) /* System Startup Flags */ @@ -95,9 +96,9 @@ var ( enableAutoUpdate = flag.Bool("cfgupgrade", true, "Enable auto config upgrade if breaking change is detected") /* Logging Configuration Flags */ - enableLog = flag.Bool("enablelog", true, "Enable system wide logging, set to false for writing log to STDOUT only") - enableLogCompression = flag.Bool("enablelogcompress", true, "Enable log compression for rotated log files") - logRotate = flag.String("logrotate", "0", "Enable log rotation and set the maximum log file size in KB, also support K, M, G suffix (e.g. 200M), set to 0 to disable") + enableLog = flag.Bool("enablelog", true, "Enable system wide logging, set to false for writing log to STDOUT only") + //enableLogCompression = flag.Bool("enablelogcompress", true, "Enable log compression for rotated log files") + //logRotate = flag.String("logrotate", "0", "Enable log rotation and set the maximum log file size in KB, also support K, M, G suffix (e.g. 200M), set to 0 to disable") /* Default Configuration Flags */ defaultInboundPort = flag.Int("default_inbound_port", 443, "Default web server listening port") diff --git a/src/mod/info/logger/handle.go b/src/mod/info/logger/handle.go new file mode 100644 index 0000000..1440b59 --- /dev/null +++ b/src/mod/info/logger/handle.go @@ -0,0 +1,164 @@ +package logger + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + + "imuslab.com/zoraxy/mod/utils" +) + +// LogConfig represents the log rotation configuration +type LogConfig struct { + Enabled bool `json:"enabled"` // Whether log rotation is enabled + MaxSize string `json:"maxSize"` // Maximum size as string (e.g., "200M", "10K") + MaxBackups int `json:"maxBackups"` // Maximum number of backup files to keep + Compress bool `json:"compress"` // Whether to compress rotated logs +} + +// LoadLogConfig loads the log configuration from the config file +func LoadLogConfig(configPath string) (*LogConfig, error) { + // Ensure config directory exists + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return nil, err + } + + // Default config + defaultConfig := &LogConfig{ + Enabled: false, + MaxSize: "0", + MaxBackups: 16, + Compress: true, + } + + // Try to read existing config + file, err := os.Open(configPath) + if err != nil { + if os.IsNotExist(err) { + // File doesn't exist, save default config + if saveErr := SaveLogConfig(configPath, defaultConfig); saveErr != nil { + return nil, saveErr + } + return defaultConfig, nil + } + return nil, err + } + defer file.Close() + + var config LogConfig + decoder := json.NewDecoder(file) + if err := decoder.Decode(&config); err != nil { + // If decode fails, use default + return defaultConfig, nil + } + + return &config, nil +} + +// SaveLogConfig saves the log configuration to the config file +func SaveLogConfig(configPath string, config *LogConfig) error { + // Ensure config directory exists + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return err + } + + file, err := os.Create(configPath) + if err != nil { + return err + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + return encoder.Encode(config) +} + +// ApplyLogConfig applies the log configuration to the logger +func (l *Logger) ApplyLogConfig(config *LogConfig) error { + maxSizeBytes, err := utils.SizeStringToBytes(config.MaxSize) + if err != nil { + return err + } + + if maxSizeBytes == 0 { + // Use default value of 25MB + maxSizeBytes = 25 * 1024 * 1024 + } + + rotateOption := &RotateOption{ + Enabled: config.Enabled, + MaxSize: int64(maxSizeBytes), + MaxBackups: config.MaxBackups, + Compress: config.Compress, + BackupDir: "", + } + + l.SetRotateOption(rotateOption) + return nil +} + +// HandleGetLogConfig handles GET /api/logger/config +func HandleGetLogConfig(configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + config, err := LoadLogConfig(configPath) + if err != nil { + utils.SendErrorResponse(w, "Failed to load log config: "+err.Error()) + return + } + js, err := json.Marshal(config) + if err != nil { + utils.SendErrorResponse(w, "Failed to marshal config: "+err.Error()) + return + } + utils.SendJSONResponse(w, string(js)) + } +} + +// HandleUpdateLogConfig handles POST /api/logger/config +func HandleUpdateLogConfig(configPath string, logger *Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + utils.SendErrorResponse(w, "Method not allowed") + return + } + + var config LogConfig + if err := json.NewDecoder(r.Body).Decode(&config); err != nil { + utils.SendErrorResponse(w, "Invalid JSON: "+err.Error()) + return + } + + // Validate MaxSize + if _, err := utils.SizeStringToBytes(config.MaxSize); err != nil { + utils.SendErrorResponse(w, "Invalid maxSize: "+err.Error()) + return + } + + // Validate MaxBackups + if config.MaxBackups < 1 { + utils.SendErrorResponse(w, "maxBackups must be at least 1") + return + } + + // Save config + if err := SaveLogConfig(configPath, &config); err != nil { + utils.SendErrorResponse(w, "Failed to save config: "+err.Error()) + return + } + + // Apply to logger + if err := logger.ApplyLogConfig(&config); err != nil { + utils.SendErrorResponse(w, "Failed to apply config: "+err.Error()) + return + } + + // Pretty print config as key: value pairs + configStr := fmt.Sprintf("enabled=%t, maxSize=%s, maxBackups=%d, compress=%t", config.Enabled, config.MaxSize, config.MaxBackups, config.Compress) + logger.PrintAndLog("logger", "Updated log rotation setting: "+configStr, nil) + utils.SendOK(w) + } +} diff --git a/src/mod/info/logger/rotate.go b/src/mod/info/logger/rotate.go index 0562dba..03b2a89 100644 --- a/src/mod/info/logger/rotate.go +++ b/src/mod/info/logger/rotate.go @@ -1,7 +1,7 @@ package logger import ( - "archive/zip" + "compress/gzip" "fmt" "io" "net/http" @@ -176,17 +176,17 @@ func (l *Logger) RotateLog() error { return nil } -// compressFile compresses the given file using zip format and creates a .gz file. +// compressFile compresses the given file using gzip format and creates a .gz file. func compressFile(filename string) error { - zipFilename := filename + ".gz" - outFile, err := os.Create(zipFilename) + gzipFilename := filename + ".gz" + outFile, err := os.Create(gzipFilename) if err != nil { return err } defer outFile.Close() - zipWriter := zip.NewWriter(outFile) - defer zipWriter.Close() + gzipWriter := gzip.NewWriter(outFile) + defer gzipWriter.Close() fileToCompress, err := os.Open(filename) if err != nil { @@ -194,11 +194,6 @@ func compressFile(filename string) error { } defer fileToCompress.Close() - w, err := zipWriter.Create(filepath.Base(filename)) - if err != nil { - return err - } - - _, err = io.Copy(w, fileToCompress) + _, err = io.Copy(gzipWriter, fileToCompress) return err } diff --git a/src/mod/info/logviewer/logviewer.go b/src/mod/info/logviewer/logviewer.go index fb62c14..bf6bee8 100644 --- a/src/mod/info/logviewer/logviewer.go +++ b/src/mod/info/logviewer/logviewer.go @@ -1,8 +1,11 @@ package logviewer import ( + "archive/zip" + "compress/gzip" "encoding/json" "errors" + "io" "io/fs" "net/http" "os" @@ -15,7 +18,6 @@ import ( type ViewerOption struct { RootFolder string //The root folder to scan for log - Extension string //The extension the root files use, include the . in your ext (e.g. .log) } type Viewer struct { @@ -72,6 +74,11 @@ func (v *Viewer) HandleReadLog(w http.ResponseWriter, r *http.Request) { filter = "" } + linesParam, err := utils.GetPara(r, "lines") + if err != nil { + linesParam = "all" + } + content, err := v.LoadLogFile(strings.TrimSpace(filepath.Base(filename))) if err != nil { utils.SendErrorResponse(w, err.Error()) @@ -107,6 +114,18 @@ func (v *Viewer) HandleReadLog(w http.ResponseWriter, r *http.Request) { content = strings.Join(filteredLines, "\n") } + // Apply lines limit after filtering + if linesParam != "all" { + if lineLimit, err := strconv.Atoi(linesParam); err == nil && lineLimit > 0 { + allLines := strings.Split(content, "\n") + if len(allLines) > lineLimit { + // Keep only the last lineLimit lines + allLines = allLines[len(allLines)-lineLimit:] + content = strings.Join(allLines, "\n") + } + } + } + utils.SendTextResponse(w, content) } @@ -158,7 +177,7 @@ func (v *Viewer) HandleLogErrorSummary(w http.ResponseWriter, r *http.Request) { line = line[strings.LastIndex(line, "]")+1:] fields := strings.Fields(strings.TrimSpace(line)) - if len(fields) > 0 { + if len(fields) >= 3 { statusStr := fields[2] if len(statusStr) == 3 && (statusStr[0] != '1' && statusStr[0] != '2' && statusStr[0] != '3') { fieldsWithTimestamp := append([]string{timestamp}, strings.Fields(strings.TrimSpace(line))...) @@ -179,7 +198,7 @@ func (v *Viewer) HandleLogErrorSummary(w http.ResponseWriter, r *http.Request) { func (v *Viewer) ListLogFiles(showFullpath bool) map[string][]*LogFile { result := map[string][]*LogFile{} filepath.WalkDir(v.option.RootFolder, func(path string, di fs.DirEntry, err error) error { - if filepath.Ext(path) == v.option.Extension { + if filepath.Ext(path) == ".log" || strings.HasSuffix(path, ".log.gz") { catergory := filepath.Base(filepath.Dir(path)) logList, ok := result[catergory] if !ok { @@ -197,9 +216,12 @@ func (v *Viewer) ListLogFiles(showFullpath bool) map[string][]*LogFile { return nil } + filename := filepath.Base(path) + filename = strings.TrimSuffix(filename, ".log") //to handle cases where the filename ends of .log.gz + logList = append(logList, &LogFile{ Title: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), - Filename: filepath.Base(path), + Filename: filename, Fullpath: fullpath, Filesize: st.Size(), }) @@ -212,13 +234,78 @@ func (v *Viewer) ListLogFiles(showFullpath bool) map[string][]*LogFile { return result } -func (v *Viewer) LoadLogFile(filename string) (string, error) { +// readLogFileContent reads a log file, handling both compressed (.gz) and uncompressed files +func (v *Viewer) readLogFileContent(filepath string) ([]byte, error) { + file, err := os.Open(filepath) + if err != nil { + return nil, err + } + defer file.Close() + + // Check if file is compressed + if strings.HasSuffix(filepath, ".gz") { + gzipReader, err := gzip.NewReader(file) + if err != nil { + // Try zip reader for older logs that use zip compression despite .gz extension + zipReader, err := zip.OpenReader(filepath) + if err != nil { + return nil, err + } + defer zipReader.Close() + if len(zipReader.File) == 0 { + return nil, errors.New("zip file is empty") + } + zipFile := zipReader.File[0] + rc, err := zipFile.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(rc) + + } + defer gzipReader.Close() + + return io.ReadAll(gzipReader) + } + + // Regular file + return io.ReadAll(file) +} + +func (v *Viewer) senatizeLogFilenameInput(filename string) string { + filename = strings.TrimSuffix(filename, ".log.gz") + filename = strings.TrimSuffix(filename, ".log") filename = filepath.ToSlash(filename) filename = strings.ReplaceAll(filename, "../", "") - logFilepath := filepath.Join(v.option.RootFolder, filename) + //Check if .log.gz or .log exists + if utils.FileExists(filepath.Join(v.option.RootFolder, filename+".log")) { + return filepath.Join(v.option.RootFolder, filename+".log") + } + if utils.FileExists(filepath.Join(v.option.RootFolder, filename+".log.gz")) { + return filepath.Join(v.option.RootFolder, filename+".log.gz") + } + return filepath.Join(v.option.RootFolder, filename) +} + +func (v *Viewer) LoadLogFile(filename string) (string, error) { + // filename might be in (no extension), .log or .log.gz format + // so we trim those first before proceeding + logFilepath := v.senatizeLogFilenameInput(filename) if utils.FileExists(logFilepath) { //Load it - content, err := os.ReadFile(logFilepath) + content, err := v.readLogFileContent(logFilepath) + if err != nil { + return "", err + } + + return string(content), nil + } + + //Also check .log.gz + logFilepathGz := logFilepath + ".gz" + if utils.FileExists(logFilepathGz) { + content, err := v.readLogFileContent(logFilepathGz) if err != nil { return "", err } @@ -230,12 +317,10 @@ func (v *Viewer) LoadLogFile(filename string) (string, error) { } func (v *Viewer) LoadLogSummary(filename string) (string, error) { - filename = filepath.ToSlash(filename) - filename = strings.ReplaceAll(filename, "../", "") - logFilepath := filepath.Join(v.option.RootFolder, filename) + logFilepath := v.senatizeLogFilenameInput(filename) if utils.FileExists(logFilepath) { //Load it - content, err := os.ReadFile(logFilepath) + content, err := v.readLogFileContent(logFilepath) if err != nil { return "", err } diff --git a/src/start.go b/src/start.go index b2fb0cf..43d0d20 100644 --- a/src/start.go +++ b/src/start.go @@ -12,7 +12,6 @@ import ( "imuslab.com/zoraxy/mod/auth/sso/oauth2" "imuslab.com/zoraxy/mod/eventsystem" - "imuslab.com/zoraxy/mod/utils" "github.com/gorilla/csrf" "imuslab.com/zoraxy/mod/access" @@ -77,30 +76,33 @@ func startupSequence() { SystemWideLogger = l SystemWideLogger.Println("System wide logging is disabled, all logs will be printed to STDOUT only") } else { - logRotateSize, err := utils.SizeStringToBytes(*logRotate) + // Load log configuration from file + logConfig, err := logger.LoadLogConfig(CONF_LOG_CONFIG) if err != nil { - //Default disable - logRotateSize = 0 + SystemWideLogger.Println("Failed to load log config, using defaults: " + err.Error()) + logConfig = &logger.LogConfig{ + Enabled: false, + MaxSize: "0", + Compress: true, + } } - l.SetRotateOption(&logger.RotateOption{ - Enabled: logRotateSize != 0, - MaxSize: int64(logRotateSize), - MaxBackups: 10, - Compress: *enableLogCompression, - BackupDir: "", - }) + + // Apply the configuration + if err := l.ApplyLogConfig(logConfig); err != nil { + SystemWideLogger.Println("Failed to apply log config: " + err.Error()) + } + SystemWideLogger = l - if logRotateSize == 0 { + if !logConfig.Enabled { SystemWideLogger.Println("Log rotation is disabled") } else { - SystemWideLogger.Println("Log rotation is enabled, max log file size " + utils.BytesToHumanReadable(int64(logRotateSize))) + SystemWideLogger.Println("Log rotation is enabled, max log file size " + logConfig.MaxSize) } SystemWideLogger.Println("System wide logging is enabled") } LogViewer = logviewer.NewLogViewer(&logviewer.ViewerOption{ RootFolder: *path_logFile, - Extension: LOG_EXTENSION, }) //Create database diff --git a/src/web/components/utils.html b/src/web/components/utils.html index ce48fc8..585b1ed 100644 --- a/src/web/components/utils.html +++ b/src/web/components/utils.html @@ -5,8 +5,9 @@
@@ -88,6 +89,60 @@
+ +

Log Settings

+

Configure log rotation settings for the system logger

+
+
+
+
+ + +
+
+
+ +
+ +
bytes
+
+ Enter size with suffix (K, M, G) or plain number. Set to 0 to disable. +
+
+ + + Maximum number of rotated log files to keep. +
+
+
+ + +
+ When enabled, rotated log files will be compressed using ZIP format. +
+ + + + +
+
+
+ + +

System Log Viewer

+

View and download Zoraxy log

+ Open Log Viewer +
+
+

IP Address to CIDR

No experience with CIDR notations? Here are some tools you can use to make setting up easier.

@@ -116,17 +171,13 @@
-
+

System Backup & Restore

Options related to system backup, migrate and restore.

- -

System Log Viewer

-

View and download Zoraxy log

- Open Log Viewer -
+

@@ -307,6 +358,7 @@ }); } initSMTPSettings(); + loadLogSettings(); function sendTestEmail(btn){ $(btn).addClass("loading").addClass("disabled"); @@ -472,4 +524,104 @@ } return [ip >>> 24 & 0xFF, ip >>> 16 & 0xFF, ip >>> 8 & 0xFF, ip & 0xFF].join('.') } + + /* + Log Settings + */ + function loadLogSettings() { + $.get("/api/logger/config", function(data) { + if (data.error) { + console.error("Failed to load log settings:", data.error); + return; + } + $("#logRotationEnabled").prop("checked", data.enabled); + $("#logMaxSize").val(data.maxSize); + $("#logMaxBackups").val(data.maxBackups || 16); + $("#logCompressionEnabled").prop("checked", data.compress); + // Re-initialize checkboxes after setting values + $('.ui.checkbox').checkbox(); + }).fail(function(xhr, status, error) { + console.error("Failed to load log settings:", error); + }); + } + + function saveLogSettings() { + const settings = { + enabled: $("#logRotationEnabled").is(":checked"), + maxSize: $("#logMaxSize").val().trim(), + maxBackups: parseInt($("#logMaxBackups").val()) || 16, + compress: $("#logCompressionEnabled").is(":checked") + }; + + // Basic validation + if (settings.maxSize === "") { + showLogSettingsError("Max size cannot be empty"); + return; + } + + if (settings.maxBackups < 1) { + showLogSettingsError("Max backups must be at least 1"); + return; + } + + $.cjax({ + type: "POST", + url: "/api/logger/config", + data: JSON.stringify(settings), + success: function(data) { + if (data.error) { + showLogSettingsError(data.error); + } else { + showLogSettingsSuccess(); + } + }, + error: function(xhr, status, error) { + showLogSettingsError("Failed to save settings: " + error); + } + }); + } + + function showLogSettingsSuccess() { + $("#logSettingsErrorMsg").hide(); + $("#logSettingsSuccessMsg").stop().finish().slideDown("fast").delay(3000).slideUp("fast"); + } + + function showLogSettingsError(message) { + $("#logSettingsSuccessMsg").hide(); + $("#logSettingsErrorText").text(message); + $("#logSettingsErrorMsg").stop().finish().slideDown("fast").delay(5000).slideUp("fast"); + } + + function triggerLogRotation(event) { + event.preventDefault(); + event.stopImmediatePropagation(); + // Show loading state on button + const rotateBtn = $('#rotateLogBtn'); + const originalText = rotateBtn.html(); + rotateBtn.html(' Rotating...').addClass('loading disabled'); + + $.get("/api/log/rotate/trigger", function(data) { + if (data.error) { + showLogSettingsError("Failed to rotate log: " + data.error); + } else { + showLogSettingsSuccess(); + // Update success message for rotation + $("#logSettingsSuccessMsg").html(' Log rotation completed successfully'); + } + }).fail(function(xhr, status, error) { + showLogSettingsError("Failed to rotate log: " + error); + }).always(function() { + // Restore button state + rotateBtn.html(originalText).removeClass('loading disabled'); + }); + } + + // Initialize Semantic UI checkboxes + $('.ui.checkbox').checkbox(); + + // Bind form submission + $("#log-settings-form").submit(function(e) { + e.preventDefault(); + saveLogSettings(); + }); \ No newline at end of file diff --git a/src/web/snippet/logview.html b/src/web/snippet/logview.html index 35a6beb..63d3350 100644 --- a/src/web/snippet/logview.html +++ b/src/web/snippet/logview.html @@ -10,7 +10,7 @@ - +