Files
3x-ui/internal/web/service/panel/panel.go
T
MHSanaei 8e4c368200 feat(update): allow opting into the dev channel from a stable build
The panel version button opened the GitHub releases page on a stable, up-to-date build, and the dev-channel toggle only rendered on dev builds, so there was no in-panel path from stable to dev. Drop the IsDevBuild() guard in devChannelActive (the toggle alone drives the channel now), always open the update modal instead of releases, and always render the Dev channel switch.
2026-06-26 18:01:51 +02:00

387 lines
11 KiB
Go

package panel
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// PanelService provides business logic for panel management operations.
// It handles panel restart, updates, and system-level panel controls.
type PanelService struct{}
// PanelUpdateInfo contains the current and latest available panel versions.
// On the dev channel the version fields carry a "dev+<sha>" label and the commit
// fields hold the short SHAs that drive the update-available decision.
type PanelUpdateInfo struct {
Channel string `json:"channel"`
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
CurrentCommit string `json:"currentCommit,omitempty"`
LatestCommit string `json:"latestCommit,omitempty"`
UpdateAvailable bool `json:"updateAvailable"`
}
const (
panelUpdaterURL = "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh"
maxPanelUpdaterBytes = 2 << 20
// devReleaseTag is the fixed-tag rolling pre-release the CI force-moves to the
// newest main commit; the dev update channel installs from it.
devReleaseTag = "dev-latest"
)
var releaseCommitRegex = regexp.MustCompile(`(?i)commit=([0-9a-f]{7,40})`)
func (s *PanelService) RestartPanel(delay time.Duration) error {
go func() {
time.Sleep(delay)
if global.TriggerRestart() {
return
}
if runtime.GOOS == "windows" {
logger.Error("panel restart: no restart hook registered (SIGHUP unsupported on Windows)")
return
}
p, err := os.FindProcess(syscall.Getpid())
if err != nil {
logger.Error("panel restart: FindProcess failed:", err)
return
}
if err := p.Signal(syscall.SIGHUP); err != nil {
logger.Error("failed to send SIGHUP signal:", err)
}
}()
return nil
}
// GetUpdateInfo checks GitHub for the latest 3x-ui release. When the dev channel
// is enabled on a dev build it compares commits against the rolling dev release;
// otherwise it compares versions against the latest stable tag.
func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
if devChannelActive() {
return getDevUpdateInfo()
}
latest, err := fetchLatestPanelVersion()
if err != nil {
return nil, err
}
current := config.GetBaseVersion()
return &PanelUpdateInfo{
Channel: "stable",
CurrentVersion: current,
LatestVersion: latest,
UpdateAvailable: isNewerVersion(latest, current),
}, nil
}
// devChannelActive reports whether self-update should track the rolling dev
// release. It is driven solely by the opt-in setting so the panel can
// cross-grade a stable build onto the dev channel once the user enables it;
// nothing updates without an explicit user action, so an unattended stable
// binary with the toggle off stays on the stable channel.
func devChannelActive() bool {
enabled, err := (&service.SettingService{}).GetDevChannelEnable()
return err == nil && enabled
}
// getDevUpdateInfo compares the running commit against the commit recorded in the
// rolling dev release.
func getDevUpdateInfo() (*PanelUpdateInfo, error) {
release, err := fetchPanelRelease(devReleaseTag)
if err != nil {
return nil, err
}
latestCommit := extractReleaseCommit(release)
if latestCommit == "" {
return nil, fmt.Errorf("dev release commit is unknown")
}
currentCommit := config.GetBuildCommit()
return &PanelUpdateInfo{
Channel: "dev",
CurrentVersion: config.GetPanelVersion(),
CurrentCommit: shortCommit(currentCommit),
LatestCommit: shortCommit(latestCommit),
LatestVersion: "dev+" + shortCommit(latestCommit),
UpdateAvailable: !commitsEqual(currentCommit, latestCommit),
}, nil
}
// StartUpdate starts the official updater using this panel's own channel setting.
func (s *PanelService) StartUpdate() error {
return s.startUpdate(devChannelActive())
}
// StartUpdateChannel runs the updater against an explicitly chosen channel,
// overriding the local dev-channel setting. Used by the master node updater so
// a node can be moved to the dev channel from the central panel.
func (s *PanelService) StartUpdateChannel(dev bool) error {
return s.startUpdate(dev)
}
func (s *PanelService) startUpdate(useDev bool) error {
if runtime.GOOS != "linux" {
return fmt.Errorf("panel web update is supported only on Linux installations")
}
bash, err := exec.LookPath("bash")
if err != nil {
return fmt.Errorf("bash is required to run the panel updater: %w", err)
}
scriptPath, err := downloadPanelUpdater()
if err != nil {
return err
}
mainFolder, serviceFolder := resolveUpdateFolders()
updateTag := ""
if useDev {
updateTag = devReleaseTag
}
updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath))
if systemdRun, err := exec.LookPath("systemd-run"); err == nil {
unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix())
cmd := exec.Command(systemdRun,
"--unit", unitName,
"--setenv", "XUI_MAIN_FOLDER="+mainFolder,
"--setenv", "XUI_SERVICE="+serviceFolder,
"--setenv", "XUI_UPDATE_TAG="+updateTag,
bash, "-lc", updateScript,
)
out, err := cmd.CombinedOutput()
if err != nil {
output := strings.TrimSpace(string(out))
if !strings.Contains(output, "System has not been booted with systemd") &&
!strings.Contains(output, "Failed to connect to bus") {
_ = os.Remove(scriptPath)
return fmt.Errorf("failed to start panel update job: %w: %s", err, output)
}
logger.Warning("systemd-run is unavailable, falling back to detached update process:", output)
} else {
logger.Infof("started panel update job via systemd-run unit %s", unitName)
return nil
}
}
cmd := exec.Command(bash, "-lc", updateScript)
cmd.Env = append(os.Environ(),
"XUI_MAIN_FOLDER="+mainFolder,
"XUI_SERVICE="+serviceFolder,
"XUI_UPDATE_TAG="+updateTag,
)
setDetachedProcess(cmd)
if err := cmd.Start(); err != nil {
_ = os.Remove(scriptPath)
return fmt.Errorf("failed to start panel update job: %w", err)
}
if err := cmd.Process.Release(); err != nil {
logger.Warning("failed to release panel update process:", err)
}
logger.Infof("started panel update job with pid %d", cmd.Process.Pid)
return nil
}
func downloadPanelUpdater() (string, error) {
client := (&service.SettingService{}).NewProxiedHTTPClient(15 * time.Second)
resp, err := client.Get(panelUpdaterURL)
if err != nil {
return "", fmt.Errorf("download panel updater: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download panel updater: unexpected HTTP %d", resp.StatusCode)
}
file, err := os.CreateTemp("", "3x-ui-update-*.sh")
if err != nil {
return "", err
}
path := file.Name()
ok := false
defer func() {
_ = file.Close()
if !ok {
_ = os.Remove(path)
}
}()
n, err := io.Copy(file, io.LimitReader(resp.Body, maxPanelUpdaterBytes+1))
if err != nil {
return "", fmt.Errorf("write panel updater: %w", err)
}
if n > maxPanelUpdaterBytes {
return "", fmt.Errorf("panel updater exceeds %d bytes", maxPanelUpdaterBytes)
}
if err := file.Chmod(0700); err != nil {
return "", err
}
ok = true
return path, nil
}
func fetchLatestPanelVersion() (string, error) {
release, err := fetchPanelRelease("")
if err != nil {
return "", err
}
if release.TagName == "" {
return "", fmt.Errorf("latest panel release tag is empty")
}
return release.TagName, nil
}
// fetchPanelRelease fetches a release from GitHub. An empty tag resolves the
// latest stable release; a non-empty tag (e.g. dev-latest) resolves that tag.
func fetchPanelRelease(tag string) (*service.Release, error) {
url := "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest"
if tag != "" {
url = "https://api.github.com/repos/MHSanaei/3x-ui/releases/tags/" + tag
}
client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second)
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
}
var release service.Release
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}
return &release, nil
}
// extractReleaseCommit reads the build commit recorded in the dev release: first
// the `commit=<sha>` marker the CI writes into the body, falling back to the
// tag's target commit.
func extractReleaseCommit(release *service.Release) string {
if m := releaseCommitRegex.FindStringSubmatch(release.Body); m != nil {
return strings.ToLower(m[1])
}
if isCommitSHA(release.TargetCommitish) {
return strings.ToLower(release.TargetCommitish)
}
return ""
}
func isCommitSHA(s string) bool {
s = strings.TrimSpace(s)
if len(s) < 7 || len(s) > 40 {
return false
}
for _, r := range s {
if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') {
return false
}
}
return true
}
func shortCommit(sha string) string {
sha = strings.TrimSpace(sha)
if len(sha) > 8 {
return sha[:8]
}
return sha
}
// commitsEqual compares a short (injected) commit against a full release commit
// by prefix, so an 8-char build stamp matches the 40-char release SHA.
func commitsEqual(a, b string) bool {
a = strings.ToLower(strings.TrimSpace(a))
b = strings.ToLower(strings.TrimSpace(b))
if a == "" || b == "" {
return false
}
if len(a) > len(b) {
a, b = b, a
}
return strings.HasPrefix(b, a)
}
func resolveUpdateFolders() (string, string) {
mainFolder := os.Getenv("XUI_MAIN_FOLDER")
if mainFolder == "" {
if exePath, err := os.Executable(); err == nil {
mainFolder = filepath.Dir(exePath)
}
}
if mainFolder == "" {
mainFolder = "/usr/local/x-ui"
}
serviceFolder := os.Getenv("XUI_SERVICE")
if serviceFolder == "" {
serviceFolder = "/etc/systemd/system"
}
return mainFolder, serviceFolder
}
func isNewerVersion(latest string, current string) bool {
cmp, ok := compareVersionStrings(latest, current)
if !ok {
return normalizeVersionTag(latest) != normalizeVersionTag(current)
}
return cmp > 0
}
func compareVersionStrings(a string, b string) (int, bool) {
aParts, okA := parseVersionParts(a)
bParts, okB := parseVersionParts(b)
if !okA || !okB {
return 0, false
}
for i := range len(aParts) {
if aParts[i] > bParts[i] {
return 1, true
}
if aParts[i] < bParts[i] {
return -1, true
}
}
return 0, true
}
func parseVersionParts(version string) ([3]int, bool) {
var result [3]int
parts := strings.Split(normalizeVersionTag(version), ".")
if len(parts) != 3 {
return result, false
}
for i, part := range parts {
n, err := strconv.Atoi(part)
if err != nil {
return result, false
}
result[i] = n
}
return result, true
}
func normalizeVersionTag(version string) string {
return strings.TrimPrefix(strings.TrimSpace(version), "v")
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}