server use sqlite
All checks were successful
Build Multi-Platform Binaries / build (push) Successful in 11m35s
All checks were successful
Build Multi-Platform Binaries / build (push) Successful in 11m35s
This commit is contained in:
@@ -4,7 +4,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/gotunnel/pkg/tunnel"
|
"github.com/gotunnel/internal/client/tunnel"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -5,25 +5,42 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/gotunnel/pkg/config"
|
"github.com/gotunnel/internal/server/app"
|
||||||
"github.com/gotunnel/pkg/tunnel"
|
"github.com/gotunnel/internal/server/config"
|
||||||
"github.com/gotunnel/pkg/webserver"
|
"github.com/gotunnel/internal/server/db"
|
||||||
|
"github.com/gotunnel/internal/server/tunnel"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("c", "server.yaml", "config file path")
|
configPath := flag.String("c", "server.yaml", "config file path")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
// 加载 YAML 配置
|
||||||
cfg, err := config.LoadServerConfig(*configPath)
|
cfg, err := config.LoadServerConfig(*configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Load config error: %v", err)
|
log.Fatalf("Load config error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
server := tunnel.NewServer(cfg)
|
// 初始化数据库
|
||||||
|
clientStore, err := db.NewSQLiteStore(cfg.Server.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Init database error: %v", err)
|
||||||
|
}
|
||||||
|
defer clientStore.Close()
|
||||||
|
|
||||||
|
// 创建隧道服务
|
||||||
|
server := tunnel.NewServer(
|
||||||
|
clientStore,
|
||||||
|
cfg.Server.BindAddr,
|
||||||
|
cfg.Server.BindPort,
|
||||||
|
cfg.Server.Token,
|
||||||
|
cfg.Server.HeartbeatSec,
|
||||||
|
cfg.Server.HeartbeatTimeout,
|
||||||
|
)
|
||||||
|
|
||||||
// 启动 Web 控制台
|
// 启动 Web 控制台
|
||||||
if cfg.Web.Enabled {
|
if cfg.Web.Enabled {
|
||||||
ws := webserver.NewWebServer(cfg, *configPath, server)
|
ws := app.NewWebServer(clientStore, server)
|
||||||
addr := fmt.Sprintf("%s:%d", cfg.Web.BindAddr, cfg.Web.BindPort)
|
addr := fmt.Sprintf("%s:%d", cfg.Web.BindAddr, cfg.Web.BindPort)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -7,3 +7,5 @@ require (
|
|||||||
github.com/hashicorp/yamux v0.1.1
|
github.com/hashicorp/yamux v0.1.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -2,6 +2,8 @@ github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
|||||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
|
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
|
||||||
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
|
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gotunnel/pkg/protocol"
|
"github.com/gotunnel/pkg/protocol"
|
||||||
|
"github.com/gotunnel/pkg/relay"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/hashicorp/yamux"
|
"github.com/hashicorp/yamux"
|
||||||
)
|
)
|
||||||
@@ -57,7 +58,6 @@ func (c *Client) connect() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送认证
|
|
||||||
authReq := protocol.AuthRequest{ClientID: c.ID, Token: c.Token}
|
authReq := protocol.AuthRequest{ClientID: c.ID, Token: c.Token}
|
||||||
msg, _ := protocol.NewMessage(protocol.MsgTypeAuth, authReq)
|
msg, _ := protocol.NewMessage(protocol.MsgTypeAuth, authReq)
|
||||||
if err := protocol.WriteMessage(conn, msg); err != nil {
|
if err := protocol.WriteMessage(conn, msg); err != nil {
|
||||||
@@ -65,7 +65,6 @@ func (c *Client) connect() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取响应
|
|
||||||
resp, err := protocol.ReadMessage(conn)
|
resp, err := protocol.ReadMessage(conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
@@ -81,7 +80,6 @@ func (c *Client) connect() error {
|
|||||||
|
|
||||||
log.Printf("[Client] Authenticated as %s", c.ID)
|
log.Printf("[Client] Authenticated as %s", c.ID)
|
||||||
|
|
||||||
// 建立 Yamux 会话
|
|
||||||
session, err := yamux.Client(conn, nil)
|
session, err := yamux.Client(conn, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
@@ -147,7 +145,6 @@ func (c *Client) handleNewProxy(stream net.Conn, msg *protocol.Message) {
|
|||||||
var req protocol.NewProxyRequest
|
var req protocol.NewProxyRequest
|
||||||
msg.ParsePayload(&req)
|
msg.ParsePayload(&req)
|
||||||
|
|
||||||
// 查找对应规则
|
|
||||||
var rule *protocol.ProxyRule
|
var rule *protocol.ProxyRule
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
for _, r := range c.rules {
|
for _, r := range c.rules {
|
||||||
@@ -163,7 +160,6 @@ func (c *Client) handleNewProxy(stream net.Conn, msg *protocol.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 连接本地服务
|
|
||||||
localAddr := fmt.Sprintf("%s:%d", rule.LocalIP, rule.LocalPort)
|
localAddr := fmt.Sprintf("%s:%d", rule.LocalIP, rule.LocalPort)
|
||||||
localConn, err := net.DialTimeout("tcp", localAddr, 5*time.Second)
|
localConn, err := net.DialTimeout("tcp", localAddr, 5*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -171,8 +167,7 @@ func (c *Client) handleNewProxy(stream net.Conn, msg *protocol.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 双向转发
|
relay.Relay(stream, localConn)
|
||||||
relay(stream, localConn)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleHeartbeat 处理心跳
|
// handleHeartbeat 处理心跳
|
||||||
112
internal/server/app/app.go
Normal file
112
internal/server/app/app.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gotunnel/internal/server/db"
|
||||||
|
"github.com/gotunnel/internal/server/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed dist/*
|
||||||
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
// spaHandler SPA路由处理器
|
||||||
|
type spaHandler struct {
|
||||||
|
fs http.FileSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
f, err := h.fs.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
f, err = h.fs.Open("index.html")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
stat, _ := f.Stat()
|
||||||
|
if stat.IsDir() {
|
||||||
|
f, err = h.fs.Open(path + "/index.html")
|
||||||
|
if err != nil {
|
||||||
|
f, _ = h.fs.Open("index.html")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.ServeContent(w, r, path, stat.ModTime(), f.(io.ReadSeeker))
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebServer Web控制台服务
|
||||||
|
type WebServer struct {
|
||||||
|
ClientStore db.ClientStore
|
||||||
|
Server router.ServerInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWebServer 创建Web服务
|
||||||
|
func NewWebServer(cs db.ClientStore, srv router.ServerInterface) *WebServer {
|
||||||
|
return &WebServer{
|
||||||
|
ClientStore: cs,
|
||||||
|
Server: srv,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 启动Web服务
|
||||||
|
func (w *WebServer) Run(addr string) error {
|
||||||
|
r := router.New()
|
||||||
|
router.RegisterRoutes(r, w)
|
||||||
|
|
||||||
|
staticFS, err := fs.Sub(staticFiles, "dist")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
|
||||||
|
|
||||||
|
log.Printf("[Web] Console listening on %s", addr)
|
||||||
|
return http.ListenAndServe(addr, r.Handler())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithAuth 启动带认证的Web服务
|
||||||
|
func (w *WebServer) RunWithAuth(addr, username, password string) error {
|
||||||
|
r := router.New()
|
||||||
|
router.RegisterRoutes(r, w)
|
||||||
|
|
||||||
|
staticFS, err := fs.Sub(staticFiles, "dist")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
|
||||||
|
|
||||||
|
handler := &authMiddleware{username, password, r.Handler()}
|
||||||
|
log.Printf("[Web] Console listening on %s (auth enabled)", addr)
|
||||||
|
return http.ListenAndServe(addr, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type authMiddleware struct {
|
||||||
|
username, password string
|
||||||
|
handler http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *authMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if !ok || user != a.username || pass != a.password {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="GoTunnel"`)
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientStore 获取客户端存储
|
||||||
|
func (w *WebServer) GetClientStore() db.ClientStore {
|
||||||
|
return w.ClientStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServer 获取服务端接口
|
||||||
|
func (w *WebServer) GetServer() router.ServerInterface {
|
||||||
|
return w.Server
|
||||||
|
}
|
||||||
1
internal/server/app/dist/assets/ClientView-Dim5xo2q.js
vendored
Normal file
1
internal/server/app/dist/assets/ClientView-Dim5xo2q.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{d as D,j as F,r,o as I,c as n,a as e,b as p,k as C,u as S,t as s,n as J,F as V,e as g,l as M,m as O,p as P,f as _,v,i as o,_ as $}from"./index-BvqIwKwu.js";const j={class:"client-view"},z={class:"header"},L={key:0,class:"ping-info"},T={class:"rules-section"},q={class:"section-header"},A={key:0},G={key:0,class:"rules-table"},H={key:1,class:"edit-form"},K=["onUpdate:modelValue"],Q=["onUpdate:modelValue"],W=["onUpdate:modelValue"],X=["onUpdate:modelValue"],Y=["onClick"],Z=D({__name:"ClientView",setup(ee){const w=F(),f=S(),u=w.params.id,m=r(!1),h=r(""),b=r([]),i=r(!1),d=r([]),y=async()=>{try{const{data:l}=await M(u);m.value=l.online,h.value=l.last_ping||"",b.value=l.rules||[]}catch(l){console.error("Failed to load client",l)}};I(y);const U=()=>{d.value=JSON.parse(JSON.stringify(b.value)),i.value=!0},R=()=>{i.value=!1},x=()=>{d.value.push({name:"",local_ip:"127.0.0.1",local_port:80,remote_port:8080})},E=l=>{d.value.splice(l,1)},N=async()=>{try{await O(u,{id:u,rules:d.value}),i.value=!1,y()}catch{alert("保存失败")}},B=async()=>{if(confirm("确定删除此客户端?"))try{await P(u),f.push("/")}catch{alert("删除失败")}};return(l,c)=>(o(),n("div",j,[e("div",z,[e("button",{class:"btn",onClick:c[0]||(c[0]=t=>C(f).push("/"))},"← 返回"),e("h2",null,s(C(u)),1),e("span",{class:J(["status-badge",m.value?"online":"offline"])},s(m.value?"在线":"离线"),3)]),h.value?(o(),n("div",L,"最后心跳: "+s(h.value),1)):p("",!0),e("div",T,[e("div",q,[c[1]||(c[1]=e("h3",null,"代理规则",-1)),i.value?p("",!0):(o(),n("div",A,[e("button",{class:"btn primary",onClick:U},"编辑"),e("button",{class:"btn danger",onClick:B},"删除")]))]),i.value?p("",!0):(o(),n("table",G,[c[2]||(c[2]=e("thead",null,[e("tr",null,[e("th",null,"名称"),e("th",null,"本地地址"),e("th",null,"远程端口")])],-1)),e("tbody",null,[(o(!0),n(V,null,g(b.value,t=>(o(),n("tr",{key:t.name},[e("td",null,s(t.name),1),e("td",null,s(t.local_ip)+":"+s(t.local_port),1),e("td",null,s(t.remote_port),1)]))),128))])])),i.value?(o(),n("div",H,[(o(!0),n(V,null,g(d.value,(t,k)=>(o(),n("div",{key:k,class:"rule-row"},[_(e("input",{"onUpdate:modelValue":a=>t.name=a,placeholder:"名称"},null,8,K),[[v,t.name]]),_(e("input",{"onUpdate:modelValue":a=>t.local_ip=a,placeholder:"本地IP"},null,8,Q),[[v,t.local_ip]]),_(e("input",{"onUpdate:modelValue":a=>t.local_port=a,type:"number",placeholder:"本地端口"},null,8,W),[[v,t.local_port,void 0,{number:!0}]]),_(e("input",{"onUpdate:modelValue":a=>t.remote_port=a,type:"number",placeholder:"远程端口"},null,8,X),[[v,t.remote_port,void 0,{number:!0}]]),e("button",{class:"btn-icon",onClick:a=>E(k)},"×",8,Y)]))),128)),e("button",{class:"btn secondary",onClick:x},"+ 添加规则"),e("div",{class:"edit-actions"},[e("button",{class:"btn",onClick:R},"取消"),e("button",{class:"btn primary",onClick:N},"保存")])])):p("",!0)])]))}}),le=$(Z,[["__scopeId","data-v-01b16887"]]);export{le as default};
|
||||||
1
internal/server/app/dist/assets/ClientView-kscuBolA.css
vendored
Normal file
1
internal/server/app/dist/assets/ClientView-kscuBolA.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.header[data-v-01b16887]{display:flex;align-items:center;gap:16px;margin-bottom:20px}.header h2[data-v-01b16887]{margin:0}.status-badge[data-v-01b16887]{padding:4px 12px;border-radius:12px;font-size:12px}.status-badge.online[data-v-01b16887]{background:#d4edda;color:#155724}.status-badge.offline[data-v-01b16887]{background:#f8d7da;color:#721c24}.ping-info[data-v-01b16887]{color:#666;margin-bottom:20px}.rules-section[data-v-01b16887]{background:#fff;border-radius:8px;padding:20px;box-shadow:0 2px 4px #0000001a}.section-header[data-v-01b16887]{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.section-header h3[data-v-01b16887]{margin:0}.section-header .btn[data-v-01b16887]{margin-left:8px}.btn[data-v-01b16887]{padding:8px 16px;border:none;border-radius:4px;cursor:pointer;font-size:14px}.btn.primary[data-v-01b16887]{background:#3498db;color:#fff}.btn.secondary[data-v-01b16887]{background:#95a5a6;color:#fff}.btn.danger[data-v-01b16887]{background:#e74c3c;color:#fff}.rules-table[data-v-01b16887]{width:100%;border-collapse:collapse}.rules-table th[data-v-01b16887],.rules-table td[data-v-01b16887]{padding:12px;text-align:left;border-bottom:1px solid #eee}.rules-table th[data-v-01b16887]{font-weight:600}.rule-row[data-v-01b16887]{display:flex;gap:8px;margin-bottom:8px}.rule-row input[data-v-01b16887]{flex:1;padding:8px;border:1px solid #ddd;border-radius:4px}.btn-icon[data-v-01b16887]{background:#e74c3c;color:#fff;border:none;border-radius:4px;width:32px;cursor:pointer}.edit-actions[data-v-01b16887]{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
|
||||||
1
internal/server/app/dist/assets/HomeView-DGCTeR78.js
vendored
Normal file
1
internal/server/app/dist/assets/HomeView-DGCTeR78.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{d as M,r as p,o as $,c as n,a as e,b as f,F as h,e as b,w as x,f as u,v as c,g as I,h as R,u as B,i as a,t as C,n as D,_ as F}from"./index-BvqIwKwu.js";const H={class:"home"},N={class:"client-grid"},z=["onClick"],A={class:"card-header"},E={class:"client-id"},L={class:"card-info"},P={key:0,class:"empty"},S={class:"modal"},T={class:"form-group"},j={class:"form-group"},q=["onUpdate:modelValue"],G=["onUpdate:modelValue"],J=["onUpdate:modelValue"],K=["onUpdate:modelValue"],O=["onClick"],Q={class:"modal-actions"},W=M({__name:"HomeView",setup(X){const y=B(),v=p([]),d=p(!1),i=p(""),r=p([]),m=async()=>{try{const{data:t}=await I();v.value=t||[]}catch(t){console.error("Failed to load clients",t)}};$(m);const k=()=>{i.value="",r.value=[{name:"",local_ip:"127.0.0.1",local_port:80,remote_port:8080}],d.value=!0},V=()=>{r.value.push({name:"",local_ip:"127.0.0.1",local_port:80,remote_port:8080})},w=t=>{r.value.splice(t,1)},U=async()=>{if(i.value)try{await R({id:i.value,rules:r.value}),d.value=!1,m()}catch{alert("添加失败")}},g=t=>{y.push(`/client/${t}`)};return(t,l)=>(a(),n("div",H,[e("div",{class:"toolbar"},[l[3]||(l[3]=e("h2",null,"客户端列表",-1)),e("button",{class:"btn primary",onClick:k},"添加客户端")]),e("div",N,[(a(!0),n(h,null,b(v.value,o=>(a(),n("div",{key:o.id,class:"client-card",onClick:_=>g(o.id)},[e("div",A,[e("span",E,C(o.id),1),e("span",{class:D(["status",o.online?"online":"offline"])},null,2)]),e("div",L,[e("span",null,C(o.rule_count)+" 条规则",1)])],8,z))),128))]),v.value.length===0?(a(),n("div",P,"暂无客户端配置")):f("",!0),d.value?(a(),n("div",{key:1,class:"modal-overlay",onClick:l[2]||(l[2]=x(o=>d.value=!1,["self"]))},[e("div",S,[l[6]||(l[6]=e("h3",null,"添加客户端",-1)),e("div",T,[l[4]||(l[4]=e("label",null,"客户端 ID",-1)),u(e("input",{"onUpdate:modelValue":l[0]||(l[0]=o=>i.value=o),placeholder:"例如: client-a"},null,512),[[c,i.value]])]),e("div",j,[l[5]||(l[5]=e("label",null,"代理规则",-1)),(a(!0),n(h,null,b(r.value,(o,_)=>(a(),n("div",{key:_,class:"rule-row"},[u(e("input",{"onUpdate:modelValue":s=>o.name=s,placeholder:"名称"},null,8,q),[[c,o.name]]),u(e("input",{"onUpdate:modelValue":s=>o.local_ip=s,placeholder:"本地IP"},null,8,G),[[c,o.local_ip]]),u(e("input",{"onUpdate:modelValue":s=>o.local_port=s,type:"number",placeholder:"本地端口"},null,8,J),[[c,o.local_port,void 0,{number:!0}]]),u(e("input",{"onUpdate:modelValue":s=>o.remote_port=s,type:"number",placeholder:"远程端口"},null,8,K),[[c,o.remote_port,void 0,{number:!0}]]),e("button",{class:"btn-icon",onClick:s=>w(_)},"×",8,O)]))),128)),e("button",{class:"btn secondary",onClick:V},"+ 添加规则")]),e("div",Q,[e("button",{class:"btn",onClick:l[1]||(l[1]=o=>d.value=!1)},"取消"),e("button",{class:"btn primary",onClick:U},"保存")])])])):f("",!0)]))}}),Z=F(W,[["__scopeId","data-v-fd6e4f1d"]]);export{Z as default};
|
||||||
1
internal/server/app/dist/assets/HomeView-fC0dyEJ8.css
vendored
Normal file
1
internal/server/app/dist/assets/HomeView-fC0dyEJ8.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.toolbar[data-v-fd6e4f1d]{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.toolbar h2[data-v-fd6e4f1d]{font-size:18px;color:#2c3e50}.btn[data-v-fd6e4f1d]{padding:8px 16px;border:none;border-radius:4px;cursor:pointer;font-size:14px}.btn.primary[data-v-fd6e4f1d]{background:#3498db;color:#fff}.btn.secondary[data-v-fd6e4f1d]{background:#95a5a6;color:#fff}.client-grid[data-v-fd6e4f1d]{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}.client-card[data-v-fd6e4f1d]{background:#fff;border-radius:8px;padding:16px;cursor:pointer;box-shadow:0 2px 4px #0000001a;transition:transform .2s}.client-card[data-v-fd6e4f1d]:hover{transform:translateY(-2px)}.card-header[data-v-fd6e4f1d]{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.client-id[data-v-fd6e4f1d]{font-weight:600}.status[data-v-fd6e4f1d]{width:10px;height:10px;border-radius:50%}.status.online[data-v-fd6e4f1d]{background:#27ae60}.status.offline[data-v-fd6e4f1d]{background:#95a5a6}.card-info[data-v-fd6e4f1d]{font-size:14px;color:#666}.empty[data-v-fd6e4f1d]{text-align:center;color:#999;padding:40px}.modal-overlay[data-v-fd6e4f1d]{position:fixed;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center}.modal[data-v-fd6e4f1d]{background:#fff;border-radius:8px;padding:24px;width:500px;max-width:90%}.modal h3[data-v-fd6e4f1d],.form-group[data-v-fd6e4f1d]{margin-bottom:16px}.form-group label[data-v-fd6e4f1d]{display:block;margin-bottom:8px;font-weight:500}.form-group input[data-v-fd6e4f1d]{width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;box-sizing:border-box}.rule-row[data-v-fd6e4f1d]{display:flex;gap:8px;margin-bottom:8px}.rule-row input[data-v-fd6e4f1d]{flex:1;width:auto}.btn-icon[data-v-fd6e4f1d]{background:#e74c3c;color:#fff;border:none;border-radius:4px;width:32px;cursor:pointer}.modal-actions[data-v-fd6e4f1d]{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
|
||||||
7
internal/server/app/dist/assets/index-BvqIwKwu.js
vendored
Normal file
7
internal/server/app/dist/assets/index-BvqIwKwu.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/server/app/dist/assets/index-fTDfeMRP.css
vendored
Normal file
1
internal/server/app/dist/assets/index-fTDfeMRP.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
:root{font-family:system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}a{font-weight:500;color:#646cff;text-decoration:inherit}a:hover{color:#535bf2}body{margin:0;display:flex;place-items:center;min-width:320px;min-height:100vh}h1{font-size:3.2em;line-height:1.1}button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}button:hover{border-color:#646cff}button:focus,button:focus-visible{outline:4px auto -webkit-focus-ring-color}.card{padding:2em}#app{max-width:1280px;margin:0 auto;padding:2rem;text-align:center}@media(prefers-color-scheme:light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}.app[data-v-dc56de06]{min-height:100vh;background:#f5f7fa}.header[data-v-dc56de06]{background:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:center;box-shadow:0 2px 4px #0000001a}.header h1[data-v-dc56de06]{font-size:20px;color:#2c3e50}.server-info[data-v-dc56de06]{display:flex;align-items:center;gap:12px;color:#666}.badge[data-v-dc56de06]{background:#3498db;color:#fff;padding:4px 12px;border-radius:12px;font-size:12px}.main[data-v-dc56de06]{padding:24px;max-width:1200px;margin:0 auto}
|
||||||
14
internal/server/app/dist/index.html
vendored
Normal file
14
internal/server/app/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>webui</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-BvqIwKwu.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-fTDfeMRP.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
internal/server/app/dist/vite.svg
vendored
Normal file
1
internal/server/app/dist/vite.svg
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -3,15 +3,13 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/gotunnel/pkg/protocol"
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServerConfig 服务端配置
|
// ServerConfig 服务端配置
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Server ServerSettings `yaml:"server"`
|
Server ServerSettings `yaml:"server"`
|
||||||
Web WebSettings `yaml:"web"`
|
Web WebSettings `yaml:"web"`
|
||||||
Clients []ClientConfig `yaml:"clients"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerSettings 服务端设置
|
// ServerSettings 服务端设置
|
||||||
@@ -21,6 +19,7 @@ type ServerSettings struct {
|
|||||||
Token string `yaml:"token"`
|
Token string `yaml:"token"`
|
||||||
HeartbeatSec int `yaml:"heartbeat_sec"`
|
HeartbeatSec int `yaml:"heartbeat_sec"`
|
||||||
HeartbeatTimeout int `yaml:"heartbeat_timeout"`
|
HeartbeatTimeout int `yaml:"heartbeat_timeout"`
|
||||||
|
DBPath string `yaml:"db_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSettings Web控制台设置
|
// WebSettings Web控制台设置
|
||||||
@@ -32,12 +31,6 @@ type WebSettings struct {
|
|||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientConfig 客户端配置(服务端维护)
|
|
||||||
type ClientConfig struct {
|
|
||||||
ID string `yaml:"id"`
|
|
||||||
Rules []protocol.ProxyRule `yaml:"rules"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadServerConfig 加载服务端配置
|
// LoadServerConfig 加载服务端配置
|
||||||
func LoadServerConfig(path string) (*ServerConfig, error) {
|
func LoadServerConfig(path string) (*ServerConfig, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
@@ -51,12 +44,21 @@ func LoadServerConfig(path string) (*ServerConfig, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 设置默认值
|
// 设置默认值
|
||||||
|
if cfg.Server.BindAddr == "" {
|
||||||
|
cfg.Server.BindAddr = "0.0.0.0"
|
||||||
|
}
|
||||||
|
if cfg.Server.BindPort == 0 {
|
||||||
|
cfg.Server.BindPort = 7000
|
||||||
|
}
|
||||||
if cfg.Server.HeartbeatSec == 0 {
|
if cfg.Server.HeartbeatSec == 0 {
|
||||||
cfg.Server.HeartbeatSec = 30
|
cfg.Server.HeartbeatSec = 30
|
||||||
}
|
}
|
||||||
if cfg.Server.HeartbeatTimeout == 0 {
|
if cfg.Server.HeartbeatTimeout == 0 {
|
||||||
cfg.Server.HeartbeatTimeout = 90
|
cfg.Server.HeartbeatTimeout = 90
|
||||||
}
|
}
|
||||||
|
if cfg.Server.DBPath == "" {
|
||||||
|
cfg.Server.DBPath = "gotunnel.db"
|
||||||
|
}
|
||||||
|
|
||||||
// Web 默认值
|
// Web 默认值
|
||||||
if cfg.Web.BindAddr == "" {
|
if cfg.Web.BindAddr == "" {
|
||||||
@@ -68,22 +70,3 @@ func LoadServerConfig(path string) (*ServerConfig, error) {
|
|||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetClientRules 获取指定客户端的代理规则
|
|
||||||
func (c *ServerConfig) GetClientRules(clientID string) []protocol.ProxyRule {
|
|
||||||
for _, client := range c.Clients {
|
|
||||||
if client.ID == clientID {
|
|
||||||
return client.Rules
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveServerConfig 保存服务端配置
|
|
||||||
func SaveServerConfig(path string, cfg *ServerConfig) error {
|
|
||||||
data, err := yaml.Marshal(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(path, data, 0644)
|
|
||||||
}
|
|
||||||
36
internal/server/db/interface.go
Normal file
36
internal/server/db/interface.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "github.com/gotunnel/pkg/protocol"
|
||||||
|
|
||||||
|
// Client 客户端数据
|
||||||
|
type Client struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Rules []protocol.ProxyRule `json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientStore 客户端存储接口
|
||||||
|
type ClientStore interface {
|
||||||
|
// GetAllClients 获取所有客户端
|
||||||
|
GetAllClients() ([]Client, error)
|
||||||
|
|
||||||
|
// GetClient 获取单个客户端
|
||||||
|
GetClient(id string) (*Client, error)
|
||||||
|
|
||||||
|
// CreateClient 创建客户端
|
||||||
|
CreateClient(c *Client) error
|
||||||
|
|
||||||
|
// UpdateClient 更新客户端
|
||||||
|
UpdateClient(c *Client) error
|
||||||
|
|
||||||
|
// DeleteClient 删除客户端
|
||||||
|
DeleteClient(id string) error
|
||||||
|
|
||||||
|
// ClientExists 检查客户端是否存在
|
||||||
|
ClientExists(id string) (bool, error)
|
||||||
|
|
||||||
|
// GetClientRules 获取客户端规则
|
||||||
|
GetClientRules(id string) ([]protocol.ProxyRule, error)
|
||||||
|
|
||||||
|
// Close 关闭连接
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
146
internal/server/db/sqlite.go
Normal file
146
internal/server/db/sqlite.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
|
||||||
|
"github.com/gotunnel/pkg/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SQLiteStore SQLite 存储实现
|
||||||
|
type SQLiteStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSQLiteStore 创建 SQLite 存储
|
||||||
|
func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
|
||||||
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &SQLiteStore{db: db}
|
||||||
|
if err := s.init(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// init 初始化数据库表
|
||||||
|
func (s *SQLiteStore) init() error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
rules TEXT NOT NULL DEFAULT '[]'
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭数据库连接
|
||||||
|
func (s *SQLiteStore) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllClients 获取所有客户端
|
||||||
|
func (s *SQLiteStore) GetAllClients() ([]Client, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`SELECT id, rules FROM clients`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var clients []Client
|
||||||
|
for rows.Next() {
|
||||||
|
var c Client
|
||||||
|
var rulesJSON string
|
||||||
|
if err := rows.Scan(&c.ID, &rulesJSON); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil {
|
||||||
|
c.Rules = []protocol.ProxyRule{}
|
||||||
|
}
|
||||||
|
clients = append(clients, c)
|
||||||
|
}
|
||||||
|
return clients, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClient 获取单个客户端
|
||||||
|
func (s *SQLiteStore) GetClient(id string) (*Client, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
var c Client
|
||||||
|
var rulesJSON string
|
||||||
|
err := s.db.QueryRow(`SELECT id, rules FROM clients WHERE id = ?`, id).Scan(&c.ID, &rulesJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil {
|
||||||
|
c.Rules = []protocol.ProxyRule{}
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateClient 创建客户端
|
||||||
|
func (s *SQLiteStore) CreateClient(c *Client) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
rulesJSON, err := json.Marshal(c.Rules)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.db.Exec(`INSERT INTO clients (id, rules) VALUES (?, ?)`, c.ID, string(rulesJSON))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateClient 更新客户端
|
||||||
|
func (s *SQLiteStore) UpdateClient(c *Client) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
rulesJSON, err := json.Marshal(c.Rules)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.db.Exec(`UPDATE clients SET rules = ? WHERE id = ?`, string(rulesJSON), c.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteClient 删除客户端
|
||||||
|
func (s *SQLiteStore) DeleteClient(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
_, err := s.db.Exec(`DELETE FROM clients WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientExists 检查客户端是否存在
|
||||||
|
func (s *SQLiteStore) ClientExists(id string) (bool, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRow(`SELECT COUNT(*) FROM clients WHERE id = ?`, id).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientRules 获取客户端规则
|
||||||
|
func (s *SQLiteStore) GetClientRules(id string) ([]protocol.ProxyRule, error) {
|
||||||
|
c, err := s.GetClient(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.Rules, nil
|
||||||
|
}
|
||||||
216
internal/server/router/api.go
Normal file
216
internal/server/router/api.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gotunnel/internal/server/db"
|
||||||
|
"github.com/gotunnel/pkg/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientStatus 客户端状态
|
||||||
|
type ClientStatus struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
LastPing string `json:"last_ping,omitempty"`
|
||||||
|
RuleCount int `json:"rule_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerInterface 服务端接口
|
||||||
|
type ServerInterface interface {
|
||||||
|
GetClientStatus(clientID string) (online bool, lastPing string)
|
||||||
|
GetAllClientStatus() map[string]struct {
|
||||||
|
Online bool
|
||||||
|
LastPing string
|
||||||
|
}
|
||||||
|
ReloadConfig() error
|
||||||
|
GetBindAddr() string
|
||||||
|
GetBindPort() int
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppInterface 应用接口
|
||||||
|
type AppInterface interface {
|
||||||
|
GetClientStore() db.ClientStore
|
||||||
|
GetServer() ServerInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIHandler API处理器
|
||||||
|
type APIHandler struct {
|
||||||
|
clientStore db.ClientStore
|
||||||
|
server ServerInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes 注册所有 API 路由
|
||||||
|
func RegisterRoutes(r *Router, app AppInterface) {
|
||||||
|
h := &APIHandler{
|
||||||
|
clientStore: app.GetClientStore(),
|
||||||
|
server: app.GetServer(),
|
||||||
|
}
|
||||||
|
|
||||||
|
api := r.Group("/api")
|
||||||
|
api.HandleFunc("/status", h.handleStatus)
|
||||||
|
api.HandleFunc("/clients", h.handleClients)
|
||||||
|
api.HandleFunc("/client/", h.handleClient)
|
||||||
|
api.HandleFunc("/config/reload", h.handleReload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) handleStatus(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clients, _ := h.clientStore.GetAllClients()
|
||||||
|
status := map[string]interface{}{
|
||||||
|
"server": map[string]interface{}{
|
||||||
|
"bind_addr": h.server.GetBindAddr(),
|
||||||
|
"bind_port": h.server.GetBindPort(),
|
||||||
|
},
|
||||||
|
"client_count": len(clients),
|
||||||
|
}
|
||||||
|
h.jsonResponse(rw, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) handleClients(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
h.getClients(rw)
|
||||||
|
case http.MethodPost:
|
||||||
|
h.addClient(rw, r)
|
||||||
|
default:
|
||||||
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) getClients(rw http.ResponseWriter) {
|
||||||
|
clients, err := h.clientStore.GetAllClients()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
statusMap := h.server.GetAllClientStatus()
|
||||||
|
var result []ClientStatus
|
||||||
|
for _, c := range clients {
|
||||||
|
cs := ClientStatus{ID: c.ID, RuleCount: len(c.Rules)}
|
||||||
|
if s, ok := statusMap[c.ID]; ok {
|
||||||
|
cs.Online = s.Online
|
||||||
|
cs.LastPing = s.LastPing
|
||||||
|
}
|
||||||
|
result = append(result, cs)
|
||||||
|
}
|
||||||
|
h.jsonResponse(rw, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) addClient(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Rules []protocol.ProxyRule `json:"rules"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ID == "" {
|
||||||
|
http.Error(rw, "client id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, _ := h.clientStore.ClientExists(req.ID)
|
||||||
|
if exists {
|
||||||
|
http.Error(rw, "client already exists", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &db.Client{ID: req.ID, Rules: req.Rules}
|
||||||
|
if err := h.clientStore.CreateClient(client); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.jsonResponse(rw, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) handleClient(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
clientID := r.URL.Path[len("/api/client/"):]
|
||||||
|
if clientID == "" {
|
||||||
|
http.Error(rw, "client id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
h.getClient(rw, clientID)
|
||||||
|
case http.MethodPut:
|
||||||
|
h.updateClient(rw, r, clientID)
|
||||||
|
case http.MethodDelete:
|
||||||
|
h.deleteClient(rw, clientID)
|
||||||
|
default:
|
||||||
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) getClient(rw http.ResponseWriter, clientID string) {
|
||||||
|
client, err := h.clientStore.GetClient(clientID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "client not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
online, lastPing := h.server.GetClientStatus(clientID)
|
||||||
|
h.jsonResponse(rw, map[string]interface{}{
|
||||||
|
"id": client.ID, "rules": client.Rules,
|
||||||
|
"online": online, "last_ping": lastPing,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) updateClient(rw http.ResponseWriter, r *http.Request, clientID string) {
|
||||||
|
var req struct {
|
||||||
|
Rules []protocol.ProxyRule `json:"rules"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, _ := h.clientStore.ClientExists(clientID)
|
||||||
|
if !exists {
|
||||||
|
http.Error(rw, "client not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &db.Client{ID: clientID, Rules: req.Rules}
|
||||||
|
if err := h.clientStore.UpdateClient(client); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.jsonResponse(rw, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) deleteClient(rw http.ResponseWriter, clientID string) {
|
||||||
|
exists, _ := h.clientStore.ClientExists(clientID)
|
||||||
|
if !exists {
|
||||||
|
http.Error(rw, "client not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.clientStore.DeleteClient(clientID); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.jsonResponse(rw, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) handleReload(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.server.ReloadConfig(); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.jsonResponse(rw, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) jsonResponse(rw http.ResponseWriter, data interface{}) {
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(rw).Encode(data)
|
||||||
|
}
|
||||||
51
internal/server/router/router.go
Normal file
51
internal/server/router/router.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Router 路由管理器
|
||||||
|
type Router struct {
|
||||||
|
mux *http.ServeMux
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 创建路由管理器
|
||||||
|
func New() *Router {
|
||||||
|
return &Router{
|
||||||
|
mux: http.NewServeMux(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 注册路由处理器
|
||||||
|
func (r *Router) Handle(pattern string, handler http.Handler) {
|
||||||
|
r.mux.Handle(pattern, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleFunc 注册路由处理函数
|
||||||
|
func (r *Router) HandleFunc(pattern string, handler http.HandlerFunc) {
|
||||||
|
r.mux.HandleFunc(pattern, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group 创建路由组
|
||||||
|
func (r *Router) Group(prefix string) *RouteGroup {
|
||||||
|
return &RouteGroup{
|
||||||
|
router: r,
|
||||||
|
prefix: prefix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteGroup 路由组
|
||||||
|
type RouteGroup struct {
|
||||||
|
router *Router
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleFunc 注册路由组处理函数
|
||||||
|
func (g *RouteGroup) HandleFunc(pattern string, handler http.HandlerFunc) {
|
||||||
|
g.router.mux.HandleFunc(g.prefix+pattern, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler 返回 http.Handler
|
||||||
|
func (r *Router) Handler() http.Handler {
|
||||||
|
return r.mux
|
||||||
|
}
|
||||||
@@ -7,15 +7,21 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gotunnel/pkg/config"
|
"github.com/gotunnel/internal/server/db"
|
||||||
"github.com/gotunnel/pkg/protocol"
|
"github.com/gotunnel/pkg/protocol"
|
||||||
|
"github.com/gotunnel/pkg/relay"
|
||||||
"github.com/gotunnel/pkg/utils"
|
"github.com/gotunnel/pkg/utils"
|
||||||
"github.com/hashicorp/yamux"
|
"github.com/hashicorp/yamux"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server 隧道服务端
|
// Server 隧道服务端
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config *config.ServerConfig
|
clientStore db.ClientStore
|
||||||
|
bindAddr string
|
||||||
|
bindPort int
|
||||||
|
token string
|
||||||
|
heartbeat int
|
||||||
|
hbTimeout int
|
||||||
portManager *utils.PortManager
|
portManager *utils.PortManager
|
||||||
clients map[string]*ClientSession
|
clients map[string]*ClientSession
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -32,9 +38,14 @@ type ClientSession struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewServer 创建服务端
|
// NewServer 创建服务端
|
||||||
func NewServer(cfg *config.ServerConfig) *Server {
|
func NewServer(cs db.ClientStore, bindAddr string, bindPort int, token string, heartbeat, hbTimeout int) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
config: cfg,
|
clientStore: cs,
|
||||||
|
bindAddr: bindAddr,
|
||||||
|
bindPort: bindPort,
|
||||||
|
token: token,
|
||||||
|
heartbeat: heartbeat,
|
||||||
|
hbTimeout: hbTimeout,
|
||||||
portManager: utils.NewPortManager(),
|
portManager: utils.NewPortManager(),
|
||||||
clients: make(map[string]*ClientSession),
|
clients: make(map[string]*ClientSession),
|
||||||
}
|
}
|
||||||
@@ -42,7 +53,7 @@ func NewServer(cfg *config.ServerConfig) *Server {
|
|||||||
|
|
||||||
// Run 启动服务端
|
// Run 启动服务端
|
||||||
func (s *Server) Run() error {
|
func (s *Server) Run() error {
|
||||||
addr := fmt.Sprintf("%s:%d", s.config.Server.BindAddr, s.config.Server.BindPort)
|
addr := fmt.Sprintf("%s:%d", s.bindAddr, s.bindPort)
|
||||||
ln, err := net.Listen("tcp", addr)
|
ln, err := net.Listen("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to listen on %s: %v", addr, err)
|
return fmt.Errorf("failed to listen on %s: %v", addr, err)
|
||||||
@@ -65,10 +76,8 @@ func (s *Server) Run() error {
|
|||||||
func (s *Server) handleConnection(conn net.Conn) {
|
func (s *Server) handleConnection(conn net.Conn) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
// 设置认证超时
|
|
||||||
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
|
||||||
// 读取认证消息
|
|
||||||
msg, err := protocol.ReadMessage(conn)
|
msg, err := protocol.ReadMessage(conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[Server] Read auth error: %v", err)
|
log.Printf("[Server] Read auth error: %v", err)
|
||||||
@@ -86,15 +95,13 @@ func (s *Server) handleConnection(conn net.Conn) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证 Token
|
if authReq.Token != s.token {
|
||||||
if authReq.Token != s.config.Server.Token {
|
|
||||||
s.sendAuthResponse(conn, false, "invalid token")
|
s.sendAuthResponse(conn, false, "invalid token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取客户端配置
|
rules, err := s.clientStore.GetClientRules(authReq.ClientID)
|
||||||
rules := s.config.GetClientRules(authReq.ClientID)
|
if err != nil || rules == nil {
|
||||||
if rules == nil {
|
|
||||||
s.sendAuthResponse(conn, false, "client not configured")
|
s.sendAuthResponse(conn, false, "client not configured")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -172,7 +179,6 @@ func (s *Server) unregisterClient(cs *ClientSession) {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
// 关闭所有监听器
|
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
for port, ln := range cs.Listeners {
|
for port, ln := range cs.Listeners {
|
||||||
ln.Close()
|
ln.Close()
|
||||||
@@ -224,7 +230,6 @@ func (s *Server) acceptProxyConns(cs *ClientSession, ln net.Listener, rule proto
|
|||||||
func (s *Server) handleProxyConn(cs *ClientSession, conn net.Conn, rule protocol.ProxyRule) {
|
func (s *Server) handleProxyConn(cs *ClientSession, conn net.Conn, rule protocol.ProxyRule) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
// 打开到客户端的流
|
|
||||||
stream, err := cs.Session.Open()
|
stream, err := cs.Session.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[Server] Open stream error: %v", err)
|
log.Printf("[Server] Open stream error: %v", err)
|
||||||
@@ -232,47 +237,21 @@ func (s *Server) handleProxyConn(cs *ClientSession, conn net.Conn, rule protocol
|
|||||||
}
|
}
|
||||||
defer stream.Close()
|
defer stream.Close()
|
||||||
|
|
||||||
// 发送新代理请求
|
|
||||||
req := protocol.NewProxyRequest{RemotePort: rule.RemotePort}
|
req := protocol.NewProxyRequest{RemotePort: rule.RemotePort}
|
||||||
msg, _ := protocol.NewMessage(protocol.MsgTypeNewProxy, req)
|
msg, _ := protocol.NewMessage(protocol.MsgTypeNewProxy, req)
|
||||||
if err := protocol.WriteMessage(stream, msg); err != nil {
|
if err := protocol.WriteMessage(stream, msg); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 双向转发
|
relay.Relay(conn, stream)
|
||||||
relay(conn, stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
// relay 双向数据转发
|
|
||||||
func relay(c1, c2 net.Conn) {
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(2)
|
|
||||||
|
|
||||||
copy := func(dst, src net.Conn) {
|
|
||||||
defer wg.Done()
|
|
||||||
buf := make([]byte, 32*1024)
|
|
||||||
for {
|
|
||||||
n, err := src.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
dst.Write(buf[:n])
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
go copy(c1, c2)
|
|
||||||
go copy(c2, c1)
|
|
||||||
wg.Wait()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// heartbeatLoop 心跳检测循环
|
// heartbeatLoop 心跳检测循环
|
||||||
func (s *Server) heartbeatLoop(cs *ClientSession) {
|
func (s *Server) heartbeatLoop(cs *ClientSession) {
|
||||||
ticker := time.NewTicker(time.Duration(s.config.Server.HeartbeatSec) * time.Second)
|
ticker := time.NewTicker(time.Duration(s.heartbeat) * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
timeout := time.Duration(s.config.Server.HeartbeatTimeout) * time.Second
|
timeout := time.Duration(s.hbTimeout) * time.Second
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -286,7 +265,6 @@ func (s *Server) heartbeatLoop(cs *ClientSession) {
|
|||||||
}
|
}
|
||||||
cs.mu.Unlock()
|
cs.mu.Unlock()
|
||||||
|
|
||||||
// 发送心跳
|
|
||||||
stream, err := cs.Session.Open()
|
stream, err := cs.Session.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -343,20 +321,15 @@ func (s *Server) GetAllClientStatus() map[string]struct {
|
|||||||
|
|
||||||
// ReloadConfig 重新加载配置
|
// ReloadConfig 重新加载配置
|
||||||
func (s *Server) ReloadConfig() error {
|
func (s *Server) ReloadConfig() error {
|
||||||
// 目前仅返回nil,后续可实现热重载
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetConfig 设置配置
|
// GetBindAddr 获取绑定地址
|
||||||
func (s *Server) SetConfig(cfg *config.ServerConfig) {
|
func (s *Server) GetBindAddr() string {
|
||||||
s.mu.Lock()
|
return s.bindAddr
|
||||||
defer s.mu.Unlock()
|
|
||||||
s.config = cfg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConfig 获取配置
|
// GetBindPort 获取绑定端口
|
||||||
func (s *Server) GetConfig() *config.ServerConfig {
|
func (s *Server) GetBindPort() int {
|
||||||
s.mu.RLock()
|
return s.bindPort
|
||||||
defer s.mu.RUnlock()
|
|
||||||
return s.config
|
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,6 @@ type ErrorMessage struct {
|
|||||||
|
|
||||||
// WriteMessage 写入消息到 writer
|
// WriteMessage 写入消息到 writer
|
||||||
func WriteMessage(w io.Writer, msg *Message) error {
|
func WriteMessage(w io.Writer, msg *Message) error {
|
||||||
// 消息格式: [1字节类型][4字节长度][payload]
|
|
||||||
header := make([]byte, 5)
|
header := make([]byte, 5)
|
||||||
header[0] = msg.Type
|
header[0] = msg.Type
|
||||||
binary.BigEndian.PutUint32(header[1:], uint32(len(msg.Payload)))
|
binary.BigEndian.PutUint32(header[1:], uint32(len(msg.Payload)))
|
||||||
@@ -89,7 +88,7 @@ func ReadMessage(r io.Reader) (*Message, error) {
|
|||||||
msgType := header[0]
|
msgType := header[0]
|
||||||
length := binary.BigEndian.Uint32(header[1:])
|
length := binary.BigEndian.Uint32(header[1:])
|
||||||
|
|
||||||
if length > 1024*1024 { // 最大 1MB
|
if length > 1024*1024 {
|
||||||
return nil, errors.New("message too large")
|
return nil, errors.New("message too large")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
pkg/relay/relay.go
Normal file
30
pkg/relay/relay.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package relay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Relay 双向数据转发
|
||||||
|
func Relay(c1, c2 net.Conn) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
copy := func(dst, src net.Conn) {
|
||||||
|
defer wg.Done()
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
for {
|
||||||
|
n, err := src.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
dst.Write(buf[:n])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go copy(c1, c2)
|
||||||
|
go copy(c2, c1)
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
@@ -1,322 +0,0 @@
|
|||||||
package webserver
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/gotunnel/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed dist/*
|
|
||||||
var staticFiles embed.FS
|
|
||||||
|
|
||||||
// spaHandler SPA路由处理器
|
|
||||||
type spaHandler struct {
|
|
||||||
fs http.FileSystem
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
path := r.URL.Path
|
|
||||||
f, err := h.fs.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
f, err = h.fs.Open("index.html")
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
stat, _ := f.Stat()
|
|
||||||
if stat.IsDir() {
|
|
||||||
f, err = h.fs.Open(path + "/index.html")
|
|
||||||
if err != nil {
|
|
||||||
f, _ = h.fs.Open("index.html")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.ServeContent(w, r, path, stat.ModTime(), f.(io.ReadSeeker))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClientStatus 客户端状态
|
|
||||||
type ClientStatus struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Online bool `json:"online"`
|
|
||||||
LastPing string `json:"last_ping,omitempty"`
|
|
||||||
RuleCount int `json:"rule_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServerInterface 服务端接口
|
|
||||||
type ServerInterface interface {
|
|
||||||
GetClientStatus(clientID string) (online bool, lastPing string)
|
|
||||||
GetAllClientStatus() map[string]struct {
|
|
||||||
Online bool
|
|
||||||
LastPing string
|
|
||||||
}
|
|
||||||
ReloadConfig() error
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebServer Web控制台服务
|
|
||||||
type WebServer struct {
|
|
||||||
config *config.ServerConfig
|
|
||||||
configPath string
|
|
||||||
server ServerInterface
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWebServer 创建Web服务
|
|
||||||
func NewWebServer(cfg *config.ServerConfig, configPath string, srv ServerInterface) *WebServer {
|
|
||||||
return &WebServer{
|
|
||||||
config: cfg,
|
|
||||||
configPath: configPath,
|
|
||||||
server: srv,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run 启动Web服务
|
|
||||||
func (w *WebServer) Run(addr string) error {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
|
|
||||||
mux.HandleFunc("/api/status", w.handleStatus)
|
|
||||||
mux.HandleFunc("/api/clients", w.handleClients)
|
|
||||||
mux.HandleFunc("/api/client/", w.handleClient)
|
|
||||||
mux.HandleFunc("/api/config/reload", w.handleReload)
|
|
||||||
|
|
||||||
staticFS, err := fs.Sub(staticFiles, "dist")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
mux.Handle("/", spaHandler{fs: http.FS(staticFS)})
|
|
||||||
|
|
||||||
log.Printf("[Web] Console listening on %s", addr)
|
|
||||||
return http.ListenAndServe(addr, mux)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStatus 获取服务状态
|
|
||||||
func (w *WebServer) handleStatus(rw http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.mu.RLock()
|
|
||||||
defer w.mu.RUnlock()
|
|
||||||
|
|
||||||
status := map[string]interface{}{
|
|
||||||
"server": map[string]interface{}{
|
|
||||||
"bind_addr": w.config.Server.BindAddr,
|
|
||||||
"bind_port": w.config.Server.BindPort,
|
|
||||||
},
|
|
||||||
"client_count": len(w.config.Clients),
|
|
||||||
}
|
|
||||||
w.jsonResponse(rw, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleClients 获取所有客户端
|
|
||||||
func (w *WebServer) handleClients(rw http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
w.getClients(rw)
|
|
||||||
case http.MethodPost:
|
|
||||||
w.addClient(rw, r)
|
|
||||||
default:
|
|
||||||
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebServer) getClients(rw http.ResponseWriter) {
|
|
||||||
w.mu.RLock()
|
|
||||||
defer w.mu.RUnlock()
|
|
||||||
|
|
||||||
var clients []ClientStatus
|
|
||||||
statusMap := w.server.GetAllClientStatus()
|
|
||||||
|
|
||||||
for _, c := range w.config.Clients {
|
|
||||||
cs := ClientStatus{ID: c.ID, RuleCount: len(c.Rules)}
|
|
||||||
if s, ok := statusMap[c.ID]; ok {
|
|
||||||
cs.Online = s.Online
|
|
||||||
cs.LastPing = s.LastPing
|
|
||||||
}
|
|
||||||
clients = append(clients, cs)
|
|
||||||
}
|
|
||||||
w.jsonResponse(rw, clients)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebServer) addClient(rw http.ResponseWriter, r *http.Request) {
|
|
||||||
var client config.ClientConfig
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&client); err != nil {
|
|
||||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if client.ID == "" {
|
|
||||||
http.Error(rw, "client id required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.mu.Lock()
|
|
||||||
for _, c := range w.config.Clients {
|
|
||||||
if c.ID == client.ID {
|
|
||||||
w.mu.Unlock()
|
|
||||||
http.Error(rw, "client already exists", http.StatusConflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.config.Clients = append(w.config.Clients, client)
|
|
||||||
w.mu.Unlock()
|
|
||||||
|
|
||||||
if err := w.saveConfig(); err != nil {
|
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.jsonResponse(rw, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebServer) handleClient(rw http.ResponseWriter, r *http.Request) {
|
|
||||||
clientID := r.URL.Path[len("/api/client/"):]
|
|
||||||
if clientID == "" {
|
|
||||||
http.Error(rw, "client id required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
w.getClient(rw, clientID)
|
|
||||||
case http.MethodPut:
|
|
||||||
w.updateClient(rw, r, clientID)
|
|
||||||
case http.MethodDelete:
|
|
||||||
w.deleteClient(rw, clientID)
|
|
||||||
default:
|
|
||||||
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebServer) getClient(rw http.ResponseWriter, clientID string) {
|
|
||||||
w.mu.RLock()
|
|
||||||
defer w.mu.RUnlock()
|
|
||||||
|
|
||||||
for _, c := range w.config.Clients {
|
|
||||||
if c.ID == clientID {
|
|
||||||
online, lastPing := w.server.GetClientStatus(clientID)
|
|
||||||
w.jsonResponse(rw, map[string]interface{}{
|
|
||||||
"id": c.ID, "rules": c.Rules,
|
|
||||||
"online": online, "last_ping": lastPing,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.Error(rw, "client not found", http.StatusNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebServer) updateClient(rw http.ResponseWriter, r *http.Request, clientID string) {
|
|
||||||
var client config.ClientConfig
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&client); err != nil {
|
|
||||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.mu.Lock()
|
|
||||||
found := false
|
|
||||||
for i, c := range w.config.Clients {
|
|
||||||
if c.ID == clientID {
|
|
||||||
client.ID = clientID
|
|
||||||
w.config.Clients[i] = client
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.mu.Unlock()
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
http.Error(rw, "client not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := w.saveConfig(); err != nil {
|
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.jsonResponse(rw, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebServer) deleteClient(rw http.ResponseWriter, clientID string) {
|
|
||||||
w.mu.Lock()
|
|
||||||
found := false
|
|
||||||
for i, c := range w.config.Clients {
|
|
||||||
if c.ID == clientID {
|
|
||||||
w.config.Clients = append(w.config.Clients[:i], w.config.Clients[i+1:]...)
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.mu.Unlock()
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
http.Error(rw, "client not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := w.saveConfig(); err != nil {
|
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.jsonResponse(rw, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebServer) handleReload(rw http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := w.server.ReloadConfig(); err != nil {
|
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.jsonResponse(rw, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebServer) saveConfig() error {
|
|
||||||
w.mu.RLock()
|
|
||||||
defer w.mu.RUnlock()
|
|
||||||
return config.SaveServerConfig(w.configPath, w.config)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebServer) jsonResponse(rw http.ResponseWriter, data interface{}) {
|
|
||||||
rw.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(rw).Encode(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunWithAuth 启动带认证的Web服务
|
|
||||||
func (w *WebServer) RunWithAuth(addr, username, password string) error {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
|
|
||||||
mux.HandleFunc("/api/status", w.handleStatus)
|
|
||||||
mux.HandleFunc("/api/clients", w.handleClients)
|
|
||||||
mux.HandleFunc("/api/client/", w.handleClient)
|
|
||||||
mux.HandleFunc("/api/config/reload", w.handleReload)
|
|
||||||
|
|
||||||
staticFS, err := fs.Sub(staticFiles, "dist")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load static files: %v", err)
|
|
||||||
}
|
|
||||||
mux.Handle("/", spaHandler{fs: http.FS(staticFS)})
|
|
||||||
|
|
||||||
handler := &authMiddleware{username, password, mux}
|
|
||||||
log.Printf("[Web] Console listening on %s (auth enabled)", addr)
|
|
||||||
return http.ListenAndServe(addr, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
type authMiddleware struct {
|
|
||||||
username, password string
|
|
||||||
handler http.Handler
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *authMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user, pass, ok := r.BasicAuth()
|
|
||||||
if !ok || user != a.username || pass != a.password {
|
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="GoTunnel"`)
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.handler.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user