Browse Source

minor: 支持简单代理配置

tuonian 3 months ago
parent
commit
6dea04fa67

File diff suppressed because it is too large
+ 1 - 0
frontend/dist/assets/index-BWdm7LBl.js


File diff suppressed because it is too large
+ 0 - 0
frontend/dist/assets/index-CU6uZ8Wu.css


+ 42 - 0
frontend/src/api/proxy.ts

@@ -0,0 +1,42 @@
+import request from "./request.ts";
+import {BaseResp} from "../models/api.ts";
+
+
+// eslint-disable-next-line @typescript-eslint/no-namespace
+export namespace Proxy {
+
+    export type Data = {
+        id: number
+        name: string
+        userId: string
+        enable: string
+        rel: string
+        patterns?: string
+        ws?: string
+        target?: string
+        remark?: string
+    }
+
+    export type Query = {
+        ref?: string
+        enable?: boolean
+    }
+
+}
+
+/**
+ * LDAP相关的API
+ */
+export const proxyApis = {
+
+    getList: (query: Proxy.Query) => {
+        return request.post<BaseResp<Proxy.Data[]>>('/proxy/list', query)
+    },
+    save: (data: Partial<Proxy.Data>) => {
+        return request.post<BaseResp<Proxy.Data>>('/proxy/save', data)
+    },
+    remove: (id: Proxy.Data) => {
+        return request.post<BaseResp<void>>(`/proxy/remove`, id)
+    },
+
+}

+ 4 - 0
frontend/src/api/settings.ts

@@ -20,6 +20,10 @@ export namespace Settings {
         id: string
         title: string
         path: string
+        /**
+         * 其它属性
+         */
+        props?: any
         navLink?: string
         target: 'APP' | 'TARGET' | 'TAB'
         type: 'MENU' | 'NAV' | 'LINK' | 'FOLDER'

+ 7 - 5
frontend/src/components/curd/index.tsx

@@ -131,8 +131,9 @@ export function CurdPage<T extends ICurdData>(
                 if (col.type == 'switch' && !col.render){
                     col.render = (value: boolean) => <Switch checked={value} disabled/>
                 }
-                if (col.type == 'select' && !col.render){
-                    col.render = (value: boolean) => <AutoText value={value} column={{...col}}/>
+                // @ts-ignore
+                if (col.type == 'select' && !col.render && column.mode!='tags'){
+                    col.render = (value: any) => <AutoText value={value} column={{...col}}/>
                 }
                 return col;
             }) as ColumnsType<T>
@@ -204,13 +205,14 @@ export function CurdPage<T extends ICurdData>(
             <Button loading={loading} onClick={() => setQuery({...query})} icon={<SyncOutlined/>}/>
         </div>
         <div className="curd-body">
-            <Table columns={tableColumns as any} dataSource={list as any}
-                   pagination={{
+            <Table columns={tableColumns as any}
+                   dataSource={list as any}
+                   pagination={ config?.pagination ? {
                        total: total,
                        pageSize: query.pageSize,
                        current: query.current,
                        onChange: onPageChange,
-                   }}
+                   }: { pageSize: query.pageSize }}
                    components={{
                        body: {
                            cell: Cell,

+ 2 - 0
frontend/src/components/curd/types.ts

@@ -21,6 +21,8 @@ export type ICurdConfig<T> = {
     hideAdd?: boolean
     operationWidth?: number
     bordered?: boolean
+    // 分页
+    pagination?: boolean
 }
 
 

+ 3 - 0
frontend/src/components/fullscreen/index.less

@@ -1,4 +1,7 @@
 .full-screen-exit{
   position: fixed;
   transition: all .3s;
+  .ant-btn{
+    background: #a8a8a870;
+  }
 }

+ 1 - 1
frontend/src/components/fullscreen/index.tsx

@@ -14,7 +14,7 @@ export const Fullscreen = ({hideInNonFullscreen}: IProps) => {
     const fullScreen = useAppSelector(state => state.settings.fullScreen)
     const dispatch = useAppDispatch()
     const [pageX,setPageX] = useState(10)
-    const [pageY, setPageY] = useState(10)
+    const [pageY, setPageY] = useState(0)
 
     const setFullScreen = (full: boolean) => {
         dispatch(settingsActions.setFullScreen(full))

+ 17 - 6
frontend/src/pages/layout/MainLayout.tsx

@@ -52,7 +52,7 @@ export const MainLayout = () => {
         })
         return [
             {
-                key: 'home',
+                key: 'HOME',
                 label: '首页',
                 path: 'home',
                 data: {}
@@ -88,14 +88,25 @@ export const MainLayout = () => {
     }
 
     useEffect(() => {
-        if (nav || !matches.length) {
+        if (!matches.length) {
             return
         }
-        const first = matches[0]
-        const active = navList.find(item => item.key == first.pathname);
+        const matchIds = matches.map(item=>item.id)
+        if (nav?.key && matchIds.includes(nav.key)){
+            return;
+        }
+        const match = matches[matches.length - 1]
+        if (nav?.key && match.id == 'APP' && match.pathname.endsWith(nav.key)){
+            return;
+        }
+        console.log('match',matches)
+        console.log('current nav', nav)
+        const active = navList.find(item => {
+            return item.key == match.id || `/app/${item.key}` == match.pathname
+        });
         if (active) {
-            handleSetNav(active)
-        } else if (navList.length) {
+            setNav(active)
+        }else {
             handleSetNav(navList[0])
         }
     }, [matches, nav, navList]);

+ 5 - 4
frontend/src/pages/microApp/index.tsx

@@ -35,18 +35,19 @@ export const MicroAppPage = () => {
         if (!domRef.current || !app) return;
         console.log('app',app)
         if (microApp.current){
-            console.log('MicroApp has init')
-            // return;
+            console.log('MicroApp has init',domRef.current)
+            return;
         }
         microApp.current = loadMicroApp({
             name: app.id,
             entry: app.path,
             container: domRef.current,
             props: {
-                root: `#micro-app-${app?.id}`
+                entry: app.path,
+                ...app.props
             },
         }, {
-            sandbox: false,
+            sandbox: true,
             autoStart: true,
         })
         microApp.current.mountPromise.then(()=>{

+ 2 - 0
frontend/src/pages/proxy/index.tsx

@@ -0,0 +1,2 @@
+export { List as ProxyList } from './list'
+

+ 0 - 0
frontend/src/pages/proxy/list/index.less


+ 149 - 0
frontend/src/pages/proxy/list/index.tsx

@@ -0,0 +1,149 @@
+import {useCallback, useMemo, useState} from "react";
+import {Alert} from "antd";
+import './index.less'
+import {CurdColumn, CurdPage} from "../../../components/curd";
+import {ICurdConfig} from "../../../components/curd/types.ts";
+import {PageData} from "../../../models/api.ts";
+import {Proxy, proxyApis} from "../../../api/proxy.ts";
+
+const columns: CurdColumn[] = [
+    {
+        key: 'id',
+        title: 'ID',
+        type: 'string',
+        required: true,
+        width: 60,
+        addable: false,
+        editable: false,
+    },
+    {
+        key: 'name',
+        title: '名称',
+        type: 'string',
+        editable: true,
+        placeholder: '名称',
+        required: true,
+        addable: true,
+        ellipsis: true,
+        width: 200
+    },
+    {
+        key: 'enable',
+        title: '是否启用',
+        type: 'switch',
+        required: true,
+        value: true,
+        width: 90
+    },
+    {
+        key: 'patterns',
+        type: 'select',
+        title: '路径匹配',
+        value: '',
+        mode: 'tags',
+        valueAsString: true,
+        placeholder: '正则表达式,可以选择多个'
+    },
+    {
+        key: 'target',
+        type: 'string',
+        title: '转发地址',
+        required: false,
+        placeholder: 'eg.https://hostname:8080/path/'
+    },
+    {
+        key:'stripPrefix',
+        title:'前缀去除',
+        type:'string',
+        required: false,
+    },
+    {
+        key: 'ws',
+        type: 'string',
+        title: 'WS代理',
+        required: false,
+        placeholder: '填写WS的代理路径'
+    },
+    {
+        key: 'remark',
+        type: 'textarea',
+        title: '备注',
+        value: '',
+        required: false,
+        rows: 4,
+        ellipsis: true,
+    }
+]
+
+const serverConfig: ICurdConfig<Proxy.Data> = {
+    editDialogWidth: 550,
+    labelSpan: 4,
+    hideAdd: false,
+    bordered: true,
+    operationWidth: 120,
+}
+
+/**
+ * 用户列表的操作
+ * @constructor
+ */
+export const List = () => {
+
+    const [success, setSuccess] = useState('')
+
+    const getList = useCallback((query: any) => {
+
+        return proxyApis.getList({
+            ...query,
+            uid: 0,
+        }).then(res => {
+            console.log('res', res.data)
+            return {
+                list: res.data.data || [],
+                total: (res.data.data || []).length,
+                current: 1,
+                pageSize: 1000,
+            } as PageData<Proxy.Data>
+        })
+    }, [])
+
+    const getDetail = (data: Partial<Proxy.Data>) => {
+        return Promise.resolve({...data} as Proxy.Data)
+    }
+
+    const onSave = (data: Partial<Proxy.Data>) => {
+        return proxyApis.save({
+            ...data,
+        })
+            .then(res => {
+                return res.data.data as Proxy.Data;
+            })
+    }
+
+    const handleDelete = (record: Proxy.Data) => {
+        return proxyApis.remove(record)
+            .then(res => res.data)
+    }
+
+
+    const config = useMemo(() => {
+
+        return {
+            ...serverConfig,
+        } as ICurdConfig<Proxy.Data>
+    }, [])
+
+
+    return (<>
+        {
+            success ? (<Alert type="success" message={success} style={{margin: 5}} closable={true}
+                              onClose={() => setSuccess('')}/>) : null
+        }
+        <CurdPage columns={columns} getList={getList} getDetail={getDetail}
+                  operationRender={<></>}
+                  operation={{delete: true, add: false}}
+                  onSave={onSave}
+                  onDelete={handleDelete}
+                  config={config}/>
+    </>)
+}

+ 3 - 2
frontend/src/pages/routes/list/index.tsx

@@ -38,6 +38,8 @@ const columns: CurdColumn[] = [
         title: '路径',
         type: 'string',
         required: true,
+        overlay: true,
+        ellipsis: true
     },
     {
         key: 'index',
@@ -142,6 +144,7 @@ const serverConfig: ICurdConfig<Settings.Route> = {
     hideAdd: false,
     bordered: true,
     operationWidth: 120,
+    pagination: false,
 }
 
 /**
@@ -178,8 +181,6 @@ export const List = () => {
             return {
                 list: tree,
                 total: (res.data.data || []).length,
-                current: 1,
-                pageSize: 1000,
             } as PageData<Settings.Route>
         })
     }, [])

+ 6 - 1
frontend/src/pages/settings/list/index.tsx

@@ -24,6 +24,8 @@ const columns: CurdColumn[] = [
         placeholder: '配置名称',
         required: true,
         addable: true,
+        ellipsis: true,
+        maxWidth: 250
     },
     {
         key: 'configKey',
@@ -31,6 +33,7 @@ const columns: CurdColumn[] = [
         type: 'string',
         editable: false,
         required: true,
+        ellipsis: true
     },
     {
         key: 'configValue',
@@ -40,13 +43,15 @@ const columns: CurdColumn[] = [
         required: true,
         addable: true,
         rows: 4,
+        ellipsis: true
     },
     {
         key: 'description',
         type: 'textarea',
         title: '配置说明',
         required: false,
-        rows: 5
+        rows: 5,
+        ellipsis: true
     },
     {
         key: 'enable',

+ 2 - 2
frontend/src/routes/index.tsx

@@ -48,10 +48,10 @@ export const MyRouter = () => {
             <RouterProvider router={createBrowserRouter(createRoutesFromElements(
                 <Route ErrorBoundary={ErrorBoundary}>
                     <Route id="INDEX" path="/*" element={<RouteWrapper Component={MainLayout}/>} handle={{ skipAuth: true }}>
-                        <Route path="app/:id" Component={MicroAppPage} index={true}  handle={{ skipAuth: true }}/>
+                        <Route id="APP" path="app/:id" Component={MicroAppPage} index={true}  handle={{ skipAuth: true }}/>
                         {routes.map((item, idx) => (buildRoutes(item, idx)))}
                         <Route id="MODIFY_PASSWORD" path="modifyPassword" Component={ModifyPasswordPage}  handle={{ skipAuth: true }}/>
-                        <Route path="home" Component={HomePage} handle={{ skipAuth: true }} />
+                        <Route id="HOME" path="home" Component={HomePage} handle={{ skipAuth: true }} />
                     </Route>
                     {
                         openRoutes.map((item,idx) => (<Route key={`${idx}_${item.path}`}

+ 14 - 1
frontend/src/routes/routes.tsx

@@ -11,6 +11,7 @@ import {ResetPassword} from "../pages/user/resetPassword";
 import {RouteList} from "../pages/routes";
 import {MenuLayout} from "../pages/layout/menu";
 import {UserLinks} from "../pages/user/links";
+import {ProxyList} from "../pages/proxy";
 
 
 export const openRoutes: RouteObject[] = [
@@ -23,18 +24,22 @@ export const openRoutes: RouteObject[] = [
         element: <ErrorPage type="NOT_FOUND"/>,
     },
     {
+        id: 'LOGIN',
         path: "login",
         element: <LoginPage/>
     },
     {
+        id: 'SIGNUP',
         path: "signup",
         element: <SignupPage/>
     },
     {
+        id: 'RESET_PASSWORD',
         path: 'resetPassword',
         element: <ResetPassword/>
     },
     {
+        id: 'RESET_PASSWORD_',
         path: "resetPassword/:key",
         element: <ResetPassword/>
     }
@@ -65,6 +70,14 @@ export const routes: RouteObject[] = [
             },
         ]
     },
+    {
+        id: "PROXY",
+        path: 'proxy',
+        Component: ProxyList,
+        handle: {
+            title: '代理配置'
+        }
+    },
     {
         id: 'SETTING_ID',
         path: 'settings',
@@ -79,6 +92,6 @@ export const routes: RouteObject[] = [
         id: 'USER_LINKS',
         path: 'links',
         Component: UserLinks,
-    }
+    },
 ]
 

+ 8 - 8
frontend/vite.config.ts

@@ -58,19 +58,19 @@ export default defineConfig(({command, mode}) => {
                         rewrite: path => path.replace(/^\/api/, "")
                     }
                 } : {
-                    "/api/rdm/": {
-                        // target: 'http://10.10.0.1:8080',
-                        // target: 'http://10.10.0.1:38085',
-                        target: 'http://127.0.0.1:38085',
-                        rewrite: path => path.replace(/^\/api/, ""),
-                        ws: true
-                    },
                     "/api": {
                         // target: 'http://10.10.0.1:8080',
                         target: 'http://127.0.0.1:8080',
                         rewrite: path => path.replace(/^\/api/, "")
                     },
-                }
+                },
+                "/rdm/": {
+                    // target: 'http://10.10.0.1:8080',
+                    // target: 'http://10.10.0.1:38085',
+                    target: 'http://127.0.0.1:38085',
+                    rewrite: path => path.replace(/^\/api/, ""),
+                    ws: true
+                },
             }
         },
         build: {

+ 1 - 0
server/db/db.go

@@ -46,6 +46,7 @@ func Init() {
 
 	orm.RegisterModel(new(models.Setting))
 	orm.RegisterModel(new(models.SettingRoute))
+	orm.RegisterModel(new(models.ProxyEntity))
 
 	orm.RunSyncdb("default", false, true)
 

+ 2 - 0
server/init/init.go

@@ -7,6 +7,7 @@ import (
 	"nginx-ui/server/config"
 	"nginx-ui/server/db"
 	"nginx-ui/server/models"
+	"nginx-ui/server/modules/proxy"
 	_ "nginx-ui/server/routers"
 	"nginx-ui/server/utils"
 	"os"
@@ -28,4 +29,5 @@ func init() {
 	fmt.Println("init success")
 	ensureRoutes()
 	ensureIndexHtml()
+	proxy.Instance.RefreshProxies()
 }

+ 21 - 0
server/models/proxy.go

@@ -0,0 +1,21 @@
+package models
+
+// ProxyEntity http代理实体类
+type ProxyEntity struct {
+	Id          int    `orm:"pk;auto" json:"id"`
+	Name        string `orm:"size(100)" json:"name"`
+	UserId      int    `json:"userId"` // 用户账号 Uid
+	Enable      bool   `orm:"default(true)" json:"enable"`
+	Rel         string `json:"rel"` // 关联对象主键
+	Patterns    string `json:"patterns"`
+	StripPrefix string `json:"stripPrefix"` // 去掉
+	Target      string `json:"target"`      // 转发目标
+	Ws          string `json:"ws"`          // websocket的URL
+	Remark      string `json:"remark"`
+}
+
+func (s *ProxyEntity) UniqueClone() (*ProxyEntity, string) {
+	return &ProxyEntity{
+		Id: s.Id,
+	}, "Id"
+}

+ 36 - 0
server/modules/proxy/byte_utils.go

@@ -0,0 +1,36 @@
+package proxy
+
+import "sync"
+
+var (
+	byteSlicePool = sync.Pool{
+		New: func() interface{} {
+			return []byte{}
+		},
+	}
+	byteSliceChan = make(chan []byte, 10)
+)
+
+func ByteSliceGet(length int) (data []byte) {
+	select {
+	case data = <-byteSliceChan:
+	default:
+		data = byteSlicePool.Get().([]byte)[:0]
+	}
+
+	if cap(data) < length {
+		data = make([]byte, length)
+	} else {
+		data = data[:length]
+	}
+
+	return data
+}
+
+func ByteSlicePut(data []byte) {
+	select {
+	case byteSliceChan <- data:
+	default:
+		byteSlicePool.Put(data) //nolint:staticcheck
+	}
+}

+ 91 - 0
server/modules/proxy/controller.go

@@ -0,0 +1,91 @@
+package proxy
+
+import (
+	"encoding/json"
+	"errors"
+	"github.com/astaxie/beego/logs"
+	"nginx-ui/server/base"
+	"nginx-ui/server/models"
+)
+
+type Controller struct {
+	base.Controller
+	service *Service
+}
+
+var Proxy = NewReverseProxy()
+
+var Instance = &Controller{
+	service: NewProxyService(),
+}
+
+func (c *Controller) RefreshProxies() {
+	list := c.service.GetAll(&GetProxyListReq{Enable: true})
+	Proxy.LoadProxies(list)
+}
+
+// List 登录
+func (c *Controller) List() {
+	user := c.RequiredUser()
+	if user == nil {
+		return
+	}
+	var req GetProxyListReq
+	err := json.Unmarshal(c.Ctx.Input.RequestBody, &req)
+	if err != nil {
+		logs.Error(err, string(c.Ctx.Input.RequestBody))
+		c.ErrorJson(err)
+		return
+	}
+	req.Uid = user.Id
+	resp := c.service.GetAll(&req)
+	c.SetData(resp).Json()
+}
+
+// Save 获取全部用户信息
+func (c *Controller) Save() {
+	user := c.RequiredUser()
+	if user == nil {
+		return
+	}
+	req := models.ProxyEntity{}
+	if !c.ReadBody(&req) {
+		return
+	}
+	if req.Id == 0 {
+		req.UserId = user.Id
+	}
+	resp, err := c.service.Update(&req)
+	if err != nil {
+		logs.Warn("Users get fail: %v", err)
+		c.ErrorJson(err)
+		return
+	}
+	c.RefreshProxies()
+	c.SetData(resp).Json()
+}
+
+// Remove 删除指定的代理
+func (c *Controller) Remove() {
+	user := c.RequiredUser()
+	if user == nil {
+		return
+	}
+	req := models.ProxyEntity{}
+	if !c.ReadBody(&req) {
+		return
+	}
+	if req.UserId != user.Id {
+		c.ErrorJson(errors.New("更新失败,无操作权限!"))
+		return
+	}
+	resp, err := c.service.Delete(&req)
+	if err != nil {
+		logs.Warn("Users get fail: %v", err)
+		c.ErrorJson(err)
+		return
+	}
+	c.SetSession("user", *resp)
+	c.RefreshProxies()
+	c.SetData(resp).Json()
+}

+ 72 - 0
server/modules/proxy/http.go

@@ -0,0 +1,72 @@
+package proxy
+
+import (
+	"net/http"
+	"net/http/httputil"
+	"net/url"
+	"nginx-ui/server/models"
+	"strings"
+)
+
+type HttpProxy struct {
+	*httputil.ReverseProxy
+	data *Data
+}
+
+func NewSingleHostReverseProxy(target *url.URL, data *models.ProxyEntity) *HttpProxy {
+	director := func(req *http.Request) {
+		rewriteRequestURL(req, target, data)
+	}
+	return &HttpProxy{
+		ReverseProxy: &httputil.ReverseProxy{Director: director},
+	}
+}
+
+func rewriteRequestURL(req *http.Request, target *url.URL, data *models.ProxyEntity) {
+	targetQuery := target.RawQuery
+	req.URL.Scheme = target.Scheme
+	req.URL.Host = target.Host
+	req.URL.Path, req.URL.RawPath = joinURLPath(target, req.URL)
+	if data.StripPrefix != "" {
+		req.URL.Path = strings.TrimPrefix(req.URL.Path, data.StripPrefix)
+		req.URL.RawPath = strings.TrimPrefix(req.URL.RawPath, data.StripPrefix)
+	}
+	if targetQuery == "" || req.URL.RawQuery == "" {
+		req.URL.RawQuery = targetQuery + req.URL.RawQuery
+	} else {
+		req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
+	}
+}
+
+func joinURLPath(a, b *url.URL) (path, rawpath string) {
+	if a.RawPath == "" && b.RawPath == "" {
+		return singleJoiningSlash(a.Path, b.Path), ""
+	}
+	// Same as singleJoiningSlash, but uses EscapedPath to determine
+	// whether a slash should be added
+	apath := a.EscapedPath()
+	bpath := b.EscapedPath()
+
+	aslash := strings.HasSuffix(apath, "/")
+	bslash := strings.HasPrefix(bpath, "/")
+
+	switch {
+	case aslash && bslash:
+		return a.Path + b.Path[1:], apath + bpath[1:]
+	case !aslash && !bslash:
+		return a.Path + "/" + b.Path, apath + "/" + bpath
+	}
+	return a.Path + b.Path, apath + bpath
+}
+
+func singleJoiningSlash(a, b string) string {
+	aslash := strings.HasSuffix(a, "/")
+	bslash := strings.HasPrefix(b, "/")
+	switch {
+	case aslash && bslash:
+		return a + b[1:]
+	case !aslash && !bslash:
+		return a + "/" + b
+	}
+	return a + b
+}

+ 96 - 0
server/modules/proxy/proxy.go

@@ -0,0 +1,96 @@
+package proxy
+
+import (
+	"github.com/astaxie/beego/context"
+	"github.com/astaxie/beego/logs"
+	"net/http"
+	"net/url"
+	"nginx-ui/server/models"
+	"regexp"
+	"strings"
+)
+
+type Data struct {
+	*models.ProxyEntity
+	Regexp  []*regexp.Regexp `orm:"-" json:"-"`
+	proxy   *HttpProxy
+	wsProxy *WebsocketProxy
+}
+
+type ReverseProxy struct {
+	proxies   []*Data
+	NoProxies []*regexp.Regexp `json:"-"`
+}
+
+func NewReverseProxy() *ReverseProxy {
+	return &ReverseProxy{
+		proxies:   make([]*Data, 0),
+		NoProxies: make([]*regexp.Regexp, 0),
+	}
+}
+
+func (proxy *ReverseProxy) AddNoProxy(pattern *regexp.Regexp) {
+	proxy.NoProxies = append(proxy.NoProxies, pattern)
+}
+
+func (proxy *ReverseProxy) proxy(ctx *context.Context, data *Data) {
+	if data.wsProxy != nil {
+		if data.wsProxy.Proxy(ctx.ResponseWriter, ctx.Request) {
+			return
+		}
+	}
+	data.proxy.ServeHTTP(ctx.ResponseWriter, ctx.Request)
+}
+
+func (proxy *ReverseProxy) LoadProxies(list []*models.ProxyEntity) {
+	entities := make([]*Data, 0)
+	for _, p := range list {
+		data := &Data{ProxyEntity: p}
+		items := make([]*regexp.Regexp, 0)
+		patterns := strings.Split(p.Patterns, ",")
+		for _, pattern := range patterns {
+			reg, err := regexp.Compile(pattern)
+			if err != nil {
+				logs.Error("regexp error: %s", err)
+				continue
+			}
+			items = append(items, reg)
+		}
+		data.Regexp = items
+		parsedURL, err := url.Parse(p.Target)
+		if err != nil {
+			logs.Error("target parse url error: %s", err)
+			continue
+		}
+		data.proxy = NewSingleHostReverseProxy(parsedURL, p)
+		if len(p.Ws) > 0 {
+			ws, err := NewWebsocketProxy(p.Ws, func(r *http.Request) error {
+				return nil
+			})
+			if err != nil {
+				logs.Error("NewWebsocketProxy fail: %v", err)
+			} else {
+				data.wsProxy = ws
+			}
+		}
+		entities = append(entities, data)
+	}
+	proxy.proxies = entities
+}
+
+func (proxy *ReverseProxy) Filter(ctx *context.Context) {
+	request := ctx.Request.RequestURI
+	for _, n := range proxy.NoProxies {
+		if n.MatchString(request) {
+			return
+		}
+	}
+	for _, p := range proxy.proxies {
+		for _, pattern := range p.Regexp {
+			if pattern.MatchString(request) {
+				proxy.proxy(ctx, p)
+				return
+			}
+		}
+	}
+}

+ 53 - 0
server/modules/proxy/service.go

@@ -0,0 +1,53 @@
+package proxy
+
+import (
+	"github.com/astaxie/beego/logs"
+	"github.com/astaxie/beego/orm"
+	"nginx-ui/server/models"
+)
+
+type Service struct {
+}
+
+func NewProxyService() *Service {
+	return &Service{}
+}
+
+func (u *Service) GetAll(req *GetProxyListReq) []*models.ProxyEntity {
+	o := orm.NewOrm()
+	qs := o.QueryTable(&models.ProxyEntity{})
+	if req.Enable {
+		qs = qs.Filter("Enable", true)
+	}
+	if req.Ref != "" {
+		qs = qs.Filter("Ref", req.Ref)
+	}
+	if req.Uid > 0 {
+		qs = qs.Filter("UserId", req.Uid)
+	}
+	list := make([]*models.ProxyEntity, 0)
+	_, err := qs.All(&list)
+	if err != nil {
+		logs.Error("query proxies fail: %v", err)
+		return make([]*models.ProxyEntity, 0)
+	}
+	return list
+}
+
+func (u *Service) Update(req *models.ProxyEntity) (*models.ProxyEntity, error) {
+	o := orm.NewOrm()
+	_, err := models.InsertOrUpdate(o, req)
+	if err != nil {
+		return nil, err
+	}
+	return req, nil
+}
+
+func (u *Service) Delete(req *models.ProxyEntity) (*models.ProxyEntity, error) {
+	o := orm.NewOrm()
+	_, err := o.Delete(&models.ProxyEntity{Id: req.Id})
+	if err != nil {
+		return nil, err
+	}
+	return req, nil
+}

+ 7 - 0
server/modules/proxy/vo.go

@@ -0,0 +1,7 @@
+package proxy
+
+type GetProxyListReq struct {
+	Ref    string `json:"rel"`
+	Enable bool   `json:"enable"`
+	Uid    int    `json:"uid"`
+}

+ 142 - 0
server/modules/proxy/websocket.go

@@ -0,0 +1,142 @@
+package proxy
+
+import (
+	"crypto/tls"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"strings"
+)
+
+const (
+	WsScheme  = "ws"
+	WssScheme = "wss"
+	BufSize   = 1024 * 32
+)
+
+var ErrFormatAddr = errors.New("remote websockets addr format error")
+
+type WebsocketProxy struct {
+	// ws, wss
+	scheme string
+	// The target address: host:port
+	remoteAddr string
+	// path
+	defaultPath string
+	tls         *tls.Config
+	logger      *log.Logger
+	// Send handshake before callback
+	beforeHandshake func(r *http.Request) error
+}
+
+type Options func(wp *WebsocketProxy)
+
+// You must carry a port number,ws://ip:80/ssss, wss://ip:443/aaaa
+// ex: ws://ip:port/ajaxchattest
+func NewWebsocketProxy(addr string, beforeHandshake func(r *http.Request) error, options ...Options) (*WebsocketProxy, error) {
+	u, err := url.Parse(addr)
+	if err != nil {
+		return nil, ErrFormatAddr
+	}
+	host, port, err := net.SplitHostPort(u.Host)
+	if err != nil {
+		return nil, ErrFormatAddr
+	}
+	if u.Scheme != WsScheme && u.Scheme != WssScheme {
+		return nil, ErrFormatAddr
+	}
+	wp := &WebsocketProxy{
+		scheme:          u.Scheme,
+		remoteAddr:      fmt.Sprintf("%s:%s", host, port),
+		defaultPath:     u.Path,
+		beforeHandshake: beforeHandshake,
+		logger:          log.New(os.Stderr, "", log.LstdFlags),
+	}
+	if u.Scheme == WssScheme {
+		wp.tls = &tls.Config{InsecureSkipVerify: true}
+	}
+	for op := range options {
+		options[op](wp)
+	}
+	return wp, nil
+}
+
+func (wp *WebsocketProxy) Proxy(writer http.ResponseWriter, request *http.Request) bool {
+	if strings.ToLower(request.Header.Get("Connection")) != "upgrade" ||
+		strings.ToLower(request.Header.Get("Upgrade")) != "websocket" {
+		_, _ = writer.Write([]byte(`Must be a websocket request`))
+		return false
+	}
+	hijacker, ok := writer.(http.Hijacker)
+	if !ok {
+		return false
+	}
+	conn, _, err := hijacker.Hijack()
+	if err != nil {
+		return false
+	}
+	defer conn.Close()
+	req := request.Clone(request.Context())
+	req.URL.Path, req.URL.RawPath, req.RequestURI = wp.defaultPath, wp.defaultPath, wp.defaultPath
+	req.Host = wp.remoteAddr
+	if wp.beforeHandshake != nil {
+		// Add headers, permission authentication + masquerade sources
+		err = wp.beforeHandshake(req)
+		if err != nil {
+			_, _ = writer.Write([]byte(err.Error()))
+			return false
+		}
+	}
+	var remoteConn net.Conn
+	switch wp.scheme {
+	case WsScheme:
+		remoteConn, err = net.Dial("tcp", wp.remoteAddr)
+	case WssScheme:
+		remoteConn, err = tls.Dial("tcp", wp.remoteAddr, wp.tls)
+	}
+	if err != nil {
+		_, _ = writer.Write([]byte(err.Error()))
+		return false
+	}
+	defer remoteConn.Close()
+	err = req.Write(remoteConn)
+	if err != nil {
+		wp.logger.Println("remote write err:", err)
+		return false
+	}
+	errChan := make(chan error, 2)
+	copyConn := func(a, b net.Conn) {
+		buf := ByteSliceGet(BufSize)
+		defer ByteSlicePut(buf)
+		_, err := io.CopyBuffer(a, b, buf)
+		errChan <- err
+	}
+	go copyConn(conn, remoteConn) // response
+	go copyConn(remoteConn, conn) // request
+	select {
+	case err = <-errChan:
+		if err != nil {
+			log.Println(err)
+		}
+	}
+	return true
+}
+
+func SetTLSConfig(tls *tls.Config) Options {
+	return func(wp *WebsocketProxy) {
+		wp.tls = tls
+	}
+}
+
+func SetLogger(l *log.Logger) Options {
+	return func(wp *WebsocketProxy) {
+		if l != nil {
+			wp.logger = l
+		}
+	}
+}

+ 9 - 0
server/routers/router.go

@@ -11,10 +11,12 @@ import (
 	"nginx-ui/server/modules/ldap"
 	"nginx-ui/server/modules/nginx/nginx_controller"
 	"nginx-ui/server/modules/oauth2"
+	"nginx-ui/server/modules/proxy"
 	"nginx-ui/server/modules/settings"
 	"nginx-ui/server/modules/user"
 	"nginx-ui/server/modules/wechat"
 	"nginx-ui/server/utils"
+	"regexp"
 	"strings"
 )
 
@@ -78,11 +80,18 @@ func init() {
 		beego.NSRouter("/settings/route/delete", &settings.RouteController{}, "post:Delete"),
 		//	其它设置
 		beego.NSRouter("/wechat/webhook/gitlab", &wechat.Controller{}, "post:WebhookFromGitlab"),
+		// 代理设置
+		beego.NSRouter("/proxy/list", proxy.Instance, "post:List"),
+		beego.NSRouter("/proxy/save", proxy.Instance, "post:Save"),
+		beego.NSRouter("/proxy/remove", proxy.Instance, "post:Remove"),
 	)
 	beego.AddNamespace(ns)
 	// LDAP路由
 	beego.AddNamespace(ldap.InitRouter(config.BaseApi))
 
+	proxy.Proxy.AddNoProxy(regexp.MustCompile(fmt.Sprintf("^(%s)", config.BaseApi)))
+
+	beego.InsertFilter("/**", beego.BeforeRouter, proxy.Proxy.Filter)
 	beego.InsertFilter(fmt.Sprintf("%s/**", config.BaseApi), beego.BeforeRouter, middleware.AuthFilter)
 
 	beego.Router(fmt.Sprintf("%s/config.js", config.ContextPath), &nginx_controller.ConfigController{})

Some files were not shown because too many files changed in this diff