server use sqlite
All checks were successful
Build Multi-Platform Binaries / build (push) Successful in 11m35s

This commit is contained in:
Flik
2025-12-25 19:48:11 +08:00
parent 790d3b682a
commit 7100362cd7
23 changed files with 688 additions and 421 deletions

View File

@@ -4,7 +4,7 @@ import (
"flag"
"log"
"github.com/gotunnel/pkg/tunnel"
"github.com/gotunnel/internal/client/tunnel"
)
func main() {

View File

@@ -5,25 +5,42 @@ import (
"fmt"
"log"
"github.com/gotunnel/pkg/config"
"github.com/gotunnel/pkg/tunnel"
"github.com/gotunnel/pkg/webserver"
"github.com/gotunnel/internal/server/app"
"github.com/gotunnel/internal/server/config"
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/internal/server/tunnel"
)
func main() {
configPath := flag.String("c", "server.yaml", "config file path")
flag.Parse()
// 加载 YAML 配置
cfg, err := config.LoadServerConfig(*configPath)
if err != nil {
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 控制台
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)
go func() {

2
go.mod
View File

@@ -7,3 +7,5 @@ require (
github.com/hashicorp/yamux v0.1.1
gopkg.in/yaml.v3 v3.0.1
)
require github.com/mattn/go-sqlite3 v1.14.32 // indirect

2
go.sum
View File

@@ -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/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/gotunnel/pkg/protocol"
"github.com/gotunnel/pkg/relay"
"github.com/google/uuid"
"github.com/hashicorp/yamux"
)
@@ -57,7 +58,6 @@ func (c *Client) connect() error {
return err
}
// 发送认证
authReq := protocol.AuthRequest{ClientID: c.ID, Token: c.Token}
msg, _ := protocol.NewMessage(protocol.MsgTypeAuth, authReq)
if err := protocol.WriteMessage(conn, msg); err != nil {
@@ -65,7 +65,6 @@ func (c *Client) connect() error {
return err
}
// 读取响应
resp, err := protocol.ReadMessage(conn)
if err != nil {
conn.Close()
@@ -81,7 +80,6 @@ func (c *Client) connect() error {
log.Printf("[Client] Authenticated as %s", c.ID)
// 建立 Yamux 会话
session, err := yamux.Client(conn, nil)
if err != nil {
conn.Close()
@@ -147,7 +145,6 @@ func (c *Client) handleNewProxy(stream net.Conn, msg *protocol.Message) {
var req protocol.NewProxyRequest
msg.ParsePayload(&req)
// 查找对应规则
var rule *protocol.ProxyRule
c.mu.RLock()
for _, r := range c.rules {
@@ -163,7 +160,6 @@ func (c *Client) handleNewProxy(stream net.Conn, msg *protocol.Message) {
return
}
// 连接本地服务
localAddr := fmt.Sprintf("%s:%d", rule.LocalIP, rule.LocalPort)
localConn, err := net.DialTimeout("tcp", localAddr, 5*time.Second)
if err != nil {
@@ -171,8 +167,7 @@ func (c *Client) handleNewProxy(stream net.Conn, msg *protocol.Message) {
return
}
// 双向转发
relay(stream, localConn)
relay.Relay(stream, localConn)
}
// handleHeartbeat 处理心跳

112
internal/server/app/app.go Normal file
View 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
}

View 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};

View 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}

View 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};

View 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}

File diff suppressed because one or more lines are too long

View 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
View 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
View 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

View File

@@ -3,15 +3,13 @@ package config
import (
"os"
"github.com/gotunnel/pkg/protocol"
"gopkg.in/yaml.v3"
)
// ServerConfig 服务端配置
type ServerConfig struct {
Server ServerSettings `yaml:"server"`
Web WebSettings `yaml:"web"`
Clients []ClientConfig `yaml:"clients"`
Server ServerSettings `yaml:"server"`
Web WebSettings `yaml:"web"`
}
// ServerSettings 服务端设置
@@ -21,6 +19,7 @@ type ServerSettings struct {
Token string `yaml:"token"`
HeartbeatSec int `yaml:"heartbeat_sec"`
HeartbeatTimeout int `yaml:"heartbeat_timeout"`
DBPath string `yaml:"db_path"`
}
// WebSettings Web控制台设置
@@ -32,12 +31,6 @@ type WebSettings struct {
Password string `yaml:"password"`
}
// ClientConfig 客户端配置(服务端维护)
type ClientConfig struct {
ID string `yaml:"id"`
Rules []protocol.ProxyRule `yaml:"rules"`
}
// LoadServerConfig 加载服务端配置
func LoadServerConfig(path string) (*ServerConfig, error) {
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 {
cfg.Server.HeartbeatSec = 30
}
if cfg.Server.HeartbeatTimeout == 0 {
cfg.Server.HeartbeatTimeout = 90
}
if cfg.Server.DBPath == "" {
cfg.Server.DBPath = "gotunnel.db"
}
// Web 默认值
if cfg.Web.BindAddr == "" {
@@ -68,22 +70,3 @@ func LoadServerConfig(path string) (*ServerConfig, error) {
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)
}

View 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
}

View 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
}

View 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)
}

View 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
}

View File

@@ -7,15 +7,21 @@ import (
"sync"
"time"
"github.com/gotunnel/pkg/config"
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/pkg/protocol"
"github.com/gotunnel/pkg/relay"
"github.com/gotunnel/pkg/utils"
"github.com/hashicorp/yamux"
)
// Server 隧道服务端
type Server struct {
config *config.ServerConfig
clientStore db.ClientStore
bindAddr string
bindPort int
token string
heartbeat int
hbTimeout int
portManager *utils.PortManager
clients map[string]*ClientSession
mu sync.RWMutex
@@ -32,9 +38,14 @@ type ClientSession struct {
}
// NewServer 创建服务端
func NewServer(cfg *config.ServerConfig) *Server {
func NewServer(cs db.ClientStore, bindAddr string, bindPort int, token string, heartbeat, hbTimeout int) *Server {
return &Server{
config: cfg,
clientStore: cs,
bindAddr: bindAddr,
bindPort: bindPort,
token: token,
heartbeat: heartbeat,
hbTimeout: hbTimeout,
portManager: utils.NewPortManager(),
clients: make(map[string]*ClientSession),
}
@@ -42,7 +53,7 @@ func NewServer(cfg *config.ServerConfig) *Server {
// Run 启动服务端
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)
if err != nil {
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) {
defer conn.Close()
// 设置认证超时
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
// 读取认证消息
msg, err := protocol.ReadMessage(conn)
if err != nil {
log.Printf("[Server] Read auth error: %v", err)
@@ -86,15 +95,13 @@ func (s *Server) handleConnection(conn net.Conn) {
return
}
// 验证 Token
if authReq.Token != s.config.Server.Token {
if authReq.Token != s.token {
s.sendAuthResponse(conn, false, "invalid token")
return
}
// 获取客户端配置
rules := s.config.GetClientRules(authReq.ClientID)
if rules == nil {
rules, err := s.clientStore.GetClientRules(authReq.ClientID)
if err != nil || rules == nil {
s.sendAuthResponse(conn, false, "client not configured")
return
}
@@ -172,7 +179,6 @@ func (s *Server) unregisterClient(cs *ClientSession) {
s.mu.Lock()
defer s.mu.Unlock()
// 关闭所有监听器
cs.mu.Lock()
for port, ln := range cs.Listeners {
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) {
defer conn.Close()
// 打开到客户端的流
stream, err := cs.Session.Open()
if err != nil {
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()
// 发送新代理请求
req := protocol.NewProxyRequest{RemotePort: rule.RemotePort}
msg, _ := protocol.NewMessage(protocol.MsgTypeNewProxy, req)
if err := protocol.WriteMessage(stream, msg); err != nil {
return
}
// 双向转发
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()
relay.Relay(conn, stream)
}
// heartbeatLoop 心跳检测循环
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()
timeout := time.Duration(s.config.Server.HeartbeatTimeout) * time.Second
timeout := time.Duration(s.hbTimeout) * time.Second
for {
select {
@@ -286,7 +265,6 @@ func (s *Server) heartbeatLoop(cs *ClientSession) {
}
cs.mu.Unlock()
// 发送心跳
stream, err := cs.Session.Open()
if err != nil {
return
@@ -343,20 +321,15 @@ func (s *Server) GetAllClientStatus() map[string]struct {
// ReloadConfig 重新加载配置
func (s *Server) ReloadConfig() error {
// 目前仅返回nil后续可实现热重载
return nil
}
// SetConfig 设置配置
func (s *Server) SetConfig(cfg *config.ServerConfig) {
s.mu.Lock()
defer s.mu.Unlock()
s.config = cfg
// GetBindAddr 获取绑定地址
func (s *Server) GetBindAddr() string {
return s.bindAddr
}
// GetConfig 获取配置
func (s *Server) GetConfig() *config.ServerConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return s.config
// GetBindPort 获取绑定端口
func (s *Server) GetBindPort() int {
return s.bindPort
}

View File

@@ -63,7 +63,6 @@ type ErrorMessage struct {
// WriteMessage 写入消息到 writer
func WriteMessage(w io.Writer, msg *Message) error {
// 消息格式: [1字节类型][4字节长度][payload]
header := make([]byte, 5)
header[0] = msg.Type
binary.BigEndian.PutUint32(header[1:], uint32(len(msg.Payload)))
@@ -89,7 +88,7 @@ func ReadMessage(r io.Reader) (*Message, error) {
msgType := header[0]
length := binary.BigEndian.Uint32(header[1:])
if length > 1024*1024 { // 最大 1MB
if length > 1024*1024 {
return nil, errors.New("message too large")
}

30
pkg/relay/relay.go Normal file
View 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()
}

View File

@@ -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)
}