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:
177
internal/client/tunnel/client.go
Normal file
177
internal/client/tunnel/client.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package tunnel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gotunnel/pkg/protocol"
|
||||
"github.com/gotunnel/pkg/relay"
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/yamux"
|
||||
)
|
||||
|
||||
// Client 隧道客户端
|
||||
type Client struct {
|
||||
ServerAddr string
|
||||
Token string
|
||||
ID string
|
||||
session *yamux.Session
|
||||
rules []protocol.ProxyRule
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewClient 创建客户端
|
||||
func NewClient(serverAddr, token, id string) *Client {
|
||||
if id == "" {
|
||||
id = uuid.New().String()[:8]
|
||||
}
|
||||
return &Client{
|
||||
ServerAddr: serverAddr,
|
||||
Token: token,
|
||||
ID: id,
|
||||
}
|
||||
}
|
||||
|
||||
// Run 启动客户端(带断线重连)
|
||||
func (c *Client) Run() error {
|
||||
for {
|
||||
if err := c.connect(); err != nil {
|
||||
log.Printf("[Client] Connect error: %v", err)
|
||||
log.Printf("[Client] Reconnecting in 5s...")
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
c.handleSession()
|
||||
log.Printf("[Client] Disconnected, reconnecting...")
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// connect 连接到服务端并认证
|
||||
func (c *Client) connect() error {
|
||||
conn, err := net.DialTimeout("tcp", c.ServerAddr, 10*time.Second)
|
||||
if err != nil {
|
||||
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 {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := protocol.ReadMessage(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
var authResp protocol.AuthResponse
|
||||
resp.ParsePayload(&authResp)
|
||||
if !authResp.Success {
|
||||
conn.Close()
|
||||
return fmt.Errorf("auth failed: %s", authResp.Message)
|
||||
}
|
||||
|
||||
log.Printf("[Client] Authenticated as %s", c.ID)
|
||||
|
||||
session, err := yamux.Client(conn, nil)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.session = session
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleSession 处理会话
|
||||
func (c *Client) handleSession() {
|
||||
defer c.session.Close()
|
||||
|
||||
for {
|
||||
stream, err := c.session.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go c.handleStream(stream)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStream 处理流
|
||||
func (c *Client) handleStream(stream net.Conn) {
|
||||
defer stream.Close()
|
||||
|
||||
msg, err := protocol.ReadMessage(stream)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case protocol.MsgTypeProxyConfig:
|
||||
c.handleProxyConfig(msg)
|
||||
case protocol.MsgTypeNewProxy:
|
||||
c.handleNewProxy(stream, msg)
|
||||
case protocol.MsgTypeHeartbeat:
|
||||
c.handleHeartbeat(stream)
|
||||
}
|
||||
}
|
||||
|
||||
// handleProxyConfig 处理代理配置
|
||||
func (c *Client) handleProxyConfig(msg *protocol.Message) {
|
||||
var cfg protocol.ProxyConfig
|
||||
msg.ParsePayload(&cfg)
|
||||
|
||||
c.mu.Lock()
|
||||
c.rules = cfg.Rules
|
||||
c.mu.Unlock()
|
||||
|
||||
log.Printf("[Client] Received %d proxy rules", len(cfg.Rules))
|
||||
for _, r := range cfg.Rules {
|
||||
log.Printf("[Client] %s: %s:%d", r.Name, r.LocalIP, r.LocalPort)
|
||||
}
|
||||
}
|
||||
|
||||
// handleNewProxy 处理新代理请求
|
||||
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 {
|
||||
if r.RemotePort == req.RemotePort {
|
||||
rule = &r
|
||||
break
|
||||
}
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
if rule == nil {
|
||||
log.Printf("[Client] Unknown port %d", req.RemotePort)
|
||||
return
|
||||
}
|
||||
|
||||
localAddr := fmt.Sprintf("%s:%d", rule.LocalIP, rule.LocalPort)
|
||||
localConn, err := net.DialTimeout("tcp", localAddr, 5*time.Second)
|
||||
if err != nil {
|
||||
log.Printf("[Client] Connect %s error: %v", localAddr, err)
|
||||
return
|
||||
}
|
||||
|
||||
relay.Relay(stream, localConn)
|
||||
}
|
||||
|
||||
// handleHeartbeat 处理心跳
|
||||
func (c *Client) handleHeartbeat(stream net.Conn) {
|
||||
msg := &protocol.Message{Type: protocol.MsgTypeHeartbeatAck}
|
||||
protocol.WriteMessage(stream, msg)
|
||||
}
|
||||
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 |
72
internal/server/config/config.go
Normal file
72
internal/server/config/config.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ServerConfig 服务端配置
|
||||
type ServerConfig struct {
|
||||
Server ServerSettings `yaml:"server"`
|
||||
Web WebSettings `yaml:"web"`
|
||||
}
|
||||
|
||||
// ServerSettings 服务端设置
|
||||
type ServerSettings struct {
|
||||
BindAddr string `yaml:"bind_addr"`
|
||||
BindPort int `yaml:"bind_port"`
|
||||
Token string `yaml:"token"`
|
||||
HeartbeatSec int `yaml:"heartbeat_sec"`
|
||||
HeartbeatTimeout int `yaml:"heartbeat_timeout"`
|
||||
DBPath string `yaml:"db_path"`
|
||||
}
|
||||
|
||||
// WebSettings Web控制台设置
|
||||
type WebSettings struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
BindAddr string `yaml:"bind_addr"`
|
||||
BindPort int `yaml:"bind_port"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
// LoadServerConfig 加载服务端配置
|
||||
func LoadServerConfig(path string) (*ServerConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg ServerConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
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 == "" {
|
||||
cfg.Web.BindAddr = "0.0.0.0"
|
||||
}
|
||||
if cfg.Web.BindPort == 0 {
|
||||
cfg.Web.BindPort = 7500
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
335
internal/server/tunnel/server.go
Normal file
335
internal/server/tunnel/server.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package tunnel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
clientStore db.ClientStore
|
||||
bindAddr string
|
||||
bindPort int
|
||||
token string
|
||||
heartbeat int
|
||||
hbTimeout int
|
||||
portManager *utils.PortManager
|
||||
clients map[string]*ClientSession
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// ClientSession 客户端会话
|
||||
type ClientSession struct {
|
||||
ID string
|
||||
Session *yamux.Session
|
||||
Rules []protocol.ProxyRule
|
||||
Listeners map[int]net.Listener
|
||||
LastPing time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewServer 创建服务端
|
||||
func NewServer(cs db.ClientStore, bindAddr string, bindPort int, token string, heartbeat, hbTimeout int) *Server {
|
||||
return &Server{
|
||||
clientStore: cs,
|
||||
bindAddr: bindAddr,
|
||||
bindPort: bindPort,
|
||||
token: token,
|
||||
heartbeat: heartbeat,
|
||||
hbTimeout: hbTimeout,
|
||||
portManager: utils.NewPortManager(),
|
||||
clients: make(map[string]*ClientSession),
|
||||
}
|
||||
}
|
||||
|
||||
// Run 启动服务端
|
||||
func (s *Server) Run() error {
|
||||
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)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
log.Printf("[Server] Listening on %s", addr)
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Printf("[Server] Accept error: %v", err)
|
||||
continue
|
||||
}
|
||||
go s.handleConnection(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConnection 处理客户端连接
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Type != protocol.MsgTypeAuth {
|
||||
log.Printf("[Server] Expected auth, got %d", msg.Type)
|
||||
return
|
||||
}
|
||||
|
||||
var authReq protocol.AuthRequest
|
||||
if err := msg.ParsePayload(&authReq); err != nil {
|
||||
log.Printf("[Server] Parse auth error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if authReq.Token != s.token {
|
||||
s.sendAuthResponse(conn, false, "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := s.clientStore.GetClientRules(authReq.ClientID)
|
||||
if err != nil || rules == nil {
|
||||
s.sendAuthResponse(conn, false, "client not configured")
|
||||
return
|
||||
}
|
||||
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
|
||||
if err := s.sendAuthResponse(conn, true, "ok"); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Server] Client %s authenticated", authReq.ClientID)
|
||||
s.setupClientSession(conn, authReq.ClientID, rules)
|
||||
}
|
||||
|
||||
// setupClientSession 建立客户端会话
|
||||
func (s *Server) setupClientSession(conn net.Conn, clientID string, rules []protocol.ProxyRule) {
|
||||
session, err := yamux.Server(conn, nil)
|
||||
if err != nil {
|
||||
log.Printf("[Server] Yamux error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
cs := &ClientSession{
|
||||
ID: clientID,
|
||||
Session: session,
|
||||
Rules: rules,
|
||||
Listeners: make(map[int]net.Listener),
|
||||
LastPing: time.Now(),
|
||||
}
|
||||
|
||||
s.registerClient(cs)
|
||||
defer s.unregisterClient(cs)
|
||||
|
||||
if err := s.sendProxyConfig(session, rules); err != nil {
|
||||
log.Printf("[Server] Send config error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.startProxyListeners(cs)
|
||||
go s.heartbeatLoop(cs)
|
||||
|
||||
<-session.CloseChan()
|
||||
log.Printf("[Server] Client %s disconnected", clientID)
|
||||
}
|
||||
|
||||
// sendAuthResponse 发送认证响应
|
||||
func (s *Server) sendAuthResponse(conn net.Conn, success bool, message string) error {
|
||||
resp := protocol.AuthResponse{Success: success, Message: message}
|
||||
msg, _ := protocol.NewMessage(protocol.MsgTypeAuthResp, resp)
|
||||
return protocol.WriteMessage(conn, msg)
|
||||
}
|
||||
|
||||
// sendProxyConfig 发送代理配置
|
||||
func (s *Server) sendProxyConfig(session *yamux.Session, rules []protocol.ProxyRule) error {
|
||||
stream, err := session.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
cfg := protocol.ProxyConfig{Rules: rules}
|
||||
msg, _ := protocol.NewMessage(protocol.MsgTypeProxyConfig, cfg)
|
||||
return protocol.WriteMessage(stream, msg)
|
||||
}
|
||||
|
||||
// registerClient 注册客户端
|
||||
func (s *Server) registerClient(cs *ClientSession) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.clients[cs.ID] = cs
|
||||
}
|
||||
|
||||
// unregisterClient 注销客户端
|
||||
func (s *Server) unregisterClient(cs *ClientSession) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cs.mu.Lock()
|
||||
for port, ln := range cs.Listeners {
|
||||
ln.Close()
|
||||
s.portManager.Release(port)
|
||||
}
|
||||
cs.mu.Unlock()
|
||||
|
||||
delete(s.clients, cs.ID)
|
||||
}
|
||||
|
||||
// startProxyListeners 启动代理监听
|
||||
func (s *Server) startProxyListeners(cs *ClientSession) {
|
||||
for _, rule := range cs.Rules {
|
||||
if err := s.portManager.Reserve(rule.RemotePort, cs.ID); err != nil {
|
||||
log.Printf("[Server] Port %d error: %v", rule.RemotePort, err)
|
||||
continue
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", rule.RemotePort))
|
||||
if err != nil {
|
||||
log.Printf("[Server] Listen %d error: %v", rule.RemotePort, err)
|
||||
s.portManager.Release(rule.RemotePort)
|
||||
continue
|
||||
}
|
||||
|
||||
cs.mu.Lock()
|
||||
cs.Listeners[rule.RemotePort] = ln
|
||||
cs.mu.Unlock()
|
||||
|
||||
log.Printf("[Server] Proxy %s: :%d -> %s:%d",
|
||||
rule.Name, rule.RemotePort, rule.LocalIP, rule.LocalPort)
|
||||
|
||||
go s.acceptProxyConns(cs, ln, rule)
|
||||
}
|
||||
}
|
||||
|
||||
// acceptProxyConns 接受代理连接
|
||||
func (s *Server) acceptProxyConns(cs *ClientSession, ln net.Listener, rule protocol.ProxyRule) {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go s.handleProxyConn(cs, conn, rule)
|
||||
}
|
||||
}
|
||||
|
||||
// handleProxyConn 处理代理连接
|
||||
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)
|
||||
return
|
||||
}
|
||||
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.Relay(conn, stream)
|
||||
}
|
||||
|
||||
// heartbeatLoop 心跳检测循环
|
||||
func (s *Server) heartbeatLoop(cs *ClientSession) {
|
||||
ticker := time.NewTicker(time.Duration(s.heartbeat) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
timeout := time.Duration(s.hbTimeout) * time.Second
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
cs.mu.Lock()
|
||||
if time.Since(cs.LastPing) > timeout {
|
||||
cs.mu.Unlock()
|
||||
log.Printf("[Server] Client %s heartbeat timeout", cs.ID)
|
||||
cs.Session.Close()
|
||||
return
|
||||
}
|
||||
cs.mu.Unlock()
|
||||
|
||||
stream, err := cs.Session.Open()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
msg := &protocol.Message{Type: protocol.MsgTypeHeartbeat}
|
||||
protocol.WriteMessage(stream, msg)
|
||||
stream.Close()
|
||||
|
||||
case <-cs.Session.CloseChan():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetClientStatus 获取客户端状态
|
||||
func (s *Server) GetClientStatus(clientID string) (online bool, lastPing string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if cs, ok := s.clients[clientID]; ok {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
return true, cs.LastPing.Format(time.RFC3339)
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// GetAllClientStatus 获取所有客户端状态
|
||||
func (s *Server) GetAllClientStatus() map[string]struct {
|
||||
Online bool
|
||||
LastPing string
|
||||
} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make(map[string]struct {
|
||||
Online bool
|
||||
LastPing string
|
||||
})
|
||||
|
||||
for id, cs := range s.clients {
|
||||
cs.mu.Lock()
|
||||
result[id] = struct {
|
||||
Online bool
|
||||
LastPing string
|
||||
}{
|
||||
Online: true,
|
||||
LastPing: cs.LastPing.Format(time.RFC3339),
|
||||
}
|
||||
cs.mu.Unlock()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ReloadConfig 重新加载配置
|
||||
func (s *Server) ReloadConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBindAddr 获取绑定地址
|
||||
func (s *Server) GetBindAddr() string {
|
||||
return s.bindAddr
|
||||
}
|
||||
|
||||
// GetBindPort 获取绑定端口
|
||||
func (s *Server) GetBindPort() int {
|
||||
return s.bindPort
|
||||
}
|
||||
Reference in New Issue
Block a user