Files
kazeyukiro 3d93b2497a feat(logs): separate panel and Mihomo log streams
Mihomo: GET /mihomo/logs only. Panel: GET /system/panel-logs.
Logs page uses tabs (core vs panel). Stdlib log still captured in panellog.
2026-10-04 10:35:00 +00:00

689 lines
21 KiB
Go

package system
import (
"context"
"encoding/json"
"fmt"
"github.com/kazeyukiro/3m-ui/backend/internal/panellog"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/kazeyukiro/3m-ui/backend/internal/buildinfo"
"gorm.io/gorm"
)
const maxRestoreDatabaseBytes = 128 << 20
type Handler struct {
svc *Service
db *gorm.DB
dbPath string
mihomoCfg string
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) WithBackupPaths(dbPath, mihomoConfig string) *Handler {
h.dbPath = dbPath
h.mihomoCfg = mihomoConfig
return h
}
func (h *Handler) WithDB(db *gorm.DB) *Handler {
h.db = db
return h
}
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/status", h.GetSystemStatus)
rg.GET("/backup", h.ExportBackup)
rg.GET("/backups", h.ListLocalBackups)
rg.DELETE("/backups/:name", h.DeleteLocalBackup)
rg.POST("/backups/cleanup", h.CleanupLocalBackups)
rg.POST("/backup/restore-db", h.RestoreDatabase)
rg.POST("/templates/reverse-proxy", h.ReverseProxy)
rg.POST("/templates/acme", h.ACME)
rg.POST("/geofiles/update", h.UpdateGeoFiles)
rg.POST("/templates/warp", h.WARP)
rg.POST("/templates/warp/register", h.WARPRegister)
rg.GET("/warp", h.WARPGet)
rg.POST("/warp", h.WARPCreate)
rg.DELETE("/warp", h.WARPDelete)
rg.POST("/restart", h.RestartPanel)
rg.GET("/update-info", h.UpdateInfo)
rg.POST("/update", h.RunUpdate)
rg.GET("/panel-logs", h.GetPanelLogs)
}
func (h *Handler) GetSystemStatus(c *gin.Context) {
stats := h.svc.GetStatus()
c.JSON(http.StatusOK, stats)
}
// WARP returns a Mihomo WireGuard fragment for Cloudflare WARP (v1.3.6).
func (h *Handler) WARP(c *gin.Context) {
var body struct {
PrivateKey string `json:"private_key"`
Address string `json:"address"`
IPv6 string `json:"ipv6"`
Reserved string `json:"reserved"`
}
_ = c.ShouldBindJSON(&body)
ipv4, ipv6 := body.Address, body.IPv6
if i := strings.IndexByte(ipv4, ','); i >= 0 {
if ipv6 == "" {
ipv6 = strings.TrimSpace(ipv4[i+1:])
}
ipv4 = strings.TrimSpace(ipv4[:i])
}
reserved := decodeWARPClientID(body.Reserved)
yaml, err := WARPTemplate(body.PrivateKey, ipv4, ipv6, reserved)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"yaml": yaml})
}
func (h *Handler) WARPRegister(c *gin.Context) {
res, err := RegisterWARP()
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// Persist account by default so server egress can inject WARP.
if h.db != nil && c.Query("nosave") != "1" {
if acc := AccountFromRegister(res); acc != nil && strings.TrimSpace(acc.PrivateKey) != "" {
if err := SaveWARPAccount(h.db, acc); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "save WARP account: " + err.Error()})
return
}
}
}
mode := strings.TrimSpace(c.Query("mode"))
switch mode {
case "", "wireguard":
c.JSON(http.StatusOK, gin.H{
"yaml": res.YAML,
"masque_yaml": res.MasqueYAML,
"address": res.Address,
"ipv6": res.IPv6,
"reserved": res.Reserved,
})
case "masque":
c.JSON(http.StatusOK, gin.H{
"yaml": res.MasqueYAML,
"address": res.Address,
"ipv6": res.IPv6,
})
case "both":
c.JSON(http.StatusOK, res)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid mode %q: must be wireguard, masque, or both", mode)})
}
}
// RestartPanel triggers a graceful panel restart. The handler responds 200
// first, then os.Exit(0) after a short delay so systemd's Restart=always
// brings the panel back. This is the same pattern used by RestoreDatabase.
func (h *Handler) RestartPanel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"message": "Panel is restarting now. This page will auto-reload when it is back.",
})
go func() {
time.Sleep(500 * time.Millisecond)
log.Printf("[INFO] Panel restart requested via web UI. Exiting.")
os.Exit(0)
}()
}
// UpdateInfo checks GitHub for the latest release and compares it to the
// running version. Returns current_version, latest_version, update_available.
// UpdateInfo checks GitHub for the latest release (stable and pre) and
// compares it to the running version. Returns current_version,
// current_channel, latest_stable, latest_pre, and update_available flags
// for both channels so the UI can offer switching between stable and pre.
func (h *Handler) UpdateInfo(c *gin.Context) {
current := buildinfo.Version
if current == "" || current == "dev" {
current = "dev"
}
// Detect current channel: read the CHANNEL file alongside the binary.
binPath, _ := os.Executable()
baseDir := filepath.Dir(binPath)
channelFile := filepath.Join(baseDir, "CHANNEL")
currentChannel := "stable"
if data, err := os.ReadFile(channelFile); err == nil {
ch := strings.TrimSpace(string(data))
if ch == "pre" || ch == "prerelease" {
currentChannel = "pre"
}
}
// If current version is "pre" or starts with a pre tag, detect from version too.
if current == "pre" || strings.Contains(current, "-rc") || strings.Contains(current, "-pre") {
currentChannel = "pre"
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
// Query BOTH the stable latest release AND the pre tag simultaneously.
type releaseInfo struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
}
fetchRelease := func(url string) (*releaseInfo, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var r releaseInfo
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return nil, err
}
return &r, nil
}
stableCh := make(chan *releaseInfo, 1)
preCh := make(chan *releaseInfo, 1)
stableErrCh := make(chan error, 1)
preErrCh := make(chan error, 1)
go func() {
r, err := fetchRelease("https://api.github.com/repos/kazeyukiro/3m-ui/releases/latest")
if err != nil {
stableErrCh <- err
return
}
stableCh <- r
}()
go func() {
r, err := fetchRelease("https://api.github.com/repos/kazeyukiro/3m-ui/releases/tags/pre")
if err != nil {
preErrCh <- err
return
}
preCh <- r
}()
var stable, pre *releaseInfo
var stableErr, preErr error
for i := 0; i < 2; i++ {
select {
case r := <-stableCh:
stable = r
case e := <-stableErrCh:
stableErr = e
case r := <-preCh:
pre = r
case e := <-preErrCh:
preErr = e
case <-ctx.Done():
stableErr = fmt.Errorf("timeout")
preErr = fmt.Errorf("timeout")
break
}
}
// Determine the "target" channel — the one the user is NOT currently on.
// latest_version + update_available reflect the target channel so the
// UI can show "switch to pre" or "switch to stable" appropriately.
targetChannel := "pre"
if currentChannel == "pre" {
targetChannel = "stable"
}
var latestTag, latestNotes, latestURL string
if targetChannel == "stable" && stable != nil {
latestTag = stable.TagName
latestNotes = stable.Body
latestURL = stable.HTMLURL
} else if targetChannel == "pre" && pre != nil {
latestTag = pre.TagName
latestNotes = pre.Body
latestURL = pre.HTMLURL
}
updateAvailable := false
if current != "dev" && latestTag != "" {
cur := strings.TrimPrefix(current, "v")
lat := strings.TrimPrefix(latestTag, "v")
updateAvailable = cur != lat
}
// Also include the other channel's version for display.
var stableTag, preTag string
if stable != nil {
stableTag = stable.TagName
}
if pre != nil {
preTag = pre.TagName
}
errMsg := ""
if stableErr != nil && preErr != nil {
errMsg = "cannot reach GitHub API (both stable and pre queries failed)"
}
c.JSON(http.StatusOK, gin.H{
"current_version": current,
"current_channel": currentChannel,
"target_channel": targetChannel,
"latest_version": latestTag,
"latest_stable": stableTag,
"latest_pre": preTag,
"update_available": updateAvailable,
"release_url": latestURL,
"release_notes": latestNotes,
"error": errMsg,
})
}
// startDetachedUpdate launches update.sh outside the panel service cgroup so
// "systemctl stop 3m-ui" does not kill the updater before it can start again.
func startDetachedUpdate(updateScript string, args []string, env []string) error {
if _, err := exec.LookPath("systemd-run"); err == nil {
unit := fmt.Sprintf("3m-ui-update-%d", time.Now().UnixNano())
sr := []string{
"--no-block",
"--collect",
"--unit=" + unit,
"--description=3m-ui panel self-update",
}
for _, e := range env {
if e == "" {
continue
}
// Only pass through relevant vars (avoid huge Environ() blow-up of secrets if any).
if strings.HasPrefix(e, "THREE_M_UI_") || strings.HasPrefix(e, "PATH=") ||
strings.HasPrefix(e, "HOME=") || strings.HasPrefix(e, "LANG=") {
sr = append(sr, "--setenv="+e)
}
}
sr = append(sr, updateScript)
sr = append(sr, args...)
out, err := exec.Command("systemd-run", sr...).CombinedOutput()
if err != nil {
return fmt.Errorf("systemd-run: %w (%s)", err, strings.TrimSpace(string(out)))
}
log.Printf("system update: scheduled via systemd-run unit %s", unit)
return nil
}
cmd := exec.Command(updateScript, args...)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return err
}
// Detach so the panel process exiting does not wait on / reap in a way that
// confuses the child on non-systemd hosts.
_ = cmd.Process.Release()
log.Printf("system update: started via setsid (no systemd-run)")
return nil
}
func (h *Handler) RunUpdate(c *gin.Context) {
var body struct {
Channel string `json:"channel"`
}
_ = c.ShouldBindJSON(&body)
channel := strings.TrimSpace(body.Channel)
if channel != "stable" && channel != "pre" {
channel = ""
}
binPath, _ := os.Executable()
baseDir := filepath.Dir(binPath)
updateScript := filepath.Join(baseDir, "update.sh")
if _, err := os.Stat(updateScript); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "update.sh not found at " + updateScript + ". Update via SSH: 3m-ui update",
})
return
}
// Build the command. update.sh accepts a positional argument for the
// version tag. "pre" selects the pre channel; no arg = latest stable.
args := []string{}
env := append(os.Environ(), "THREE_M_UI_UPDATE_SILENT=1")
if channel == "pre" {
args = append(args, "pre")
env = append(env, "THREE_M_UI_CHANNEL=pre")
} else if channel == "stable" {
env = append(env, "THREE_M_UI_CHANNEL=stable")
}
// Must not remain in the 3m-ui.service cgroup: install.sh stops the service,
// and systemd would kill this updater mid-flight. Prefer a transient systemd
// unit; fall back to setsid + process release.
env = append(env, "THREE_M_UI_UPDATE_ESCAPED=1")
if err := startDetachedUpdate(updateScript, args, env); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to start update: " + err.Error(),
})
return
}
msg := "Update started outside the panel process. The service will stop, upgrade, then start again automatically. This page will auto-reload."
if channel == "pre" {
msg = "Switching to pre-release channel. The panel will download the latest pre build and restart."
} else if channel == "stable" {
msg = "Switching to stable channel. The panel will download the latest stable release and restart."
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"channel": channel,
"message": msg,
})
}
func (h *Handler) ExportBackup(c *gin.Context) {
name := fmt.Sprintf("3m-ui-backup-%s.zip", time.Now().UTC().Format("20060102-150405"))
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
if err := WriteZip(c.Writer, BackupPaths{DatabasePath: h.dbPath, MihomoConfig: h.mihomoCfg}); err != nil {
_ = c.Error(err)
return
}
}
func (h *Handler) RestoreDatabase(c *gin.Context) {
if h.dbPath == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database path is not configured"})
return
}
// FormFile otherwise permits arbitrarily large multipart requests to be
// written to disk, turning an authenticated endpoint into a storage DoS.
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxRestoreDatabaseBytes)
// gin's default MultipartMemory is 32 MiB — files larger than that spill to
// os.TempDir(). Bump it to match the request body cap so a 100 MiB backup
// stays in memory (avoiding the disk-spill failure mode where TempDir is
// not writable or full). 128 MiB peak RAM is acceptable for an admin-only
// restore endpoint that already requires authentication.
_ = c.Request.ParseMultipartForm(maxRestoreDatabaseBytes)
file, err := c.FormFile("database")
if err != nil {
// Surface the actual underlying error so the operator can distinguish
// "no file selected" / "body too large" / "multipart parse error".
// Pre-fix this returned a generic message that masked the real cause.
errMsg := err.Error()
if strings.Contains(errMsg, "request body too large") {
errMsg = "upload exceeds 128 MiB limit"
} else if strings.Contains(errMsg, "missing boundary") {
errMsg = "invalid upload (missing multipart boundary); refresh the page and retry"
} else if strings.Contains(errMsg, "EOF") {
errMsg = "upload was empty or interrupted"
}
log.Printf("system restore-database: FormFile error: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": errMsg})
return
}
if file.Size > maxRestoreDatabaseBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "database backup exceeds 128 MiB limit"})
return
}
f, err := file.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
defer f.Close()
result, err := RestoreDatabase(h.dbPath, h.mihomoCfg, f)
if err != nil {
log.Printf("system restore-database failed: %v", err)
// Distinguish client errors (bad upload / not SQLite / corrupt zip)
// from server errors (disk full / permission denied). Client errors
// return 400 so the operator knows to fix the file, not the server.
errMsg := err.Error()
status := http.StatusInternalServerError
if strings.Contains(errMsg, "not a valid SQLite database") ||
strings.Contains(errMsg, "does not contain a database") ||
strings.Contains(errMsg, "extract from zip") ||
strings.Contains(errMsg, "empty or does not contain") ||
strings.Contains(errMsg, "database path is empty") {
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": errMsg})
return
}
log.Printf("[WARNING] Database restored from backup. Panel will exit now so systemd restarts it with the new DB. mihomo_config_skipped=%v", result.MihomoSkipped)
resp := gin.H{
"status": "ok",
"restart_required": true,
"message": "Database restored. The panel is exiting now so systemd (Restart=always) brings it back with the new DB. The browser will auto-reload when the panel is back.",
"path": filepath.Base(h.dbPath),
"mihomo_config": "",
}
if result.MihomoConfigPath != "" {
resp["mihomo_config"] = result.MihomoConfigPath
} else if result.MihomoSkipped {
resp["mihomo_config"] = "skipped (panel has no mihomo.config path)"
}
// Flush the response, then exit so systemd's Restart=always reboots the
// panel with the freshly-restored DB. Without this, the running GORM
// pool keeps writing to the old (now-unlinked) SQLite inode and every
// write is silently lost.
c.JSON(http.StatusOK, resp)
go func() {
time.Sleep(500 * time.Millisecond) // let gin flush
log.Printf("[WARNING] Panel exiting for post-restore restart.")
os.Exit(0)
}()
}
func (h *Handler) ReverseProxy(c *gin.Context) {
var req struct {
Kind string `json:"kind"`
Domain string `json:"domain"`
Upstream string `json:"upstream"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := ReverseProxyTemplate(req.Kind, req.Domain, req.Upstream)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"config": out})
}
func (h *Handler) ACME(c *gin.Context) {
var req struct {
Domain string `json:"domain"`
Email string `json:"email"`
Webroot string `json:"webroot"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Domain == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "domain is required"})
return
}
cmd, err := ACMECommand(req.Domain, req.Email, req.Webroot)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"command": cmd})
}
// UpdateGeoFiles downloads MetaCubeX GeoIP/GeoSite databases next to the Mihomo config .
func (h *Handler) UpdateGeoFiles(c *gin.Context) {
dir := filepath.Dir(h.mihomoCfg)
if h.mihomoCfg == "" {
dir = "/var/lib/3m-ui/mihomo"
}
result, err := UpdateGeoFiles(dir)
if err != nil {
log.Printf("system update-geofiles failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
c.JSON(http.StatusOK, gin.H{"dir": dir, "files": result})
}
func (h *Handler) backupDir() string {
return backupsDirFromDB(h.dbPath)
}
func (h *Handler) ListLocalBackups(c *gin.Context) {
items, total, err := listLocalBackups(h.backupDir())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total_bytes": total, "dir": h.backupDir()})
}
func (h *Handler) DeleteLocalBackup(c *gin.Context) {
name := c.Param("name")
if err := deleteLocalBackup(h.backupDir(), name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "deleted": name})
}
func (h *Handler) CleanupLocalBackups(c *gin.Context) {
var body struct {
Keep int `json:"keep"`
OlderThanDays int `json:"older_than_days"`
}
_ = c.ShouldBindJSON(&body)
deleted, kept, freed, err := cleanupLocalBackups(h.backupDir(), body.Keep, body.OlderThanDays)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"deleted": deleted,
"deleted_count": len(deleted),
"kept": kept,
"freed_bytes": freed,
})
}
func (h *Handler) WARPGet(c *gin.Context) {
acc, err := GetWARPAccount(h.db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if acc == nil {
c.JSON(http.StatusOK, WARPAccountView{Configured: false, ProxyName: WARPProxyName})
return
}
c.JSON(http.StatusOK, acc.View())
}
func (h *Handler) WARPCreate(c *gin.Context) {
res, err := RegisterWARP()
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
acc := AccountFromRegister(res)
if acc == nil || strings.TrimSpace(acc.PrivateKey) == "" {
c.JSON(http.StatusBadGateway, gin.H{"error": "WARP registration returned incomplete account"})
return
}
// Peer public key defaults to Cloudflare's shared key when the API omits peers.
if err := SaveWARPAccount(h.db, acc); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
mode := strings.TrimSpace(c.Query("mode"))
yamlOut := res.YAML
if mode == "masque" && res.MasqueYAML != "" {
yamlOut = res.MasqueYAML
}
c.JSON(http.StatusOK, gin.H{
"account": acc.View(),
"yaml": yamlOut,
"masque_yaml": res.MasqueYAML,
"proxy_name": WARPProxyName,
})
}
func (h *Handler) WARPDelete(c *gin.Context) {
if err := DeleteWARPAccount(h.db); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *Handler) GetPanelLogs(c *gin.Context) {
lines := panellog.Lines()
type logEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Payload string `json:"payload"`
}
out := make([]logEntry, 0, len(lines))
for _, line := range lines {
ts, payload := parsePanelLogLine(line)
out = append(out, logEntry{
Timestamp: ts.UTC().Format(time.RFC3339),
Level: panelLogLevel(payload),
Payload: payload,
})
}
c.JSON(http.StatusOK, out)
}
func parsePanelLogLine(line string) (time.Time, string) {
idx := strings.IndexByte(line, ' ')
if idx < 20 {
return time.Now(), line
}
ts, err := time.Parse(time.RFC3339, line[:idx])
if err != nil {
return time.Now(), line
}
return ts, strings.TrimSpace(line[idx+1:])
}
func panelLogLevel(payload string) string {
u := strings.ToUpper(payload)
switch {
case strings.Contains(u, "ERROR"), strings.Contains(u, "FATAL"):
return "error"
case strings.Contains(u, "WARN"):
return "warning"
default:
return "info"
}
}