1
0
sprov 4 жил өмнө
parent
commit
3d7192d0f6

+ 2 - 2
database/db.go

@@ -51,9 +51,9 @@ func InitDB(dbPath string) error {
 	var gormLogger logger.Interface
 
 	if config.IsDebug() {
-		gormLogger = logger.Discard
-	} else {
 		gormLogger = logger.Default
+	} else {
+		gormLogger = logger.Discard
 	}
 
 	c := &gorm.Config{

+ 13 - 12
database/model/model.go

@@ -1,7 +1,8 @@
 package model
 
 import (
-	"encoding/json"
+	"fmt"
+	"x-ui/util/json_util"
 	"x-ui/xray"
 )
 
@@ -24,32 +25,32 @@ type User struct {
 
 type Inbound struct {
 	Id         int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
-	UserId     int    `json:"user_id" form:"user_id"`
-	Up         int64  `json:"up" form:"up"`
-	Down       int64  `json:"down" form:"down"`
+	UserId     int    `json:"-"`
+	Up         int64  `json:"up"`
+	Down       int64  `json:"down"`
 	Remark     string `json:"remark" form:"remark"`
 	Enable     bool   `json:"enable" form:"enable"`
-	ExpiryTime int64  `json:"expiry_time" form:"expiry_time"`
+	ExpiryTime int64  `json:"expiryTime" form:"expiryTime"`
 
 	// config part
 	Listen         string   `json:"listen" form:"listen"`
-	Port           int      `json:"port" form:"port"`
+	Port           int      `json:"port" form:"port" gorm:"unique"`
 	Protocol       Protocol `json:"protocol" form:"protocol"`
 	Settings       string   `json:"settings" form:"settings"`
-	StreamSettings string   `json:"stream_settings" form:"stream_settings"`
-	Tag            string   `json:"tag" form:"tag"`
+	StreamSettings string   `json:"streamSettings" form:"streamSettings"`
+	Tag            string   `json:"tag" form:"tag" gorm:"unique"`
 	Sniffing       string   `json:"sniffing" form:"sniffing"`
 }
 
 func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
 	return &xray.InboundConfig{
-		Listen:         json.RawMessage(i.Listen),
+		Listen:         json_util.RawMessage(fmt.Sprintf("\"%s\"", i.Listen)),
 		Port:           i.Port,
 		Protocol:       string(i.Protocol),
-		Settings:       json.RawMessage(i.Settings),
-		StreamSettings: json.RawMessage(i.StreamSettings),
+		Settings:       json_util.RawMessage(i.Settings),
+		StreamSettings: json_util.RawMessage(i.StreamSettings),
 		Tag:            i.Tag,
-		Sniffing:       json.RawMessage(i.Sniffing),
+		Sniffing:       json_util.RawMessage(i.Sniffing),
 	}
 }
 

+ 148 - 0
install.sh

@@ -0,0 +1,148 @@
+#!/bin/bash
+
+red='\033[0;31m'
+green='\033[0;32m'
+yellow='\033[0;33m'
+plain='\033[0m'
+
+cur_dir=$(pwd)
+
+# check root
+[[ $EUID -ne 0 ]] && echo -e "${red}错误:${plain} 必须使用root用户运行此脚本!\n" && exit 1
+
+# check os
+if [[ -f /etc/redhat-release ]]; then
+    release="centos"
+elif cat /etc/issue | grep -Eqi "debian"; then
+    release="debian"
+elif cat /etc/issue | grep -Eqi "ubuntu"; then
+    release="ubuntu"
+elif cat /etc/issue | grep -Eqi "centos|red hat|redhat"; then
+    release="centos"
+elif cat /proc/version | grep -Eqi "debian"; then
+    release="debian"
+elif cat /proc/version | grep -Eqi "ubuntu"; then
+    release="ubuntu"
+elif cat /proc/version | grep -Eqi "centos|red hat|redhat"; then
+    release="centos"
+else
+    echo -e "${red}未检测到系统版本,请联系脚本作者!${plain}\n" && exit 1
+fi
+
+arch=$(arch)
+
+if [[ $arch == "x86_64" || $arch == "x64" || $arch == "amd64" ]]; then
+  arch="amd64"
+elif [[ $arch == "aarch64" || $arch == "arm64" ]]; then
+  arch="arm64"
+else
+  arch="amd64"
+  echo -e "${red}检测架构失败,使用默认架构: ${arch}${plain}"
+fi
+
+echo "架构: ${arch}"
+
+if [ $(getconf WORD_BIT) != '32' ] && [ $(getconf LONG_BIT) != '64' ] ; then
+    echo "本软件不支持 32 位系统(x86),请使用 64 位系统(x86_64),如果检测有误,请联系作者"
+    exit -1
+fi
+
+os_version=""
+
+# os version
+if [[ -f /etc/os-release ]]; then
+    os_version=$(awk -F'[= ."]' '/VERSION_ID/{print $3}' /etc/os-release)
+fi
+if [[ -z "$os_version" && -f /etc/lsb-release ]]; then
+    os_version=$(awk -F'[= ."]+' '/DISTRIB_RELEASE/{print $2}' /etc/lsb-release)
+fi
+
+if [[ x"${release}" == x"centos" ]]; then
+    if [[ ${os_version} -le 6 ]]; then
+        echo -e "${red}请使用 CentOS 7 或更高版本的系统!${plain}\n" && exit 1
+    fi
+elif [[ x"${release}" == x"ubuntu" ]]; then
+    if [[ ${os_version} -lt 16 ]]; then
+        echo -e "${red}请使用 Ubuntu 16 或更高版本的系统!${plain}\n" && exit 1
+    fi
+elif [[ x"${release}" == x"debian" ]]; then
+    if [[ ${os_version} -lt 8 ]]; then
+        echo -e "${red}请使用 Debian 8 或更高版本的系统!${plain}\n" && exit 1
+    fi
+fi
+
+install_base() {
+    if [[ x"${release}" == x"centos" ]]; then
+        yum install wget curl tar -y
+    else
+        apt install wget curl tar -y
+    fi
+}
+
+install_x-ui() {
+    systemctl stop x-ui
+    cd /usr/local/
+    if [[ -e /usr/local/x-ui/ ]]; then
+        rm /usr/local/x-ui/ -rf
+    fi
+
+    if  [ $# == 0 ] ;then
+        last_version=$(curl -Ls "https://api.github.com/repos/sprov065/x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
+        if [[ ! -n "$last_version" ]]; then
+            echo -e "${red}检测 x-ui 版本失败,可能是超出 Github API 限制,请稍后再试,或手动指定 x-ui 版本安装${plain}"
+            exit 1
+        fi
+        echo -e "检测到 x-ui 最新版本:${last_version},开始安装"
+        wget -N --no-check-certificate -O /usr/local/x-ui-linux-${arch}.tar.gz https://github.com/sprov065/x-ui/releases/download/${last_version}/x-ui-linux-${arch}.tar.gz
+        if [[ $? -ne 0 ]]; then
+            echo -e "${red}下载 x-ui 失败,请确保你的服务器能够下载 Github 的文件${plain}"
+            exit 1
+        fi
+    else
+        last_version=$1
+        url="https://github.com/sprov065/x-ui/releases/download/${last_version}/x-ui-linux-${arch}.tar.gz"
+        echo -e "开始安装 x-ui v$1"
+        wget -N --no-check-certificate -O /usr/local/x-ui-linux-${arch}.tar.gz ${url}
+        if [[ $? -ne 0 ]]; then
+            echo -e "${red}下载 x-ui v$1 失败,请确保此版本存在${plain}"
+            exit 1
+        fi
+    fi
+
+    tar zxvf x-ui-linux-${arch}.tar.gz
+    rm x-ui-linux-${arch}.tar.gz -f
+    cd x-ui
+    chmod +x x-ui bin/xray-x-ui-linux-${arch}
+    cp -f x-ui.service /etc/systemd/system/
+    systemctl daemon-reload
+    systemctl enable x-ui
+    systemctl start x-ui
+    echo -e "${green}x-ui v${last_version}${plain} 安装完成,面板已启动,"
+    echo -e ""
+    echo -e "如果是全新安装,默认网页端口为 ${green}54321${plain},用户名和密码默认都是 ${green}admin${plain}"
+    echo -e "请自行确保此端口没有被其他程序占用,${yellow}并且确保 54321 端口已放行${plain}"
+#    echo -e "若想将 54321 修改为其它端口,输入 x-ui 命令进行修改,同样也要确保你修改的端口也是放行的"
+    echo -e ""
+    echo -e "如果是更新面板,则按你之前的方式访问面板"
+    echo -e ""
+    curl -o /usr/bin/x-ui -Ls https://raw.githubusercontent.com/sprov065/x-ui/master/x-ui.sh
+    chmod +x /usr/bin/x-ui
+    echo -e "x-ui 管理脚本使用方法: "
+    echo -e "----------------------------------------------"
+    echo -e "x-ui              - 显示管理菜单 (功能更多)"
+    echo -e "x-ui start        - 启动 x-ui 面板"
+    echo -e "x-ui stop         - 停止 x-ui 面板"
+    echo -e "x-ui restart      - 重启 x-ui 面板"
+    echo -e "x-ui status       - 查看 x-ui 状态"
+    echo -e "x-ui enable       - 设置 x-ui 开机自启"
+    echo -e "x-ui disable      - 取消 x-ui 开机自启"
+    echo -e "x-ui log          - 查看 x-ui 日志"
+    echo -e "x-ui update       - 更新 x-ui 面板"
+    echo -e "x-ui install      - 安装 x-ui 面板"
+    echo -e "x-ui uninstall    - 卸载 x-ui 面板"
+    echo -e "----------------------------------------------"
+}
+
+echo -e "${green}开始安装${plain}"
+install_base
+install_x-ui $1

+ 106 - 9
main.go

@@ -12,8 +12,10 @@ import (
 	"x-ui/config"
 	"x-ui/database"
 	"x-ui/logger"
+	"x-ui/v2ui"
 	"x-ui/web"
 	"x-ui/web/global"
+	"x-ui/web/service"
 )
 
 // this function call global.setWebServer
@@ -46,7 +48,8 @@ func runWebServer() {
 	setWebServer(server)
 	err = server.Start()
 	if err != nil {
-		panic(err)
+		log.Println(err)
+		return
 	}
 
 	sigCh := make(chan os.Signal, 1)
@@ -60,7 +63,8 @@ func runWebServer() {
 			setWebServer(server)
 			err = server.Start()
 			if err != nil {
-				panic(err)
+				log.Println(err)
+				return
 			}
 		} else {
 			continue
@@ -68,8 +72,48 @@ func runWebServer() {
 	}
 }
 
-func v2ui(dbPath string) {
-	// migrate from v2-ui
+func resetSetting() {
+	err := database.InitDB(config.GetDBPath())
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+
+	settingService := service.SettingService{}
+	err = settingService.ResetSettings()
+	if err != nil {
+		fmt.Println("reset setting failed:", err)
+	} else {
+		fmt.Println("reset setting success")
+	}
+}
+
+func updateSetting(port int, username string, password string) {
+	err := database.InitDB(config.GetDBPath())
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+
+	settingService := service.SettingService{}
+
+	if port > 0 {
+		err := settingService.SetPort(port)
+		if err != nil {
+			fmt.Println("set port failed:", err)
+		} else {
+			fmt.Printf("set port %v success", port)
+		}
+	}
+	if username != "" || password != "" {
+		userService := service.UserService{}
+		err := userService.UpdateFirstUser(username, password)
+		if err != nil {
+			fmt.Println("set username and password failed:", err)
+		} else {
+			fmt.Println("set username and password success")
+		}
+	}
 }
 
 func main() {
@@ -78,24 +122,77 @@ func main() {
 		return
 	}
 
+	var showVersion bool
+	flag.BoolVar(&showVersion, "v", false, "show version")
+
 	runCmd := flag.NewFlagSet("run", flag.ExitOnError)
 
 	v2uiCmd := flag.NewFlagSet("v2-ui", flag.ExitOnError)
 	var dbPath string
 	v2uiCmd.StringVar(&dbPath, "db", "/etc/v2-ui/v2-ui.db", "set v2-ui db file path")
 
-	switch flag.Arg(0) {
+	settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
+	var port int
+	var username string
+	var password string
+	var reset bool
+	settingCmd.BoolVar(&reset, "reset", false, "reset all setting")
+	settingCmd.IntVar(&port, "port", 0, "set panel port")
+	settingCmd.StringVar(&username, "username", "", "set login username")
+	settingCmd.StringVar(&password, "password", "", "set login password")
+
+	oldUsage := flag.Usage
+	flag.Usage = func() {
+		oldUsage()
+		fmt.Println()
+		fmt.Println("Commands:")
+		fmt.Println("    run            run web panel")
+		fmt.Println("    v2-ui          migrate form v2-ui")
+		fmt.Println("    setting        set settings")
+	}
+
+	flag.Parse()
+	if showVersion {
+		fmt.Println(config.GetVersion())
+		return
+	}
+
+	switch os.Args[1] {
 	case "run":
-		runCmd.Parse(os.Args[2:])
+		err := runCmd.Parse(os.Args[2:])
+		if err != nil {
+			fmt.Println(err)
+			return
+		}
 		runWebServer()
 	case "v2-ui":
-		v2uiCmd.Parse(os.Args[2:])
-		v2ui(dbPath)
+		err := v2uiCmd.Parse(os.Args[2:])
+		if err != nil {
+			fmt.Println(err)
+			return
+		}
+		err = v2ui.MigrateFromV2UI(dbPath)
+		if err != nil {
+			logger.Error("migrate from v2-ui failed:", err)
+		}
+	case "setting":
+		err := settingCmd.Parse(os.Args[2:])
+		if err != nil {
+			fmt.Println(err)
+			return
+		}
+		if reset {
+			resetSetting()
+		} else {
+			updateSetting(port, username, password)
+		}
 	default:
-		fmt.Println("excepted 'run' or 'v2-ui' subcommands")
+		fmt.Println("except 'run' or 'v2-ui' or 'setting' subcommands")
 		fmt.Println()
 		runCmd.Usage()
 		fmt.Println()
 		v2uiCmd.Usage()
+		fmt.Println()
+		settingCmd.Usage()
 	}
 }

+ 16 - 29
util/json_util/json.go

@@ -1,37 +1,24 @@
 package json_util
 
 import (
-	"encoding/json"
-	"reflect"
-	"x-ui/util/reflect_util"
+	"errors"
 )
 
-/*
-MarshalJSON 特殊处理 json.RawMessage
+type RawMessage []byte
 
-当 json.RawMessage 不为 nil 且 len() 为 0 时,MarshalJSON 将会解析报错
-*/
-func MarshalJSON(i interface{}) ([]byte, error) {
-	m := map[string]interface{}{}
-	t := reflect.TypeOf(i).Elem()
-	v := reflect.ValueOf(i).Elem()
-	fields := reflect_util.GetFields(t)
-	for _, field := range fields {
-		key := field.Tag.Get("json")
-		if key == "" || key == "-" {
-			continue
-		}
-		fieldV := v.FieldByName(field.Name)
-		value := fieldV.Interface()
-		switch value.(type) {
-		case json.RawMessage:
-			value := value.(json.RawMessage)
-			if len(value) > 0 {
-				m[key] = value
-			}
-		default:
-			m[key] = value
-		}
+// MarshalJSON 自定义 json.RawMessage 默认行为
+func (m RawMessage) MarshalJSON() ([]byte, error) {
+	if len(m) == 0 {
+		return []byte("null"), nil
 	}
-	return json.Marshal(m)
+	return m, nil
+}
+
+// UnmarshalJSON sets *m to a copy of data.
+func (m *RawMessage) UnmarshalJSON(data []byte) error {
+	if m == nil {
+		return errors.New("json.RawMessage: UnmarshalJSON on nil pointer")
+	}
+	*m = append((*m)[0:0], data...)
+	return nil
 }

+ 7 - 0
v2ui/v2ui.go

@@ -0,0 +1,7 @@
+package v2ui
+
+import "errors"
+
+func MigrateFromV2UI(dbPath string) error {
+	return errors.New("not support right now")
+}

+ 1 - 1
web/assets/css/custom.css

@@ -3,7 +3,7 @@
 }
 
 .ant-space {
-    display: block;
+    width: 100%;
 }
 
 .ant-layout-sider-zero-width-trigger {

+ 2 - 2
web/assets/js/model/models.js

@@ -84,7 +84,7 @@ class DBInbound {
         }
     }
 
-    genLink(address="") {
+    genLink(address = "") {
         const inbound = this.toInbound();
         return inbound.genLink(address, this.remark);
     }
@@ -92,7 +92,7 @@ class DBInbound {
 
 class AllSetting {
     webListen = "";
-    webPort = 65432;
+    webPort = 54321;
     webCertFile = "";
     webKeyFile = "";
     webBasePath = "/";

+ 3 - 0
web/assets/js/util/utils.js

@@ -223,6 +223,9 @@ class ObjectUtil {
     }
 
     static cloneProps(dest, src, ...ignoreProps) {
+        if (dest == null || src == null) {
+            return;
+        }
         const ignoreEmpty = this.isArrEmpty(ignoreProps);
         for (const key of Object.keys(src)) {
             if (!src.hasOwnProperty(key)) {

+ 5 - 8
web/controller/inbound.go

@@ -3,7 +3,6 @@ package controller
 import (
 	"fmt"
 	"github.com/gin-gonic/gin"
-	"go.uber.org/atomic"
 	"strconv"
 	"x-ui/database/model"
 	"x-ui/logger"
@@ -15,8 +14,6 @@ import (
 type InboundController struct {
 	inboundService service.InboundService
 	xrayService    service.XrayService
-
-	isNeedXrayRestart atomic.Bool
 }
 
 func NewInboundController(g *gin.RouterGroup) *InboundController {
@@ -39,12 +36,12 @@ func (a *InboundController) startTask() {
 	webServer := global.GetWebServer()
 	c := webServer.GetCron()
 	c.AddFunc("@every 10s", func() {
-		if a.isNeedXrayRestart.Load() {
+		if a.xrayService.IsNeedRestart() {
+			a.xrayService.SetIsNeedRestart(false)
 			err := a.xrayService.RestartXray()
 			if err != nil {
 				logger.Error("restart xray failed:", err)
 			}
-			a.isNeedXrayRestart.Store(false)
 		}
 	})
 }
@@ -73,7 +70,7 @@ func (a *InboundController) addInbound(c *gin.Context) {
 	err = a.inboundService.AddInbound(inbound)
 	jsonMsg(c, "添加", err)
 	if err == nil {
-		a.isNeedXrayRestart.Store(true)
+		a.xrayService.SetIsNeedRestart(true)
 	}
 }
 
@@ -86,7 +83,7 @@ func (a *InboundController) delInbound(c *gin.Context) {
 	err = a.inboundService.DelInbound(id)
 	jsonMsg(c, "删除", err)
 	if err == nil {
-		a.isNeedXrayRestart.Store(true)
+		a.xrayService.SetIsNeedRestart(true)
 	}
 }
 
@@ -107,6 +104,6 @@ func (a *InboundController) updateInbound(c *gin.Context) {
 	err = a.inboundService.UpdateInbound(inbound)
 	jsonMsg(c, "修改", err)
 	if err == nil {
-		a.isNeedXrayRestart.Store(true)
+		a.xrayService.SetIsNeedRestart(true)
 	}
 }

+ 8 - 17
web/controller/server.go

@@ -38,28 +38,19 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
 }
 
 func (a *ServerController) refreshStatus() {
-	status := a.serverService.GetStatus(a.lastStatus)
-	a.lastStatus = status
+	a.lastStatus = a.serverService.GetStatus(a.lastStatus)
 }
 
 func (a *ServerController) startTask() {
 	webServer := global.GetWebServer()
-	ctx := webServer.GetCtx()
-	go func() {
-		for {
-			select {
-			case <-ctx.Done():
-				return
-			default:
-			}
-			now := time.Now()
-			if now.Sub(a.lastGetStatusTime) > time.Minute*3 {
-				time.Sleep(time.Second * 2)
-				continue
-			}
-			a.refreshStatus()
+	c := webServer.GetCron()
+	c.AddFunc("@every 2s", func() {
+		now := time.Now()
+		if now.Sub(a.lastGetStatusTime) > time.Minute*3 {
+			return
 		}
-	}()
+		a.refreshStatus()
+	})
 }
 
 func (a *ServerController) status(c *gin.Context) {

+ 44 - 0
web/controller/setting.go

@@ -1,13 +1,25 @@
 package controller
 
 import (
+	"errors"
 	"github.com/gin-gonic/gin"
+	"time"
 	"x-ui/web/entity"
 	"x-ui/web/service"
+	"x-ui/web/session"
 )
 
+type updateUserForm struct {
+	OldUsername string `json:"oldUsername" form:"oldUsername"`
+	OldPassword string `json:"oldPassword" form:"oldPassword"`
+	NewUsername string `json:"newUsername" form:"newUsername"`
+	NewPassword string `json:"newPassword" form:"newPassword"`
+}
+
 type SettingController struct {
 	settingService service.SettingService
+	userService    service.UserService
+	panelService   service.PanelService
 }
 
 func NewSettingController(g *gin.RouterGroup) *SettingController {
@@ -21,6 +33,8 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
 
 	g.POST("/all", a.getAllSetting)
 	g.POST("/update", a.updateSetting)
+	g.POST("/updateUser", a.updateUser)
+	g.POST("/restartPanel", a.restartPanel)
 }
 
 func (a *SettingController) getAllSetting(c *gin.Context) {
@@ -42,3 +56,33 @@ func (a *SettingController) updateSetting(c *gin.Context) {
 	err = a.settingService.UpdateAllSetting(allSetting)
 	jsonMsg(c, "修改设置", err)
 }
+
+func (a *SettingController) updateUser(c *gin.Context) {
+	form := &updateUserForm{}
+	err := c.ShouldBind(form)
+	if err != nil {
+		jsonMsg(c, "修改用户", err)
+		return
+	}
+	user := session.GetLoginUser(c)
+	if user.Username != form.OldUsername || user.Password != form.OldPassword {
+		jsonMsg(c, "修改用户", errors.New("原用户名或原密码错误"))
+		return
+	}
+	if form.NewUsername == "" || form.NewPassword == "" {
+		jsonMsg(c, "修改用户", errors.New("新用户名和新密码不能为空"))
+		return
+	}
+	err = a.userService.UpdateUser(user.Id, form.NewUsername, form.NewPassword)
+	if err == nil {
+		user.Username = form.NewUsername
+		user.Password = form.NewPassword
+		session.SetLoginUser(c, user)
+	}
+	jsonMsg(c, "修改用户", err)
+}
+
+func (a *SettingController) restartPanel(c *gin.Context) {
+	err := a.panelService.RestartPanel(time.Second * 3)
+	jsonMsg(c, "重启面板", err)
+}

+ 2 - 2
web/entity/entity.go

@@ -58,10 +58,10 @@ func (s *AllSetting) CheckValid() error {
 	}
 
 	if !strings.HasPrefix(s.WebBasePath, "/") {
-		return common.NewErrorf("web base path must start with '/' : <%v>", s.WebBasePath)
+		s.WebBasePath = "/" + s.WebBasePath
 	}
 	if !strings.HasSuffix(s.WebBasePath, "/") {
-		return common.NewErrorf("web base path must end with '/' : <%v>", s.WebBasePath)
+		s.WebBasePath += "/"
 	}
 
 	xrayConfig := &xray.Config{}

+ 4 - 4
web/html/xui/common_sider.html

@@ -11,10 +11,10 @@
     <a-icon type="setting"></a-icon>
     <span>面板设置</span>
 </a-menu-item>
-<a-menu-item key="{{ .base_path }}xui/clients">
-    <a-icon type="laptop"></a-icon>
-    <span>客户端</span>
-</a-menu-item>
+<!--<a-menu-item key="{{ .base_path }}xui/clients">-->
+<!--    <a-icon type="laptop"></a-icon>-->
+<!--    <span>客户端</span>-->
+<!--</a-menu-item>-->
 <a-sub-menu>
     <template slot="title">
         <a-icon type="link"></a-icon>

+ 1 - 1
web/html/xui/component/setting.html

@@ -12,7 +12,7 @@
                 <a-input type="number" :value="value" @input="$emit('input', $event.target.value)"></a-input>
             </template>
             <template v-else-if="type === 'textarea'">
-                <a-textarea :value="value" @input="$emit('input', $event.target.value)" :auto-size="{ minRows: 6, maxRows: 6 }"></a-textarea>
+                <a-textarea :value="value" @input="$emit('input', $event.target.value)" :auto-size="{ minRows: 10, maxRows: 10 }"></a-textarea>
             </template>
         </a-col>
     </a-row>

+ 16 - 42
web/html/xui/inbounds.html

@@ -19,7 +19,7 @@
         <a-layout-content>
             <a-spin :spinning="spinning" :delay="500" tip="loading">
                 <transition name="list" appear>
-                    <a-tag v-if="true" color="red" style="margin-bottom: 10px">
+                    <a-tag v-if="false" color="red" style="margin-bottom: 10px">
                         Please go to the panel settings as soon as possible to modify the username and password, otherwise there may be a risk of leaking account information
                     </a-tag>
                 </transition>
@@ -32,11 +32,7 @@
                             </a-col>
                             <a-col :xs="24" :sm="24" :lg="12">
                                 total traffic:
-                                <a-popconfirm title="Are you sure you want to reset all traffic to 0? It\'s unrecoverable"
-                                              @confirm="resetAllTraffic()"
-                                              ok-text="confirm" cancel-text="cancel">
-                                    <a-tag color="green">[[ sizeFormat(total.up + total.down) ]]</a-tag>
-                                </a-popconfirm>
+                                <a-tag color="green">[[ sizeFormat(total.up + total.down) ]]</a-tag>
                             </a-col>
                             <a-col :xs="24" :sm="24" :lg="12">
                                 number of accounts:
@@ -112,26 +108,21 @@
         align: 'center',
         width: 60,
         scopedSlots: { customRender: 'traffic' },
-    }, {
-        title: "settings",
-        align: 'center',
-        width: 60,
-        scopedSlots: { customRender: 'settings' },
-    }, {
-        title: "streamSettings",
-        align: 'center',
-        width: 60,
-        scopedSlots: { customRender: 'streamSettings' },
+    // }, {
+    //     title: "settings",
+    //     align: 'center',
+    //     width: 60,
+    //     scopedSlots: { customRender: 'settings' },
+    // }, {
+    //     title: "streamSettings",
+    //     align: 'center',
+    //     width: 60,
+    //     scopedSlots: { customRender: 'streamSettings' },
     }, {
         title: "enable",
         align: 'center',
         width: 60,
         scopedSlots: { customRender: 'enable' },
-    }, {
-        title: "expiryTime",
-        align: 'center',
-        width: 60,
-        scopedSlots: { customRender: 'expiryTime' },
     }, {
         title: "action",
         align: 'center',
@@ -213,7 +204,7 @@
                     port: inbound.port,
                     protocol: inbound.protocol,
                     settings: inbound.settings.toString(),
-                    stream_settings: inbound.stream.toString(),
+                    streamSettings: inbound.stream.toString(),
                     sniffing: inbound.canSniffing() ? inbound.sniffing.toString() : '{}',
                 };
                 await this.submit('/xui/inbound/add', data, inModal);
@@ -227,7 +218,7 @@
                     port: inbound.port,
                     protocol: inbound.protocol,
                     settings: inbound.settings.toString(),
-                    stream_settings: inbound.stream.toString(),
+                    streamSettings: inbound.stream.toString(),
                     sniffing: inbound.canSniffing() ? inbound.sniffing.toString() : '{}',
                 };
                 await this.submit(`/xui/inbound/update/${dbInbound.id}`, data, inModal);
@@ -249,30 +240,13 @@
                 const link = dbInbound.genLink(address);
                 qrModal.show('二维码', link);
             },
-            resetTraffic(inbound) {
-                this.submit(`/xui/reset_traffic/${inbound.id}`);
-            },
-            resetAllTraffic() {
-                this.submit('/xui/reset_all_traffic');
-            },
             switchEnable(dbInbound) {
-                const data = {
-                    remark: dbInbound.remark,
-                    enable: dbInbound.enable,
-
-                    listen: dbInbound.listen,
-                    port: dbInbound.port,
-                    protocol: dbInbound.protocol,
-                    settings: dbInbound.settings,
-                    stream_settings: dbInbound.stream,
-                    sniffing: dbInbound.sniffing,
-                };
-                this.submit(`/xui/inbound/update/${dbInbound.id}`, data);
+                this.submit(`/xui/inbound/update/${dbInbound.id}`, dbInbound);
             },
             async submit(url, data, modal) {
                 const msg = await HttpUtil.postWithModal(url, data, modal);
                 if (msg.success) {
-                    this.getDBInbounds();
+                    await this.getDBInbounds();
                 }
             },
         },

+ 41 - 5
web/html/xui/setting.html

@@ -31,7 +31,10 @@
         <a-layout-content>
             <a-spin :spinning="spinning" :delay="500" tip="loading">
                 <a-space direction="vertical">
-                    <a-button type="primary" :disabled="saveBtnDisable" @click="updateAllSetting">保存配置</a-button>
+                    <a-space direction="horizontal">
+                        <a-button type="primary" :disabled="saveBtnDisable" @click="updateAllSetting">保存配置</a-button>
+                        <a-button type="danger" :disabled="!saveBtnDisable" @click="restartPanel">重启面板</a-button>
+                    </a-space>
                     <a-tabs default-active-key="1">
                         <a-tab-pane key="1" tab="面板配置">
                             <a-list item-layout="horizontal" style="background: white">
@@ -45,16 +48,21 @@
                         <a-tab-pane key="2" tab="用户设置">
                             <a-form style="background: white; padding: 20px">
                                 <a-form-item label="原用户名">
-                                    <a-input></a-input>
+                                    <a-input v-model="user.oldUsername" style="max-width: 300px"></a-input>
                                 </a-form-item>
                                 <a-form-item label="原密码">
-                                    <a-input></a-input>
+                                    <a-input type="password" v-model="user.oldPassword"
+                                             style="max-width: 300px"></a-input>
                                 </a-form-item>
                                 <a-form-item label="新用户名">
-                                    <a-input></a-input>
+                                    <a-input v-model="user.newUsername" style="max-width: 300px"></a-input>
                                 </a-form-item>
                                 <a-form-item label="新密码">
-                                    <a-input></a-input>
+                                    <a-input type="password" v-model="user.newPassword"
+                                             style="max-width: 300px"></a-input>
+                                </a-form-item>
+                                <a-form-item>
+                                    <a-button type="primary" @click="updateUser">修改</a-button>
                                 </a-form-item>
                             </a-form>
                         </a-tab-pane>
@@ -87,6 +95,7 @@
             oldAllSetting: new AllSetting(),
             allSetting: new AllSetting(),
             saveBtnDisable: true,
+            user: {},
         },
         methods: {
             loading(spinning = true) {
@@ -109,6 +118,33 @@
                 if (msg.success) {
                     await this.getAllSetting();
                 }
+            },
+            async updateUser() {
+                this.loading(true);
+                const msg = await HttpUtil.post("/xui/setting/updateUser", this.user);
+                this.loading(false);
+                if (msg.success) {
+                    this.user = {};
+                }
+            },
+            async restartPanel() {
+                await new Promise(resolve => {
+                    this.$confirm({
+                        title: '重启面板',
+                        content: '确定要重启面板吗?点击确定将于 3 秒后重启,若重启后无法访问面板,请前往服务器查看面板日志信息',
+                        okText: '确定',
+                        cancelText: '取消',
+                        onOk: () => resolve(),
+                    });
+                });
+                this.loading(true);
+                const msg = await HttpUtil.post("/xui/setting/restartPanel");
+                this.loading(false);
+                if (msg.success) {
+                    this.loading(true);
+                    await PromiseUtil.sleep(5000);
+                    location.reload();
+                }
             }
         },
         async mounted() {

+ 26 - 0
web/service/panel.go

@@ -0,0 +1,26 @@
+package service
+
+import (
+	"os"
+	"syscall"
+	"time"
+	"x-ui/logger"
+)
+
+type PanelService struct {
+}
+
+func (s *PanelService) RestartPanel(delay time.Duration) error {
+	p, err := os.FindProcess(syscall.Getpid())
+	if err != nil {
+		return err
+	}
+	go func() {
+		time.Sleep(delay)
+		err := p.Signal(syscall.SIGHUP)
+		if err != nil {
+			logger.Error("send signal SIGHUP failed:", err)
+		}
+	}()
+	return nil
+}

+ 1 - 1
web/service/server.go

@@ -77,7 +77,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 		T: now,
 	}
 
-	percents, err := cpu.Percent(time.Second*2, false)
+	percents, err := cpu.Percent(0, false)
 	if err != nil {
 		logger.Warning("get cpu percent failed:", err)
 	} else {

+ 14 - 2
web/service/setting.go

@@ -23,7 +23,7 @@ var xrayTemplateConfig string
 var defaultValueMap = map[string]string{
 	"xrayTemplateConfig": xrayTemplateConfig,
 	"webListen":          "",
-	"webPort":            "65432",
+	"webPort":            "54321",
 	"webCertFile":        "",
 	"webKeyFile":         "",
 	"secret":             random.Seq(32),
@@ -109,7 +109,7 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
 
 func (s *SettingService) ResetSettings() error {
 	db := database.GetDB()
-	return db.Delete(model.Setting{}).Error
+	return db.Where("1 = 1").Delete(model.Setting{}).Error
 }
 
 func (s *SettingService) getSetting(key string) (*model.Setting, error) {
@@ -152,6 +152,10 @@ func (s *SettingService) getString(key string) (string, error) {
 	return setting.Value, nil
 }
 
+func (s *SettingService) setString(key string, value string) error {
+	return s.saveSetting(key, value)
+}
+
 func (s *SettingService) getInt(key string) (int, error) {
 	str, err := s.getString(key)
 	if err != nil {
@@ -160,6 +164,10 @@ func (s *SettingService) getInt(key string) (int, error) {
 	return strconv.Atoi(str)
 }
 
+func (s *SettingService) setInt(key string, value int) error {
+	return s.setString(key, strconv.Itoa(value))
+}
+
 func (s *SettingService) GetXrayConfigTemplate() (string, error) {
 	return s.getString("xrayTemplateConfig")
 }
@@ -172,6 +180,10 @@ func (s *SettingService) GetPort() (int, error) {
 	return s.getInt("webPort")
 }
 
+func (s *SettingService) SetPort(port int) error {
+	return s.setInt("webPort", port)
+}
+
 func (s *SettingService) GetCertFile() (string, error) {
 	return s.getString("webCertFile")
 }

+ 31 - 0
web/service/user.go

@@ -1,6 +1,7 @@
 package service
 
 import (
+	"errors"
 	"gorm.io/gorm"
 	"x-ui/database"
 	"x-ui/database/model"
@@ -26,3 +27,33 @@ func (s *UserService) CheckUser(username string, password string) *model.User {
 	}
 	return user
 }
+
+func (s *UserService) UpdateUser(id int, username string, password string) error {
+	db := database.GetDB()
+	return db.Model(model.User{}).
+		Where("id = ?", id).
+		Update("username", username).
+		Update("password", password).
+		Error
+}
+
+func (s *UserService) UpdateFirstUser(username string, password string) error {
+	if username == "" {
+		return errors.New("username can not be empty")
+	} else if password == "" {
+		return errors.New("password can not be empty")
+	}
+	db := database.GetDB()
+	user := &model.User{}
+	err := db.Model(model.User{}).First(user).Error
+	if database.IsNotFound(err) {
+		user.Username = username
+		user.Password = password
+		return db.Model(model.User{}).Create(user).Error
+	} else if err != nil {
+		return err
+	}
+	user.Username = username
+	user.Password = password
+	return db.Save(user).Error
+}

+ 18 - 3
web/service/xray.go

@@ -3,6 +3,7 @@ package service
 import (
 	"encoding/json"
 	"errors"
+	"go.uber.org/atomic"
 	"sync"
 	"x-ui/xray"
 )
@@ -14,6 +15,8 @@ var result string
 type XrayService struct {
 	inboundService InboundService
 	settingService SettingService
+
+	isNeedXrayRestart atomic.Bool
 }
 
 func (s *XrayService) IsXrayRunning() bool {
@@ -84,15 +87,19 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, error) {
 func (s *XrayService) RestartXray() error {
 	lock.Lock()
 	defer lock.Unlock()
-	if p != nil {
-		p.Stop()
-	}
 
 	xrayConfig, err := s.GetXrayConfig()
 	if err != nil {
 		return err
 	}
 
+	if p != nil {
+		if p.GetConfig().Equals(xrayConfig) {
+			return nil
+		}
+		p.Stop()
+	}
+
 	p = xray.NewProcess(xrayConfig)
 	result = ""
 	return p.Start()
@@ -106,3 +113,11 @@ func (s *XrayService) StopXray() error {
 	}
 	return errors.New("xray is not running")
 }
+
+func (s *XrayService) SetIsNeedRestart(needRestart bool) {
+	s.isNeedXrayRestart.Store(needRestart)
+}
+
+func (s *XrayService) IsNeedRestart() bool {
+	return s.isNeedXrayRestart.Load()
+}

+ 9 - 9
web/translation/translate.zh_Hans.toml

@@ -1,12 +1,12 @@
 "username" = "用户名"
 "password" = "密码"
 "login" = "登录"
-"confirm" = "confirm"
-"cancel" = "cancel"
-"close" = "close"
-"copy" = "copy"
-"copied" = "copied"
-"download" = "download"
-"remark" = "remark"
-"enable" = "enable"
-"protocol" = "protocol"
+"confirm" = "确定"
+"cancel" = "取消"
+"close" = "关闭"
+"copy" = "复制"
+"copied" = "已复制"
+"download" = "下载"
+"remark" = "备注"
+"enable" = "启用"
+"protocol" = "协议"

+ 9 - 9
web/translation/translate.zh_Hant.toml

@@ -1,12 +1,12 @@
 "username" = "用戶名"
 "password" = "密碼"
 "login" = "登錄"
-"confirm" = "confirm"
-"cancel" = "cancel"
-"close" = "close"
-"copy" = "copy"
-"copied" = "copied"
-"download" = "download"
-"remark" = "remark"
-"enable" = "enable"
-"protocol" = "protocol"
+"confirm" = "確定"
+"cancel" = "取消"
+"close" = "關閉"
+"copy" = "複製"
+"copied" = "已複製"
+"download" = "下載"
+"remark" = "備註"
+"enable" = "啟用"
+"protocol" = "協議"

+ 3 - 5
web/web.go

@@ -253,10 +253,7 @@ func (s *Server) startTask() {
 		if checkTime < 2 {
 			return
 		}
-		err := s.xrayService.RestartXray()
-		if err != nil {
-			logger.Warning("start xray failed:", err)
-		}
+		s.xrayService.SetIsNeedRestart(true)
 	})
 	go func() {
 		time.Sleep(time.Second * 5)
@@ -316,7 +313,8 @@ func (s *Server) Start() (err error) {
 	listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
 	var listener net.Listener
 	if certFile != "" || keyFile != "" {
-		cert, err := tls.LoadX509KeyPair(certFile, keyFile)
+		var cert tls.Certificate
+		cert, err = tls.LoadX509KeyPair(certFile, keyFile)
 		if err != nil {
 			return err
 		}

+ 493 - 0
x-ui.sh

@@ -0,0 +1,493 @@
+#!/bin/bash
+
+red='\033[0;31m'
+green='\033[0;32m'
+yellow='\033[0;33m'
+plain='\033[0m'
+
+# check root
+[[ $EUID -ne 0 ]] && echo -e "${red}错误: ${plain} 必须使用root用户运行此脚本!\n" && exit 1
+
+# check os
+if [[ -f /etc/redhat-release ]]; then
+    release="centos"
+elif cat /etc/issue | grep -Eqi "debian"; then
+    release="debian"
+elif cat /etc/issue | grep -Eqi "ubuntu"; then
+    release="ubuntu"
+elif cat /etc/issue | grep -Eqi "centos|red hat|redhat"; then
+    release="centos"
+elif cat /proc/version | grep -Eqi "debian"; then
+    release="debian"
+elif cat /proc/version | grep -Eqi "ubuntu"; then
+    release="ubuntu"
+elif cat /proc/version | grep -Eqi "centos|red hat|redhat"; then
+    release="centos"
+else
+    echo -e "${red}未检测到系统版本,请联系脚本作者!${plain}\n" && exit 1
+fi
+
+os_version=""
+
+# os version
+if [[ -f /etc/os-release ]]; then
+    os_version=$(awk -F'[= ."]' '/VERSION_ID/{print $3}' /etc/os-release)
+fi
+if [[ -z "$os_version" && -f /etc/lsb-release ]]; then
+    os_version=$(awk -F'[= ."]+' '/DISTRIB_RELEASE/{print $2}' /etc/lsb-release)
+fi
+
+if [[ x"${release}" == x"centos" ]]; then
+    if [[ ${os_version} -le 6 ]]; then
+        echo -e "${red}请使用 CentOS 7 或更高版本的系统!${plain}\n" && exit 1
+    fi
+elif [[ x"${release}" == x"ubuntu" ]]; then
+    if [[ ${os_version} -lt 16 ]]; then
+        echo -e "${red}请使用 Ubuntu 16 或更高版本的系统!${plain}\n" && exit 1
+    fi
+elif [[ x"${release}" == x"debian" ]]; then
+    if [[ ${os_version} -lt 8 ]]; then
+        echo -e "${red}请使用 Debian 8 或更高版本的系统!${plain}\n" && exit 1
+    fi
+fi
+
+confirm() {
+    if [[ $# > 1 ]]; then
+        echo && read -p "$1 [默认$2]: " temp
+        if [[ x"${temp}" == x"" ]]; then
+            temp=$2
+        fi
+    else
+        read -p "$1 [y/n]: " temp
+    fi
+    if [[ x"${temp}" == x"y" || x"${temp}" == x"Y" ]]; then
+        return 0
+    else
+        return 1
+    fi
+}
+
+confirm_restart() {
+    confirm "是否重启面板,重启面板也会重启 xray" "y"
+    if [[ $? == 0 ]]; then
+        restart
+    else
+        show_menu
+    fi
+}
+
+before_show_menu() {
+    echo && echo -n -e "${yellow}按回车返回主菜单: ${plain}" && read temp
+    show_menu
+}
+
+install() {
+    bash <(curl -Ls https://blog.sprov.xyz/x-ui.sh)
+    if [[ $? == 0 ]]; then
+        if [[ $# == 0 ]]; then
+            start
+        else
+            start 0
+        fi
+    fi
+}
+
+update() {
+    confirm "本功能会强制重装当前最新版,数据不会丢失,是否继续?" "n"
+    if [[ $? != 0 ]]; then
+        echo -e "${red}已取消${plain}"
+        if [[ $# == 0 ]]; then
+            before_show_menu
+        fi
+        return 0
+    fi
+    bash <(curl -Ls https://blog.sprov.xyz/x-ui.sh)
+    if [[ $? == 0 ]]; then
+        echo -e "${green}更新完成,已自动重启面板${plain}"
+        exit 0
+    fi
+}
+
+uninstall() {
+    confirm "确定要卸载面板吗,xray 也会卸载?" "n"
+    if [[ $? != 0 ]]; then
+        if [[ $# == 0 ]]; then
+            show_menu
+        fi
+        return 0
+    fi
+    systemctl stop x-ui
+    systemctl disable x-ui
+    rm /etc/systemd/system/x-ui.service -f
+    systemctl daemon-reload
+    systemctl reset-failed
+    rm /etc/x-ui/ -rf
+    rm /usr/local/x-ui/ -rf
+
+    echo ""
+    echo -e "卸载成功,如果你想删除此脚本,则退出脚本后运行 ${green}rm /usr/bin/x-ui -f${plain} 进行删除"
+    echo ""
+    echo -e "Telegram 群组: ${green}https://t.me/sprov_blog${plain}"
+    echo -e "Github issues: ${green}https://github.com/sprov065/x-ui/issues${plain}"
+    echo -e "博客: ${green}https://blog.sprov.xyz/x-ui${plain}"
+
+    if [[ $# == 0 ]]; then
+        before_show_menu
+    fi
+}
+
+reset_user() {
+    confirm "确定要将用户名和密码重置为 admin 吗" "n"
+    if [[ $? != 0 ]]; then
+        if [[ $# == 0 ]]; then
+            show_menu
+        fi
+        return 0
+    fi
+    /usr/local/x-ui/x-ui setting -username admin -password admin
+    echo -e "用户名和密码已重置为 ${green}admin${plain},现在请重启面板"
+    confirm_restart
+}
+
+reset_config() {
+    confirm "确定要重置所有面板设置吗,账号数据不会丢失,用户名和密码不会改变" "n"
+    if [[ $? != 0 ]]; then
+        if [[ $# == 0 ]]; then
+            show_menu
+        fi
+        return 0
+    fi
+    /usr/local/x-ui/x-ui setting -reset
+    echo -e "所有面板设置已重置为默认值,现在请重启面板,并使用默认的 ${green}54321${plain} 端口访问面板"
+    confirm_restart
+}
+
+set_port() {
+    echo && echo -n -e "输入端口号[1-65535]: " && read port
+    if [[ -z "${port}" ]]; then
+        echo -e "${yellow}已取消${plain}"
+        before_show_menu
+    else
+        /usr/local/x-ui/x-ui setting -port ${port}
+        echo -e "设置端口完毕,现在请重启面板,并使用新设置的端口 ${green}${port}${plain} 访问面板"
+        confirm_restart
+    fi
+}
+
+start() {
+    check_status
+    if [[ $? == 0 ]]; then
+        echo ""
+        echo -e "${green}面板已运行,无需再次启动,如需重启请选择重启${plain}"
+    else
+        systemctl start x-ui
+        sleep 2
+        check_status
+        if [[ $? == 0 ]]; then
+            echo -e "${green}x-ui 启动成功${plain}"
+        else
+            echo -e "${red}面板启动失败,可能是因为启动时间超过了两秒,请稍后查看日志信息${plain}"
+        fi
+    fi
+
+    if [[ $# == 0 ]]; then
+        before_show_menu
+    fi
+}
+
+stop() {
+    check_status
+    if [[ $? == 1 ]]; then
+        echo ""
+        echo -e "${green}面板已停止,无需再次停止${plain}"
+    else
+        systemctl stop x-ui
+        sleep 2
+        check_status
+        if [[ $? == 1 ]]; then
+            echo -e "${green}x-ui 与 xray 停止成功${plain}"
+        else
+            echo -e "${red}面板停止失败,可能是因为停止时间超过了两秒,请稍后查看日志信息${plain}"
+        fi
+    fi
+
+    if [[ $# == 0 ]]; then
+        before_show_menu
+    fi
+}
+
+restart() {
+    systemctl restart x-ui
+    sleep 2
+    check_status
+    if [[ $? == 0 ]]; then
+        echo -e "${green}x-ui 与 xray 重启成功${plain}"
+    else
+        echo -e "${red}面板重启失败,可能是因为启动时间超过了两秒,请稍后查看日志信息${plain}"
+    fi
+    if [[ $# == 0 ]]; then
+        before_show_menu
+    fi
+}
+
+status() {
+    systemctl status x-ui -l
+    if [[ $# == 0 ]]; then
+        before_show_menu
+    fi
+}
+
+enable() {
+    systemctl enable x-ui
+    if [[ $? == 0 ]]; then
+        echo -e "${green}x-ui 设置开机自启成功${plain}"
+    else
+        echo -e "${red}x-ui 设置开机自启失败${plain}"
+    fi
+
+    if [[ $# == 0 ]]; then
+        before_show_menu
+    fi
+}
+
+disable() {
+    systemctl disable x-ui
+    if [[ $? == 0 ]]; then
+        echo -e "${green}x-ui 取消开机自启成功${plain}"
+    else
+        echo -e "${red}x-ui 取消开机自启失败${plain}"
+    fi
+
+    if [[ $# == 0 ]]; then
+        before_show_menu
+    fi
+}
+
+show_log() {
+    journalctl -u x-ui.service -e --no-pager -f
+    if [[ $# == 0 ]]; then
+        before_show_menu
+    fi
+}
+
+install_bbr() {
+    bash <(curl -L -s https://raw.githubusercontent.com/sprov065/blog/master/bbr.sh)
+    echo ""
+    before_show_menu
+}
+
+update_shell() {
+    wget -O /usr/bin/x-ui -N --no-check-certificate https://github.com/sprov065/x-ui/raw/master/x-ui.sh
+    if [[ $? != 0 ]]; then
+        echo ""
+        echo -e "${red}下载脚本失败,请检查本机能否连接 Github${plain}"
+        before_show_menu
+    else
+        chmod +x /usr/bin/x-ui
+        echo -e "${green}升级脚本成功,请重新运行脚本${plain}" && exit 0
+    fi
+}
+
+# 0: running, 1: not running, 2: not installed
+check_status() {
+    if [[ ! -f /etc/systemd/system/x-ui.service ]]; then
+        return 2
+    fi
+    temp=$(systemctl status x-ui | grep Active | awk '{print $3}' | cut -d "(" -f2 | cut -d ")" -f1)
+    if [[ x"${temp}" == x"running" ]]; then
+        return 0
+    else
+        return 1
+    fi
+}
+
+check_enabled() {
+    temp=$(systemctl is-enabled x-ui)
+    if [[ x"${temp}" == x"enabled" ]]; then
+        return 0
+    else
+        return 1;
+    fi
+}
+
+check_uninstall() {
+    check_status
+    if [[ $? != 2 ]]; then
+        echo ""
+        echo -e "${red}面板已安装,请不要重复安装${plain}"
+        if [[ $# == 0 ]]; then
+            before_show_menu
+        fi
+        return 1
+    else
+        return 0
+    fi
+}
+
+check_install() {
+    check_status
+    if [[ $? == 2 ]]; then
+        echo ""
+        echo -e "${red}请先安装面板${plain}"
+        if [[ $# == 0 ]]; then
+            before_show_menu
+        fi
+        return 1
+    else
+        return 0
+    fi
+}
+
+show_status() {
+    check_status
+    case $? in
+        0)
+            echo -e "面板状态: ${green}已运行${plain}"
+            show_enable_status
+            ;;
+        1)
+            echo -e "面板状态: ${yellow}未运行${plain}"
+            show_enable_status
+            ;;
+        2)
+            echo -e "面板状态: ${red}未安装${plain}"
+    esac
+    show_xray_status
+}
+
+show_enable_status() {
+    check_enabled
+    if [[ $? == 0 ]]; then
+        echo -e "是否开机自启: ${green}是${plain}"
+    else
+        echo -e "是否开机自启: ${red}否${plain}"
+    fi
+}
+
+check_xray_status() {
+    count=$(ps -ef | grep "xray-linux" | grep -v "grep" | wc -l)
+    if [[ count -ne 0 ]]; then
+        return 0
+    else
+        return 1
+    fi
+}
+
+show_xray_status() {
+    check_xray_status
+    if [[ $? == 0 ]]; then
+        echo -e "xray 状态: ${green}运行${plain}"
+    else
+        echo -e "xray 状态: ${red}未运行${plain}"
+    fi
+}
+
+show_usage() {
+    echo "x-ui 管理脚本使用方法: "
+    echo "------------------------------------------"
+    echo "x-ui              - 显示管理菜单 (功能更多)"
+    echo "x-ui start        - 启动 x-ui 面板"
+    echo "x-ui stop         - 停止 x-ui 面板"
+    echo "x-ui restart      - 重启 x-ui 面板"
+    echo "x-ui status       - 查看 x-ui 状态"
+    echo "x-ui enable       - 设置 x-ui 开机自启"
+    echo "x-ui disable      - 取消 x-ui 开机自启"
+    echo "x-ui log          - 查看 x-ui 日志"
+    echo "x-ui update       - 更新 x-ui 面板"
+    echo "x-ui install      - 安装 x-ui 面板"
+    echo "x-ui uninstall    - 卸载 x-ui 面板"
+    echo "------------------------------------------"
+}
+
+show_menu() {
+    echo -e "
+  ${green}x-ui 面板管理脚本${plain}
+--- https://blog.sprov.xyz/x-ui ---
+  ${green}0.${plain} 退出脚本
+————————————————
+  ${green}1.${plain} 安装 x-ui
+  ${green}2.${plain} 更新 x-ui
+  ${green}3.${plain} 卸载 x-ui
+————————————————
+  ${green}4.${plain} 重置用户名密码
+  ${green}5.${plain} 重置面板设置
+  ${green}6.${plain} 设置面板端口
+————————————————
+  ${green}7.${plain} 启动 x-ui
+  ${green}8.${plain} 停止 x-ui
+  ${green}9.${plain} 重启 x-ui
+ ${green}10.${plain} 查看 x-ui 状态
+ ${green}11.${plain} 查看 x-ui 日志
+————————————————
+ ${green}12.${plain} 设置 x-ui 开机自启
+ ${green}13.${plain} 取消 x-ui 开机自启
+————————————————
+ ${green}14.${plain} 一键安装 bbr (最新内核)
+ "
+    show_status
+    echo && read -p "请输入选择 [0-14]: " num
+
+    case "${num}" in
+        0) exit 0
+        ;;
+        1) check_uninstall && install
+        ;;
+        2) check_install && update
+        ;;
+        3) check_install && uninstall
+        ;;
+        4) check_install && reset_user
+        ;;
+        5) check_install && reset_config
+        ;;
+        6) check_install && set_port
+        ;;
+        7) check_install && start
+        ;;
+        8) check_install && stop
+        ;;
+        9) check_install && restart
+        ;;
+        10) check_install && status
+        ;;
+        11) check_install && show_log
+        ;;
+        12) check_install && enable
+        ;;
+        13) check_install && disable
+        ;;
+        14) install_bbr
+        ;;
+        *) echo -e "${red}请输入正确的数字 [0-14]${plain}"
+        ;;
+    esac
+}
+
+
+if [[ $# > 0 ]]; then
+    case $1 in
+        "start") check_install 0 && start 0
+        ;;
+        "stop") check_install 0 && stop 0
+        ;;
+        "restart") check_install 0 && restart 0
+        ;;
+        "status") check_install 0 && status 0
+        ;;
+        "enable") check_install 0 && enable 0
+        ;;
+        "disable") check_install 0 && disable 0
+        ;;
+        "log") check_install 0 && show_log 0
+        ;;
+        "update") check_install 0 && update 0
+        ;;
+        "install") check_uninstall 0 && install 0
+        ;;
+        "uninstall") check_install 0 && uninstall 0
+        ;;
+        *) show_usage
+    esac
+else
+    show_menu
+fi

+ 52 - 14
xray/config.go

@@ -1,24 +1,62 @@
 package xray
 
 import (
-	"encoding/json"
+	"bytes"
 	"x-ui/util/json_util"
 )
 
 type Config struct {
-	LogConfig       json.RawMessage `json:"log"`
-	RouterConfig    json.RawMessage `json:"routing"`
-	DNSConfig       json.RawMessage `json:"dns"`
-	InboundConfigs  []InboundConfig `json:"inbounds"`
-	OutboundConfigs json.RawMessage `json:"outbounds"`
-	Transport       json.RawMessage `json:"transport"`
-	Policy          json.RawMessage `json:"policy"`
-	API             json.RawMessage `json:"api"`
-	Stats           json.RawMessage `json:"stats"`
-	Reverse         json.RawMessage `json:"reverse"`
-	FakeDNS         json.RawMessage `json:"fakeDns"`
+	LogConfig       json_util.RawMessage `json:"log"`
+	RouterConfig    json_util.RawMessage `json:"routing"`
+	DNSConfig       json_util.RawMessage `json:"dns"`
+	InboundConfigs  []InboundConfig      `json:"inbounds"`
+	OutboundConfigs json_util.RawMessage `json:"outbounds"`
+	Transport       json_util.RawMessage `json:"transport"`
+	Policy          json_util.RawMessage `json:"policy"`
+	API             json_util.RawMessage `json:"api"`
+	Stats           json_util.RawMessage `json:"stats"`
+	Reverse         json_util.RawMessage `json:"reverse"`
+	FakeDNS         json_util.RawMessage `json:"fakeDns"`
 }
 
-func (c *Config) MarshalJSON() ([]byte, error) {
-	return json_util.MarshalJSON(c)
+func (c *Config) Equals(other *Config) bool {
+	if len(c.InboundConfigs) != len(other.InboundConfigs) {
+		return false
+	}
+	for i, inbound := range c.InboundConfigs {
+		if !inbound.Equals(&other.InboundConfigs[i]) {
+			return false
+		}
+	}
+	if !bytes.Equal(c.LogConfig, other.LogConfig) {
+		return false
+	}
+	if !bytes.Equal(c.RouterConfig, other.RouterConfig) {
+		return false
+	}
+	if !bytes.Equal(c.DNSConfig, other.DNSConfig) {
+		return false
+	}
+	if !bytes.Equal(c.OutboundConfigs, other.OutboundConfigs) {
+		return false
+	}
+	if !bytes.Equal(c.Transport, other.Transport) {
+		return false
+	}
+	if !bytes.Equal(c.Policy, other.Policy) {
+		return false
+	}
+	if !bytes.Equal(c.API, other.API) {
+		return false
+	}
+	if !bytes.Equal(c.Stats, other.Stats) {
+		return false
+	}
+	if !bytes.Equal(c.Reverse, other.Reverse) {
+		return false
+	}
+	if !bytes.Equal(c.FakeDNS, other.FakeDNS) {
+		return false
+	}
+	return true
 }

+ 31 - 10
xray/inbound.go

@@ -1,20 +1,41 @@
 package xray
 
 import (
-	"encoding/json"
+	"bytes"
 	"x-ui/util/json_util"
 )
 
 type InboundConfig struct {
-	Listen         json.RawMessage `json:"listen"` // listen 不能为空字符串
-	Port           int             `json:"port"`
-	Protocol       string          `json:"protocol"`
-	Settings       json.RawMessage `json:"settings"`
-	StreamSettings json.RawMessage `json:"streamSettings"`
-	Tag            string          `json:"tag"`
-	Sniffing       json.RawMessage `json:"sniffing"`
+	Listen         json_util.RawMessage `json:"listen"` // listen 不能为空字符串
+	Port           int                  `json:"port"`
+	Protocol       string               `json:"protocol"`
+	Settings       json_util.RawMessage `json:"settings"`
+	StreamSettings json_util.RawMessage `json:"streamSettings"`
+	Tag            string               `json:"tag"`
+	Sniffing       json_util.RawMessage `json:"sniffing"`
 }
 
-func (i *InboundConfig) MarshalJSON() ([]byte, error) {
-	return json_util.MarshalJSON(i)
+func (c *InboundConfig) Equals(other *InboundConfig) bool {
+	if !bytes.Equal(c.Listen, other.Listen) {
+		return false
+	}
+	if c.Port != other.Port {
+		return false
+	}
+	if c.Protocol != other.Protocol {
+		return false
+	}
+	if !bytes.Equal(c.Settings, other.Settings) {
+		return false
+	}
+	if !bytes.Equal(c.StreamSettings, other.StreamSettings) {
+		return false
+	}
+	if c.Tag != other.Tag {
+		return false
+	}
+	if !bytes.Equal(c.Sniffing, other.Sniffing) {
+		return false
+	}
+	return true
 }

+ 25 - 12
xray/process.go

@@ -62,16 +62,16 @@ type process struct {
 	version string
 	apiPort int
 
-	xrayConfig *Config
-	lines      *queue.Queue
-	exitErr    error
+	config  *Config
+	lines   *queue.Queue
+	exitErr error
 }
 
-func newProcess(xrayConfig *Config) *process {
+func newProcess(config *Config) *process {
 	return &process{
-		version:    "Unknown",
-		xrayConfig: xrayConfig,
-		lines:      queue.New(100),
+		version: "Unknown",
+		config:  config,
+		lines:   queue.New(100),
 	}
 }
 
@@ -90,6 +90,9 @@ func (p *process) GetErr() error {
 }
 
 func (p *process) GetResult() string {
+	if p.lines.Empty() && p.exitErr != nil {
+		return p.exitErr.Error()
+	}
 	items, _ := p.lines.TakeUntil(func(item interface{}) bool {
 		return true
 	})
@@ -108,8 +111,12 @@ func (p *Process) GetAPIPort() int {
 	return p.apiPort
 }
 
+func (p *Process) GetConfig() *Config {
+	return p.config
+}
+
 func (p *process) refreshAPIPort() {
-	for _, inbound := range p.xrayConfig.InboundConfigs {
+	for _, inbound := range p.config.InboundConfigs {
 		if inbound.Tag == "api" {
 			p.apiPort = inbound.Port
 			break
@@ -132,19 +139,25 @@ func (p *process) refreshVersion() {
 	}
 }
 
-func (p *process) Start() error {
+func (p *process) Start() (err error) {
 	if p.IsRunning() {
 		return errors.New("xray is already running")
 	}
 
-	data, err := json.MarshalIndent(p.xrayConfig, "", "  ")
+	defer func() {
+		if err != nil {
+			p.exitErr = err
+		}
+	}()
+
+	data, err := json.MarshalIndent(p.config, "", "  ")
 	if err != nil {
-		return err
+		return common.NewErrorf("生成 xray 配置文件失败: %v", err)
 	}
 	configPath := GetConfigPath()
 	err = os.WriteFile(configPath, data, fs.ModePerm)
 	if err != nil {
-		return err
+		return common.NewErrorf("写入配置文件失败: %v", err)
 	}
 
 	cmd := exec.Command(GetBinaryPath(), "-c", configPath)