Pārlūkot izejas kodu

完成大部分基础功能

sprov 4 gadi atpakaļ
vecāks
revīzija
4bae13dcc6

+ 21 - 0
util/reflect_util/reflect.go

@@ -0,0 +1,21 @@
+package reflect_util
+
+import "reflect"
+
+func GetFields(t reflect.Type) []reflect.StructField {
+	num := t.NumField()
+	fields := make([]reflect.StructField, 0, num)
+	for i := 0; i < num; i++ {
+		fields = append(fields, t.Field(i))
+	}
+	return fields
+}
+
+func GetFieldValues(v reflect.Value) []reflect.Value {
+	num := v.NumField()
+	fields := make([]reflect.Value, 0, num)
+	for i := 0; i < num; i++ {
+		fields = append(fields, v.Field(i))
+	}
+	return fields
+}

+ 4 - 0
web/assets/css/custom.css

@@ -2,6 +2,10 @@
     height: 100%;
 }
 
+.ant-space {
+    display: block;
+}
+
 .ant-layout-sider-zero-width-trigger {
     display: none;
 }

+ 23 - 0
web/assets/js/model/models.js

@@ -88,4 +88,27 @@ class DBInbound {
         const inbound = this.toInbound();
         return inbound.genLink(address, this.remark);
     }
+}
+
+class AllSetting {
+    webListen = "";
+    webPort = 65432;
+    webCertFile = "";
+    webKeyFile = "";
+    webBasePath = "/";
+
+    xrayTemplateConfig = "";
+
+    timeLocation = "Asia/Shanghai";
+
+    constructor(data) {
+        if (data == null) {
+            return
+        }
+        ObjectUtil.cloneProps(this, data);
+    }
+
+    equals(other) {
+        return ObjectUtil.equals(this, other);
+    }
 }

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

@@ -270,4 +270,18 @@ class ObjectUtil {
         return obj;
     }
 
+    static equals(a, b) {
+        for (const key in a) {
+            if (!a.hasOwnProperty(key)) {
+                continue;
+            }
+            if (!b.hasOwnProperty(key)) {
+                return false;
+            } else if (a[key] !== b[key]) {
+                return false;
+            }
+        }
+        return true
+    }
+
 }

+ 24 - 0
web/controller/xui.go

@@ -7,6 +7,7 @@ import (
 	"strconv"
 	"x-ui/database/model"
 	"x-ui/logger"
+	"x-ui/web/entity"
 	"x-ui/web/global"
 	"x-ui/web/service"
 	"x-ui/web/session"
@@ -17,6 +18,7 @@ type XUIController struct {
 
 	inboundService service.InboundService
 	xrayService    service.XrayService
+	settingService service.SettingService
 
 	isNeedXrayRestart atomic.Bool
 }
@@ -39,6 +41,8 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
 	g.POST("/inbound/del/:id", a.delInbound)
 	g.POST("/inbound/update/:id", a.updateInbound)
 	g.GET("/setting", a.setting)
+	g.POST("/setting/all", a.getAllSetting)
+	g.POST("/setting/update", a.updateSetting)
 }
 
 func (a *XUIController) startTask() {
@@ -128,3 +132,23 @@ func (a *XUIController) updateInbound(c *gin.Context) {
 		a.isNeedXrayRestart.Store(true)
 	}
 }
+
+func (a *XUIController) getAllSetting(c *gin.Context) {
+	allSetting, err := a.settingService.GetAllSetting()
+	if err != nil {
+		jsonMsg(c, "获取设置", err)
+		return
+	}
+	jsonObj(c, allSetting, nil)
+}
+
+func (a *XUIController) updateSetting(c *gin.Context) {
+	allSetting := &entity.AllSetting{}
+	err := c.ShouldBind(allSetting)
+	if err != nil {
+		jsonMsg(c, "修改设置", err)
+		return
+	}
+	err = a.settingService.UpdateAllSetting(allSetting)
+	jsonMsg(c, "修改设置", err)
+}

+ 62 - 0
web/entity/entity.go

@@ -1,5 +1,15 @@
 package entity
 
+import (
+	"crypto/tls"
+	"encoding/json"
+	"net"
+	"strings"
+	"time"
+	"x-ui/util/common"
+	"x-ui/xray"
+)
+
 type Msg struct {
 	Success bool        `json:"success"`
 	Msg     string      `json:"msg"`
@@ -15,3 +25,55 @@ type Pager struct {
 	Key      string      `json:"key"`
 	List     interface{} `json:"list"`
 }
+
+type AllSetting struct {
+	WebListen   string `json:"webListen" form:"webListen"`
+	WebPort     int    `json:"webPort" form:"webPort"`
+	WebCertFile string `json:"webCertFile" form:"webCertFile"`
+	WebKeyFile  string `json:"webKeyFile" form:"webKeyFile"`
+	WebBasePath string `json:"webBasePath" form:"webBasePath"`
+
+	XrayTemplateConfig string `json:"xrayTemplateConfig" form:"xrayTemplateConfig"`
+
+	TimeLocation string `json:"timeLocation" form:"timeLocation"`
+}
+
+func (s *AllSetting) CheckValid() error {
+	if s.WebListen != "" {
+		ip := net.ParseIP(s.WebListen)
+		if ip == nil {
+			return common.NewError("web listen is not valid ip:", s.WebListen)
+		}
+	}
+
+	if s.WebPort <= 0 || s.WebPort > 65535 {
+		return common.NewError("web port is not a valid port:", s.WebPort)
+	}
+
+	if s.WebCertFile != "" || s.WebKeyFile != "" {
+		_, err := tls.LoadX509KeyPair(s.WebCertFile, s.WebKeyFile)
+		if err != nil {
+			return common.NewErrorf("cert file <%v> or key file <%v> invalid: %v", s.WebCertFile, s.WebKeyFile, err)
+		}
+	}
+
+	if !strings.HasPrefix(s.WebBasePath, "/") {
+		return common.NewErrorf("web base path must start with '/' : <%v>", s.WebBasePath)
+	}
+	if !strings.HasSuffix(s.WebBasePath, "/") {
+		return common.NewErrorf("web base path must end with '/' : <%v>", s.WebBasePath)
+	}
+
+	xrayConfig := &xray.Config{}
+	err := json.Unmarshal([]byte(s.XrayTemplateConfig), xrayConfig)
+	if err != nil {
+		return common.NewError("xray template config invalid:", err)
+	}
+
+	_, err = time.LoadLocation(s.TimeLocation)
+	if err != nil {
+		return common.NewError("time location not exist:", s.TimeLocation)
+	}
+
+	return nil
+}

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

@@ -5,7 +5,7 @@
 </a-menu-item>
 <a-menu-item key="{{ .base_path }}xui/inbounds">
     <a-icon type="user"></a-icon>
-    <span>账号列表</span>
+    <span>入站列表</span>
 </a-menu-item>
 <a-menu-item key="{{ .base_path }}xui/setting">
     <a-icon type="setting"></a-icon>

+ 29 - 0
web/html/xui/component/setting.html

@@ -0,0 +1,29 @@
+{{define "component/settingListItem"}}
+<a-list-item style="padding: 20px">
+    <a-row>
+        <a-col :lg="24" :xl="12">
+            <a-list-item-meta :title="title" :description="desc"/>
+        </a-col>
+        <a-col :lg="24" :xl="12">
+            <template v-if="type === 'text'">
+                <a-input :value="value" @input="$emit('input', $event.target.value)"></a-input>
+            </template>
+            <template v-else-if="type === 'number'">
+                <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>
+            </template>
+        </a-col>
+    </a-row>
+</a-list-item>
+{{end}}
+
+{{define "component/setting"}}
+<script>
+    Vue.component('setting-list-item', {
+        props: ["type", "title", "desc", "value"],
+        template: `{{template "component/settingListItem"}}`,
+    });
+</script>
+{{end}}

+ 5 - 3
web/html/xui/inbounds.html

@@ -2,8 +2,10 @@
 <html lang="en">
 {{template "head" .}}
 <style>
-    .ant-layout-content {
-        margin: 24px 16px;
+    @media (min-width: 769px) {
+        .ant-layout-content {
+            margin: 24px 16px;
+        }
     }
 
     .ant-col-sm-24 {
@@ -133,7 +135,7 @@
         delimiters: ['[[', ']]'],
         el: '#app',
         data: {
-            ip: location.hostname,
+            siderDrawer,
             spinning: false,
             dbInbounds: [],
             searchKey: '',

+ 5 - 2
web/html/xui/index.html

@@ -2,8 +2,10 @@
 <html lang="en">
 {{template "head" .}}
 <style>
-    .ant-layout-content {
-        margin: 24px 16px;
+    @media (min-width: 769px) {
+        .ant-layout-content {
+            margin: 24px 16px;
+        }
     }
 
     .ant-col-sm-24 {
@@ -273,6 +275,7 @@
         delimiters: ['[[', ']]'],
         el: '#app',
         data: {
+            siderDrawer,
             status: new Status(),
             versionModal,
             spinning: false,

+ 125 - 0
web/html/xui/setting.html

@@ -0,0 +1,125 @@
+<!DOCTYPE html>
+<html lang="en">
+{{template "head" .}}
+<style>
+    @media (min-width: 769px) {
+        .ant-layout-content {
+            margin: 24px 16px;
+        }
+    }
+
+    .ant-col-sm-24 {
+        margin-top: 10px;
+    }
+
+    .ant-tabs-bar {
+        margin: 0;
+    }
+
+    .ant-list-item {
+        display: block;
+    }
+
+    .ant-tabs-top-bar {
+        background: white;
+    }
+</style>
+<body>
+<a-layout id="app" v-cloak>
+    {{ template "commonSider" . }}
+    <a-layout id="content-layout">
+        <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-tabs default-active-key="1">
+                        <a-tab-pane key="1" tab="面板配置">
+                            <a-list item-layout="horizontal" style="background: white">
+                                <setting-list-item type="text" title="面板监听 IP" desc="默认留空监听所有 IP" v-model="allSetting.webListen"></setting-list-item>
+                                <setting-list-item type="number" title="面板监听端口" v-model.number="allSetting.webPort"></setting-list-item>
+                                <setting-list-item type="text" title="面板证书公钥文件路径" desc="填写一个 '/' 开头的绝对路径" v-model="allSetting.webCertFile"></setting-list-item>
+                                <setting-list-item type="text" title="面板证书密钥文件路径" desc="填写一个 '/' 开头的绝对路径" v-model="allSetting.webKeyFile"></setting-list-item>
+                                <setting-list-item type="text" title="面板 url 根路径" desc="必须以 '/' 开头,以 '/' 结尾" v-model="allSetting.webBasePath"></setting-list-item>
+                            </a-list>
+                        </a-tab-pane>
+                        <a-tab-pane key="2" tab="用户设置">
+                            <a-form style="background: white; padding: 20px">
+                                <a-form-item label="原用户名">
+                                    <a-input></a-input>
+                                </a-form-item>
+                                <a-form-item label="原密码">
+                                    <a-input></a-input>
+                                </a-form-item>
+                                <a-form-item label="新用户名">
+                                    <a-input></a-input>
+                                </a-form-item>
+                                <a-form-item label="新密码">
+                                    <a-input></a-input>
+                                </a-form-item>
+                            </a-form>
+                        </a-tab-pane>
+                        <a-tab-pane key="3" tab="xray 相关设置">
+                            <a-list item-layout="horizontal" style="background: white">
+                                <setting-list-item type="textarea" title="xray 配置模版" desc="以该模版为基础生成最终的 xray 配置文件" v-model="allSetting.xrayTemplateConfig"></setting-list-item>
+                            </a-list>
+                        </a-tab-pane>
+                        <a-tab-pane key="4" tab="其他设置">
+                            <a-list item-layout="horizontal" style="background: white">
+                                <setting-list-item type="text" title="时区" desc="定时任务按照该时区的时间运行" v-model="allSetting.timeLocation"></setting-list-item>
+                            </a-list>
+                        </a-tab-pane>
+                    </a-tabs>
+                </a-space>
+            </a-spin>
+        </a-layout-content>
+    </a-layout>
+</a-layout>
+{{template "js" .}}
+{{template "component/setting"}}
+<script>
+
+    const app = new Vue({
+        delimiters: ['[[', ']]'],
+        el: '#app',
+        data: {
+            siderDrawer,
+            spinning: false,
+            oldAllSetting: new AllSetting(),
+            allSetting: new AllSetting(),
+            saveBtnDisable: true,
+        },
+        methods: {
+            loading(spinning = true) {
+                this.spinning = spinning;
+            },
+            async getAllSetting() {
+                this.loading(true);
+                const msg = await HttpUtil.post("/xui/setting/all");
+                this.loading(false);
+                if (msg.success) {
+                    this.oldAllSetting = new AllSetting(msg.obj);
+                    this.allSetting = new AllSetting(msg.obj);
+                    this.saveBtnDisable = true;
+                }
+            },
+            async updateAllSetting() {
+                this.loading(true);
+                const msg = await HttpUtil.post("/xui/setting/update", this.allSetting);
+                this.loading(false);
+                if (msg.success) {
+                    await this.getAllSetting();
+                }
+            }
+        },
+        async mounted() {
+            await this.getAllSetting();
+            while (true) {
+                await PromiseUtil.sleep(1000);
+                this.saveBtnDisable = this.oldAllSetting.equals(this.allSetting);
+            }
+        },
+    });
+
+</script>
+</body>
+</html>

+ 130 - 16
web/service/setting.go

@@ -2,22 +2,112 @@ package service
 
 import (
 	_ "embed"
+	"errors"
+	"fmt"
+	"reflect"
 	"strconv"
 	"strings"
 	"time"
 	"x-ui/database"
 	"x-ui/database/model"
 	"x-ui/logger"
+	"x-ui/util/common"
 	"x-ui/util/random"
+	"x-ui/util/reflect_util"
+	"x-ui/web/entity"
 )
 
 //go:embed config.json
 var xrayTemplateConfig string
 
+var defaultValueMap = map[string]string{
+	"xrayTemplateConfig": xrayTemplateConfig,
+	"webListen":          "",
+	"webPort":            "65432",
+	"webCertFile":        "",
+	"webKeyFile":         "",
+	"secret":             random.Seq(32),
+	"webBasePath":        "/",
+	"timeLocation":       "Asia/Shanghai",
+}
+
 type SettingService struct {
 }
 
-func (s *SettingService) ClearSetting() error {
+func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
+	db := database.GetDB()
+	settings := make([]*model.Setting, 0)
+	err := db.Model(model.Setting{}).Find(&settings).Error
+	if err != nil {
+		return nil, err
+	}
+	allSetting := &entity.AllSetting{}
+	t := reflect.TypeOf(allSetting).Elem()
+	v := reflect.ValueOf(allSetting).Elem()
+	fields := reflect_util.GetFields(t)
+
+	setSetting := func(key, value string) (err error) {
+		defer func() {
+			panicErr := recover()
+			if panicErr != nil {
+				err = errors.New(fmt.Sprint(panicErr))
+			}
+		}()
+
+		var found bool
+		var field reflect.StructField
+		for _, f := range fields {
+			if f.Tag.Get("json") == key {
+				field = f
+				found = true
+				break
+			}
+		}
+
+		if !found {
+			// 有些设置自动生成,不需要返回到前端给用户修改
+			return nil
+		}
+
+		fieldV := v.FieldByName(field.Name)
+		switch t := fieldV.Interface().(type) {
+		case int:
+			n, err := strconv.ParseInt(value, 10, 32)
+			if err != nil {
+				return err
+			}
+			fieldV.SetInt(n)
+		case string:
+			fieldV.SetString(value)
+		default:
+			return common.NewErrorf("unknown field %v type %v", key, t)
+		}
+		return
+	}
+
+	keyMap := map[string]bool{}
+	for _, setting := range settings {
+		err := setSetting(setting.Key, setting.Value)
+		if err != nil {
+			return nil, err
+		}
+		keyMap[setting.Key] = true
+	}
+
+	for key, value := range defaultValueMap {
+		if keyMap[key] {
+			continue
+		}
+		err := setSetting(key, value)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return allSetting, nil
+}
+
+func (s *SettingService) ResetSettings() error {
 	db := database.GetDB()
 	return db.Delete(model.Setting{}).Error
 }
@@ -48,18 +138,22 @@ func (s *SettingService) saveSetting(key string, value string) error {
 	return db.Save(setting).Error
 }
 
-func (s *SettingService) getString(key string, defaultValue string) (string, error) {
+func (s *SettingService) getString(key string) (string, error) {
 	setting, err := s.getSetting(key)
 	if database.IsNotFound(err) {
-		return defaultValue, nil
+		value, ok := defaultValueMap[key]
+		if !ok {
+			return "", common.NewErrorf("key <%v> not in defaultValueMap", key)
+		}
+		return value, nil
 	} else if err != nil {
 		return "", err
 	}
 	return setting.Value, nil
 }
 
-func (s *SettingService) getInt(key string, defaultValue int) (int, error) {
-	str, err := s.getString(key, strconv.Itoa(defaultValue))
+func (s *SettingService) getInt(key string) (int, error) {
+	str, err := s.getString(key)
 	if err != nil {
 		return 0, err
 	}
@@ -67,29 +161,28 @@ func (s *SettingService) getInt(key string, defaultValue int) (int, error) {
 }
 
 func (s *SettingService) GetXrayConfigTemplate() (string, error) {
-	return s.getString("xray_template_config", xrayTemplateConfig)
+	return s.getString("xrayTemplateConfig")
 }
 
 func (s *SettingService) GetListen() (string, error) {
-	return s.getString("web_listen", "")
+	return s.getString("webListen")
 }
 
 func (s *SettingService) GetPort() (int, error) {
-	return s.getInt("web_port", 65432)
+	return s.getInt("webPort")
 }
 
 func (s *SettingService) GetCertFile() (string, error) {
-	return s.getString("web_cert_file", "")
+	return s.getString("webCertFile")
 }
 
 func (s *SettingService) GetKeyFile() (string, error) {
-	return s.getString("web_key_file", "")
+	return s.getString("webKeyFile")
 }
 
 func (s *SettingService) GetSecret() ([]byte, error) {
-	seq := random.Seq(32)
-	secret, err := s.getString("secret", seq)
-	if secret == seq {
+	secret, err := s.getString("secret")
+	if secret == defaultValueMap["secret"] {
 		err := s.saveSetting("secret", secret)
 		if err != nil {
 			logger.Warning("save secret failed:", err)
@@ -99,7 +192,7 @@ func (s *SettingService) GetSecret() ([]byte, error) {
 }
 
 func (s *SettingService) GetBasePath() (string, error) {
-	basePath, err := s.getString("web_base_path", "/")
+	basePath, err := s.getString("webBasePath")
 	if err != nil {
 		return "", err
 	}
@@ -113,15 +206,36 @@ func (s *SettingService) GetBasePath() (string, error) {
 }
 
 func (s *SettingService) GetTimeLocation() (*time.Location, error) {
-	defaultLocation := "Asia/Shanghai"
-	l, err := s.getString("time_location", defaultLocation)
+	l, err := s.getString("timeLocation")
 	if err != nil {
 		return nil, err
 	}
 	location, err := time.LoadLocation(l)
 	if err != nil {
+		defaultLocation := defaultValueMap["timeLocation"]
 		logger.Errorf("location <%v> not exist, using default location: %v", l, defaultLocation)
 		return time.LoadLocation(defaultLocation)
 	}
 	return location, nil
 }
+
+func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
+	if err := allSetting.CheckValid(); err != nil {
+		return err
+	}
+
+	v := reflect.ValueOf(allSetting).Elem()
+	t := reflect.TypeOf(allSetting).Elem()
+	fields := reflect_util.GetFields(t)
+	errs := make([]error, 0)
+	for _, field := range fields {
+		key := field.Tag.Get("json")
+		fieldV := v.FieldByName(field.Name)
+		value := fmt.Sprint(fieldV.Interface())
+		err := s.saveSetting(key, value)
+		if err != nil {
+			errs = append(errs, err)
+		}
+	}
+	return common.Combine(errs...)
+}