update
All checks were successful
Build Multi-Platform Binaries / build (push) Successful in 11m54s

This commit is contained in:
Flik
2025-12-26 17:14:54 +08:00
parent 4623a7f031
commit 549f9aaf26
63 changed files with 10266 additions and 740 deletions

View File

@@ -51,11 +51,11 @@ func NewManager(cacheDir string) (*Manager, error) {
// registerBuiltins 注册内置 plugins
// 注意: tcp, udp, http, https 是内置类型,直接在 tunnel 中处理
func (m *Manager) registerBuiltins() error {
// 注册 SOCKS5 plugin
if err := m.registry.RegisterBuiltin(builtin.NewSOCKS5Plugin()); err != nil {
// 使用统一的插件注册入口
if err := m.registry.RegisterAll(builtin.GetAll()); err != nil {
return err
}
log.Println("[Plugin] Builtin plugins registered: socks5")
log.Printf("[Plugin] Registered %d builtin plugins", len(builtin.GetAll()))
return nil
}

View File

@@ -5,10 +5,12 @@ import (
"fmt"
"log"
"net"
"os"
"path/filepath"
"sync"
"time"
"github.com/google/uuid"
"github.com/gotunnel/pkg/plugin"
"github.com/gotunnel/pkg/protocol"
"github.com/gotunnel/pkg/relay"
"github.com/hashicorp/yamux"
@@ -22,24 +24,27 @@ const (
reconnectDelay = 5 * time.Second
disconnectDelay = 3 * time.Second
udpBufferSize = 65535
idFileName = ".gotunnel_id"
)
// Client 隧道客户端
type Client struct {
ServerAddr string
Token string
ID string
TLSEnabled bool
TLSConfig *tls.Config
session *yamux.Session
rules []protocol.ProxyRule
mu sync.RWMutex
ServerAddr string
Token string
ID string
TLSEnabled bool
TLSConfig *tls.Config
session *yamux.Session
rules []protocol.ProxyRule
mu sync.RWMutex
pluginRegistry *plugin.Registry
}
// NewClient 创建客户端
func NewClient(serverAddr, token, id string) *Client {
// 如果未指定 ID尝试从本地文件加载
if id == "" {
id = uuid.New().String()[:8]
id = loadClientID()
}
return &Client{
ServerAddr: serverAddr,
@@ -48,6 +53,36 @@ func NewClient(serverAddr, token, id string) *Client {
}
}
// getIDFilePath 获取 ID 文件路径
func getIDFilePath() string {
home, err := os.UserHomeDir()
if err != nil {
return idFileName
}
return filepath.Join(home, idFileName)
}
// loadClientID 从本地文件加载客户端 ID
func loadClientID() string {
data, err := os.ReadFile(getIDFilePath())
if err != nil {
return ""
}
return string(data)
}
// saveClientID 保存客户端 ID 到本地文件
func saveClientID(id string) {
if err := os.WriteFile(getIDFilePath(), []byte(id), 0600); err != nil {
log.Printf("[Client] Failed to save client ID: %v", err)
}
}
// SetPluginRegistry 设置插件注册表
func (c *Client) SetPluginRegistry(registry *plugin.Registry) {
c.pluginRegistry = registry
}
// Run 启动客户端(带断线重连)
func (c *Client) Run() error {
for {
@@ -102,6 +137,13 @@ func (c *Client) connect() error {
return fmt.Errorf("auth failed: %s", authResp.Message)
}
// 如果服务端分配了新 ID则更新并保存
if authResp.ClientID != "" && authResp.ClientID != c.ID {
c.ID = authResp.ClientID
saveClientID(c.ID)
log.Printf("[Client] New ID assigned and saved: %s", c.ID)
}
log.Printf("[Client] Authenticated as %s", c.ID)
session, err := yamux.Client(conn, nil)

View File

@@ -10,6 +10,7 @@ import (
"github.com/gotunnel/internal/server/config"
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/internal/server/router"
"github.com/gotunnel/pkg/auth"
)
//go:embed dist/*
@@ -92,6 +93,35 @@ func (w *WebServer) RunWithAuth(addr, username, password string) error {
return http.ListenAndServe(addr, handler)
}
// RunWithJWT 启动带 JWT 认证的 Web 服务
func (w *WebServer) RunWithJWT(addr, username, password, jwtSecret string) error {
r := router.New()
// JWT 认证器
jwtAuth := auth.NewJWTAuth(jwtSecret, 24) // 24小时过期
// 注册认证路由(不需要认证)
authHandler := router.NewAuthHandler(username, password, jwtAuth)
router.RegisterAuthRoutes(r, authHandler)
// 注册业务路由
router.RegisterRoutes(r, w)
// 静态文件
staticFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
return err
}
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
// JWT 中间件,只对 /api/ 路径进行认证(排除 /api/auth/
skipPaths := []string{"/api/auth/"}
handler := router.JWTMiddleware(jwtAuth, skipPaths, r.Handler())
log.Printf("[Web] Console listening on %s (JWT auth enabled)", addr)
return http.ListenAndServe(addr, handler)
}
// GetClientStore 获取客户端存储
func (w *WebServer) GetClientStore() db.ClientStore {
return w.ClientStore

View File

@@ -0,0 +1 @@
import{k as n,S as r,V as o,U as t}from"./vue-vendor-k28cQfDw.js";const l={xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",viewBox:"0 0 512 512"},a=n({name:"ArrowBackOutline",render:function(i,e){return t(),r("svg",l,e[0]||(e[0]=[o("path",{fill:"none",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"48",d:"M244 400L100 256l144-144"},null,-1),o("path",{fill:"none",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"48",d:"M120 256h292"},null,-1)]))}});export{a as A};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
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

@@ -1 +0,0 @@
.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{k as B,r as V,c as y,o as z,S as p,V as r,_ as l,X as f,Y as a,N as e,F as S,R as $,Z as j,U as u,a4 as F,$ as i,j as m,a3 as R}from"./vue-vendor-k28cQfDw.js";import{e as g,f as E,g as G,h as d,N as c,i as _,j as k,k as N,B as M}from"./index-BJ4y0MF5.js";const T={class:"home"},D={style:{margin:"0 0 4px 0"}},H={key:0,style:{margin:"0 0 8px 0",color:"#999","font-size":"12px"}},Y=B({__name:"HomeView",setup(L){const h=j(),n=V([]),x=async()=>{try{const{data:t}=await G();n.value=t||[]}catch(t){console.error("Failed to load clients",t)}},C=y(()=>n.value.filter(t=>t.online).length),b=y(()=>n.value.reduce((t,o)=>t+o.rule_count,0));z(x);const v=t=>{h.push(`/client/${t}`)};return(t,o)=>(u(),p("div",T,[o[1]||(o[1]=r("div",{style:{"margin-bottom":"24px"}},[r("h2",{style:{margin:"0 0 8px 0"}},"客户端管理"),r("p",{style:{margin:"0",color:"#666"}},"查看已连接的隧道客户端")],-1)),l(e(g),{cols:3,"x-gap":16,"y-gap":16,style:{"margin-bottom":"24px"}},{default:a(()=>[l(e(d),null,{default:a(()=>[l(e(c),null,{default:a(()=>[l(e(_),{label:"总客户端",value:n.value.length},null,8,["value"])]),_:1})]),_:1}),l(e(d),null,{default:a(()=>[l(e(c),null,{default:a(()=>[l(e(_),{label:"在线客户端",value:C.value},null,8,["value"])]),_:1})]),_:1}),l(e(d),null,{default:a(()=>[l(e(c),null,{default:a(()=>[l(e(_),{label:"总规则数",value:b.value},null,8,["value"])]),_:1})]),_:1})]),_:1}),n.value.length===0?(u(),f(e(E),{key:0,description:"暂无客户端连接"})):(u(),f(e(g),{key:1,cols:3,"x-gap":16,"y-gap":16,responsive:"screen","cols-s":"1","cols-m":"2"},{default:a(()=>[(u(!0),p(S,null,$(n.value,s=>(u(),f(e(d),{key:s.id},{default:a(()=>[l(e(c),{hoverable:"",style:{cursor:"pointer"},onClick:w=>v(s.id)},{default:a(()=>[l(e(k),{justify:"space-between",align:"center"},{default:a(()=>[r("div",null,[r("h3",D,i(s.nickname||s.id),1),s.nickname?(u(),p("p",H,i(s.id),1)):F("",!0),l(e(k),null,{default:a(()=>[l(e(N),{type:s.online?"success":"default",size:"small"},{default:a(()=>[m(i(s.online?"在线":"离线"),1)]),_:2},1032,["type"]),l(e(N),{type:"info",size:"small"},{default:a(()=>[m(i(s.rule_count)+" 条规则",1)]),_:2},1024)]),_:2},1024)]),l(e(M),{size:"small",onClick:R(w=>v(s.id),["stop"])},{default:a(()=>[...o[0]||(o[0]=[m("查看详情",-1)])]),_:1},8,["onClick"])]),_:2},1024)]),_:2},1032,["onClick"])]),_:2},1024))),128))]),_:1}))]))}});export{Y as default};

View File

@@ -1 +0,0 @@
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

@@ -1 +0,0 @@
.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}

View File

@@ -0,0 +1 @@
import{k as N,r as d,S as k,_ as s,Y as a,N as t,Z as w,U as f,a3 as V,X as h,a4 as x,j as m,$ as g,V as i}from"./vue-vendor-k28cQfDw.js";import{N as B,a as C,b,c as _,d as T,B as I,l as L,s as S}from"./index-BJ4y0MF5.js";const U={class:"login-page"},F=N({__name:"LoginView",setup(v){const p=w(),l=d(""),r=d(""),o=d(""),n=d(!1),y=async()=>{if(!l.value||!r.value){o.value="请输入用户名和密码";return}n.value=!0,o.value="";try{const{data:u}=await L(l.value,r.value);S(u.token),p.push("/")}catch(u){o.value=u.response?.data?.error||"登录失败"}finally{n.value=!1}};return(u,e)=>(f(),k("div",U,[s(t(B),{class:"login-card",bordered:!1},{header:a(()=>[...e[2]||(e[2]=[i("div",{class:"login-header"},[i("h1",{class:"logo"},"GoTunnel"),i("p",{class:"subtitle"},"安全的内网穿透工具")],-1)])]),footer:a(()=>[...e[3]||(e[3]=[i("div",{class:"login-footer"},"欢迎使用 GoTunnel",-1)])]),default:a(()=>[s(t(C),{onSubmit:V(y,["prevent"])},{default:a(()=>[s(t(b),{label:"用户名"},{default:a(()=>[s(t(_),{value:l.value,"onUpdate:value":e[0]||(e[0]=c=>l.value=c),placeholder:"请输入用户名",disabled:n.value},null,8,["value","disabled"])]),_:1}),s(t(b),{label:"密码"},{default:a(()=>[s(t(_),{value:r.value,"onUpdate:value":e[1]||(e[1]=c=>r.value=c),type:"password",placeholder:"请输入密码",disabled:n.value,"show-password-on":"click"},null,8,["value","disabled"])]),_:1}),o.value?(f(),h(t(T),{key:0,type:"error","show-icon":!0,style:{"margin-bottom":"16px"}},{default:a(()=>[m(g(o.value),1)]),_:1})):x("",!0),s(t(I),{type:"primary",block:"",loading:n.value,"attr-type":"submit"},{default:a(()=>[m(g(n.value?"登录中...":"登录"),1)]),_:1},8,["loading"])]),_:1})]),_:1})]))}}),G=(v,p)=>{const l=v.__vccOpts||v;for(const[r,o]of p)l[r]=o;return l},D=G(F,[["__scopeId","data-v-0e29b44b"]]);export{D as default};

View File

@@ -0,0 +1 @@
.login-page[data-v-0e29b44b]{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#e8f5e9,#c8e6c9);padding:16px}.login-card[data-v-0e29b44b]{width:100%;max-width:400px;box-shadow:0 8px 24px #0000001a}.login-header[data-v-0e29b44b]{text-align:center}.logo[data-v-0e29b44b]{font-size:28px;font-weight:700;color:#18a058;margin:0 0 8px}.subtitle[data-v-0e29b44b]{color:#666;margin:0;font-size:14px}.login-footer[data-v-0e29b44b]{text-align:center;color:#999;font-size:14px}

View File

@@ -0,0 +1 @@
import{k as $,r as b,c as x,o as E,S as N,_ as a,Y as l,N as e,V as u,Z as F,j as c,X as m,F as T,R as j,U as r,$ as i}from"./vue-vendor-k28cQfDw.js";import{u as A,j as d,D as G,y as M,B as U,p as h,e as w,h as p,N as f,i as _,f as D,k as g,E as L,F as O,G as R,H as q}from"./index-BJ4y0MF5.js";import{A as H}from"./ArrowBackOutline-QaNKMlLc.js";const I={class:"plugins-view"},W={style:{margin:"0",color:"#666"}},Q=$({__name:"PluginsView",setup(X){const k=F(),v=A(),o=b([]),y=b(!0),P=async()=>{try{const{data:t}=await M();o.value=t||[]}catch(t){console.error("Failed to load plugins",t)}finally{y.value=!1}},z=x(()=>o.value.filter(t=>t.type==="proxy")),B=x(()=>o.value.filter(t=>t.type==="app")),S=async t=>{try{t.enabled?(await R(t.name),v.success(`已禁用 ${t.name}`)):(await q(t.name),v.success(`已启用 ${t.name}`)),t.enabled=!t.enabled}catch{v.error("操作失败")}},C=t=>({proxy:"协议",app:"应用",service:"服务",tool:"工具"})[t]||t,V=t=>({proxy:"info",app:"success",service:"warning",tool:"default"})[t]||"default";return E(P),(t,n)=>(r(),N("div",I,[a(e(d),{justify:"space-between",align:"center",style:{"margin-bottom":"24px"}},{default:l(()=>[n[2]||(n[2]=u("div",null,[u("h2",{style:{margin:"0 0 8px 0"}},"插件管理"),u("p",{style:{margin:"0",color:"#666"}},"查看和管理已注册的插件")],-1)),a(e(U),{quaternary:"",onClick:n[0]||(n[0]=s=>e(k).push("/"))},{icon:l(()=>[a(e(h),null,{default:l(()=>[a(e(H))]),_:1})]),default:l(()=>[n[1]||(n[1]=c(" 返回首页 ",-1))]),_:1})]),_:1}),a(e(G),{show:y.value},{default:l(()=>[a(e(w),{cols:3,"x-gap":16,"y-gap":16,style:{"margin-bottom":"24px"}},{default:l(()=>[a(e(p),null,{default:l(()=>[a(e(f),null,{default:l(()=>[a(e(_),{label:"总插件数",value:o.value.length},null,8,["value"])]),_:1})]),_:1}),a(e(p),null,{default:l(()=>[a(e(f),null,{default:l(()=>[a(e(_),{label:"协议插件",value:z.value.length},null,8,["value"])]),_:1})]),_:1}),a(e(p),null,{default:l(()=>[a(e(f),null,{default:l(()=>[a(e(_),{label:"应用插件",value:B.value.length},null,8,["value"])]),_:1})]),_:1})]),_:1}),!y.value&&o.value.length===0?(r(),m(e(D),{key:0,description:"暂无插件"})):(r(),m(e(w),{key:1,cols:3,"x-gap":16,"y-gap":16,responsive:"screen","cols-s":"1","cols-m":"2"},{default:l(()=>[(r(!0),N(T,null,j(o.value,s=>(r(),m(e(p),{key:s.name},{default:l(()=>[a(e(f),{hoverable:""},{header:l(()=>[a(e(d),{align:"center"},{default:l(()=>[a(e(h),{size:"24",color:"#18a058"},{default:l(()=>[a(e(O))]),_:1}),u("span",null,i(s.name),1)]),_:2},1024)]),"header-extra":l(()=>[a(e(L),{value:s.enabled,"onUpdate:value":Y=>S(s)},null,8,["value","onUpdate:value"])]),default:l(()=>[a(e(d),{vertical:"",size:8},{default:l(()=>[a(e(d),null,{default:l(()=>[a(e(g),{size:"small"},{default:l(()=>[c("v"+i(s.version),1)]),_:2},1024),a(e(g),{size:"small",type:V(s.type)},{default:l(()=>[c(i(C(s.type)),1)]),_:2},1032,["type"]),a(e(g),{size:"small",type:s.source==="builtin"?"default":"warning"},{default:l(()=>[c(i(s.source==="builtin"?"内置":"WASM"),1)]),_:2},1032,["type"])]),_:2},1024),u("p",W,i(s.description),1)]),_:2},1024)]),_:2},1024)]),_:2},1024))),128))]),_:1}))]),_:1},8,["show"])]))}});export{Q as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#app{min-height:100vh}

View File

@@ -1 +0,0 @@
: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}

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,9 @@
<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">
<script type="module" crossorigin src="/assets/index-BJ4y0MF5.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vue-vendor-k28cQfDw.js">
<link rel="stylesheet" crossorigin href="/assets/index-cn54chxY.css">
</head>
<body>
<div id="app"></div>

View File

@@ -4,33 +4,50 @@ import "github.com/gotunnel/pkg/protocol"
// Client 客户端数据
type Client struct {
ID string `json:"id"`
Rules []protocol.ProxyRule `json:"rules"`
ID string `json:"id"`
Nickname string `json:"nickname,omitempty"`
Rules []protocol.ProxyRule `json:"rules"`
}
// PluginData 插件数据
type PluginData struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
Source string `json:"source"`
Description string `json:"description"`
Author string `json:"author"`
Checksum string `json:"checksum"`
Size int64 `json:"size"`
Enabled bool `json:"enabled"`
WASMData []byte `json:"-"`
}
// 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
}
// PluginStore 插件存储接口
type PluginStore interface {
GetAllPlugins() ([]PluginData, error)
GetPlugin(name string) (*PluginData, error)
SavePlugin(p *PluginData) error
DeletePlugin(name string) error
SetPluginEnabled(name string, enabled bool) error
GetPluginWASM(name string) ([]byte, error)
}
// Store 统一存储接口
type Store interface {
ClientStore
PluginStore
Close() error
}

View File

@@ -34,11 +34,35 @@ func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
// init 初始化数据库表
func (s *SQLiteStore) init() error {
// 创建客户端表
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY,
nickname TEXT NOT NULL DEFAULT '',
rules TEXT NOT NULL DEFAULT '[]'
);
)
`)
if err != nil {
return err
}
// 迁移:添加 nickname 列
s.db.Exec(`ALTER TABLE clients ADD COLUMN nickname TEXT NOT NULL DEFAULT ''`)
// 创建插件表
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS plugins (
name TEXT PRIMARY KEY,
version TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'proxy',
source TEXT NOT NULL DEFAULT 'wasm',
description TEXT,
author TEXT,
checksum TEXT,
size INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
wasm_data BLOB
)
`)
return err
}
@@ -53,7 +77,7 @@ func (s *SQLiteStore) GetAllClients() ([]Client, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT id, rules FROM clients`)
rows, err := s.db.Query(`SELECT id, nickname, rules FROM clients`)
if err != nil {
return nil, err
}
@@ -63,7 +87,7 @@ func (s *SQLiteStore) GetAllClients() ([]Client, error) {
for rows.Next() {
var c Client
var rulesJSON string
if err := rows.Scan(&c.ID, &rulesJSON); err != nil {
if err := rows.Scan(&c.ID, &c.Nickname, &rulesJSON); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil {
@@ -81,7 +105,7 @@ func (s *SQLiteStore) GetClient(id string) (*Client, error) {
var c Client
var rulesJSON string
err := s.db.QueryRow(`SELECT id, rules FROM clients WHERE id = ?`, id).Scan(&c.ID, &rulesJSON)
err := s.db.QueryRow(`SELECT id, nickname, rules FROM clients WHERE id = ?`, id).Scan(&c.ID, &c.Nickname, &rulesJSON)
if err != nil {
return nil, err
}
@@ -100,7 +124,7 @@ func (s *SQLiteStore) CreateClient(c *Client) error {
if err != nil {
return err
}
_, err = s.db.Exec(`INSERT INTO clients (id, rules) VALUES (?, ?)`, c.ID, string(rulesJSON))
_, err = s.db.Exec(`INSERT INTO clients (id, nickname, rules) VALUES (?, ?, ?)`, c.ID, c.Nickname, string(rulesJSON))
return err
}
@@ -113,7 +137,7 @@ func (s *SQLiteStore) UpdateClient(c *Client) error {
if err != nil {
return err
}
_, err = s.db.Exec(`UPDATE clients SET rules = ? WHERE id = ?`, string(rulesJSON), c.ID)
_, err = s.db.Exec(`UPDATE clients SET nickname = ?, rules = ? WHERE id = ?`, c.Nickname, string(rulesJSON), c.ID)
return err
}
@@ -144,3 +168,100 @@ func (s *SQLiteStore) GetClientRules(id string) ([]protocol.ProxyRule, error) {
}
return c.Rules, nil
}
// ========== 插件存储方法 ==========
// GetAllPlugins 获取所有插件
func (s *SQLiteStore) GetAllPlugins() ([]PluginData, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`
SELECT name, version, type, source, description, author, checksum, size, enabled
FROM plugins
`)
if err != nil {
return nil, err
}
defer rows.Close()
var plugins []PluginData
for rows.Next() {
var p PluginData
var enabled int
err := rows.Scan(&p.Name, &p.Version, &p.Type, &p.Source,
&p.Description, &p.Author, &p.Checksum, &p.Size, &enabled)
if err != nil {
return nil, err
}
p.Enabled = enabled == 1
plugins = append(plugins, p)
}
return plugins, nil
}
// GetPlugin 获取单个插件
func (s *SQLiteStore) GetPlugin(name string) (*PluginData, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var p PluginData
var enabled int
err := s.db.QueryRow(`
SELECT name, version, type, source, description, author, checksum, size, enabled
FROM plugins WHERE name = ?
`, name).Scan(&p.Name, &p.Version, &p.Type, &p.Source,
&p.Description, &p.Author, &p.Checksum, &p.Size, &enabled)
if err != nil {
return nil, err
}
p.Enabled = enabled == 1
return &p, nil
}
// SavePlugin 保存插件
func (s *SQLiteStore) SavePlugin(p *PluginData) error {
s.mu.Lock()
defer s.mu.Unlock()
enabled := 0
if p.Enabled {
enabled = 1
}
_, err := s.db.Exec(`
INSERT OR REPLACE INTO plugins
(name, version, type, source, description, author, checksum, size, enabled, wasm_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, p.Name, p.Version, p.Type, p.Source, p.Description, p.Author,
p.Checksum, p.Size, enabled, p.WASMData)
return err
}
// DeletePlugin 删除插件
func (s *SQLiteStore) DeletePlugin(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM plugins WHERE name = ?`, name)
return err
}
// SetPluginEnabled 设置插件启用状态
func (s *SQLiteStore) SetPluginEnabled(name string, enabled bool) error {
s.mu.Lock()
defer s.mu.Unlock()
val := 0
if enabled {
val = 1
}
_, err := s.db.Exec(`UPDATE plugins SET enabled = ? WHERE name = ?`, val, name)
return err
}
// GetPluginWASM 获取插件 WASM 数据
func (s *SQLiteStore) GetPluginWASM(name string) ([]byte, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var data []byte
err := s.db.QueryRow(`SELECT wasm_data FROM plugins WHERE name = ?`, name).Scan(&data)
return data, err
}

View File

@@ -2,28 +2,26 @@ package plugin
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"sync"
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/pkg/plugin"
"github.com/gotunnel/pkg/plugin/builtin"
"github.com/gotunnel/pkg/plugin/store"
"github.com/gotunnel/pkg/plugin/wasm"
)
// Manager 服务端 plugin 管理器
type Manager struct {
registry *plugin.Registry
store store.PluginStore
store db.PluginStore
runtime *wasm.Runtime
mu sync.RWMutex
}
// NewManager 创建 plugin 管理器
func NewManager(pluginStore store.PluginStore) (*Manager, error) {
func NewManager(pluginStore db.PluginStore) (*Manager, error) {
ctx := context.Background()
runtime, err := wasm.NewRuntime(ctx)
@@ -51,12 +49,11 @@ func NewManager(pluginStore store.PluginStore) (*Manager, error) {
// 注意: tcp, udp, http, https 是内置类型,直接在 tunnel 中处理
// 这里只注册需要通过 plugin 系统提供的协议
func (m *Manager) registerBuiltins() error {
// 注册 SOCKS5 plugin
if err := m.registry.RegisterBuiltin(builtin.NewSOCKS5Plugin()); err != nil {
return fmt.Errorf("register socks5: %w", err)
// 使用统一的插件注册入口
if err := m.registry.RegisterAll(builtin.GetAll()); err != nil {
return err
}
log.Println("[Plugin] Builtin plugins registered: socks5")
log.Printf("[Plugin] Registered %d builtin plugins", len(builtin.GetAll()))
return nil
}
@@ -71,15 +68,15 @@ func (m *Manager) LoadStoredPlugins(ctx context.Context) error {
return err
}
for _, meta := range plugins {
data, err := m.store.GetPluginData(meta.Name)
for _, p := range plugins {
data, err := m.store.GetPluginWASM(p.Name)
if err != nil {
log.Printf("[Plugin] Failed to load %s: %v", meta.Name, err)
log.Printf("[Plugin] Failed to load %s: %v", p.Name, err)
continue
}
if err := m.loadWASMPlugin(ctx, meta.Name, data); err != nil {
log.Printf("[Plugin] Failed to init %s: %v", meta.Name, err)
if err := m.loadWASMPlugin(ctx, p.Name, data); err != nil {
log.Printf("[Plugin] Failed to init %s: %v", p.Name, err)
}
}
@@ -97,28 +94,19 @@ func (m *Manager) loadWASMPlugin(ctx context.Context, name string, data []byte)
}
// InstallPlugin 安装新的 WASM plugin
func (m *Manager) InstallPlugin(ctx context.Context, meta plugin.PluginMetadata, wasmData []byte) error {
func (m *Manager) InstallPlugin(ctx context.Context, p *db.PluginData) error {
m.mu.Lock()
defer m.mu.Unlock()
// 验证 checksum
hash := sha256.Sum256(wasmData)
checksum := hex.EncodeToString(hash[:])
if meta.Checksum != "" && meta.Checksum != checksum {
return fmt.Errorf("checksum mismatch")
}
meta.Checksum = checksum
meta.Size = int64(len(wasmData))
// 存储到数据库
if m.store != nil {
if err := m.store.SavePlugin(meta, wasmData); err != nil {
if err := m.store.SavePlugin(p); err != nil {
return err
}
}
// 加载到运行时
return m.loadWASMPlugin(ctx, meta.Name, wasmData)
return m.loadWASMPlugin(ctx, p.Name, p.WASMData)
}
// GetHandler 返回指定代理类型的 handler

View File

@@ -21,6 +21,7 @@ func validateClientID(id string) bool {
// ClientStatus 客户端状态
type ClientStatus struct {
ID string `json:"id"`
Nickname string `json:"nickname,omitempty"`
Online bool `json:"online"`
LastPing string `json:"last_ping,omitempty"`
RuleCount int `json:"rule_count"`
@@ -36,6 +37,23 @@ type ServerInterface interface {
ReloadConfig() error
GetBindAddr() string
GetBindPort() int
// 客户端控制
PushConfigToClient(clientID string) error
DisconnectClient(clientID string) error
GetPluginList() []PluginInfo
EnablePlugin(name string) error
DisablePlugin(name string) error
InstallPluginsToClient(clientID string, plugins []string) error
}
// PluginInfo 插件信息
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
Description string `json:"description"`
Source string `json:"source"`
Enabled bool `json:"enabled"`
}
// AppInterface 应用接口
@@ -68,6 +86,8 @@ func RegisterRoutes(r *Router, app AppInterface) {
api.HandleFunc("/client/", h.handleClient)
api.HandleFunc("/config", h.handleConfig)
api.HandleFunc("/config/reload", h.handleReload)
api.HandleFunc("/plugins", h.handlePlugins)
api.HandleFunc("/plugin/", h.handlePlugin)
}
func (h *APIHandler) handleStatus(rw http.ResponseWriter, r *http.Request) {
@@ -108,7 +128,7 @@ func (h *APIHandler) getClients(rw http.ResponseWriter) {
statusMap := h.server.GetAllClientStatus()
var result []ClientStatus
for _, c := range clients {
cs := ClientStatus{ID: c.ID, RuleCount: len(c.Rules)}
cs := ClientStatus{ID: c.ID, Nickname: c.Nickname, RuleCount: len(c.Rules)}
if s, ok := statusMap[c.ID]; ok {
cs.Online = s.Online
cs.LastPing = s.LastPing
@@ -156,6 +176,32 @@ func (h *APIHandler) handleClient(rw http.ResponseWriter, r *http.Request) {
http.Error(rw, "client id required", http.StatusBadRequest)
return
}
// 处理子路径操作
if idx := len(clientID) - 1; idx > 0 {
if clientID[idx] == '/' {
clientID = clientID[:idx]
}
}
// 检查是否是特殊操作
parts := splitPath(clientID)
if len(parts) == 2 {
clientID = parts[0]
action := parts[1]
switch action {
case "push":
h.pushConfigToClient(rw, r, clientID)
return
case "disconnect":
h.disconnectClient(rw, r, clientID)
return
case "install-plugins":
h.installPluginsToClient(rw, r, clientID)
return
}
}
switch r.Method {
case http.MethodGet:
h.getClient(rw, clientID)
@@ -168,6 +214,16 @@ func (h *APIHandler) handleClient(rw http.ResponseWriter, r *http.Request) {
}
}
// splitPath 分割路径
func splitPath(path string) []string {
for i, c := range path {
if c == '/' {
return []string{path[:i], path[i+1:]}
}
}
return []string{path}
}
func (h *APIHandler) getClient(rw http.ResponseWriter, clientID string) {
client, err := h.clientStore.GetClient(clientID)
if err != nil {
@@ -176,27 +232,29 @@ func (h *APIHandler) getClient(rw http.ResponseWriter, clientID string) {
}
online, lastPing := h.server.GetClientStatus(clientID)
h.jsonResponse(rw, map[string]interface{}{
"id": client.ID, "rules": client.Rules,
"id": client.ID, "nickname": client.Nickname, "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"`
Nickname string `json:"nickname"`
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 {
client, err := h.clientStore.GetClient(clientID)
if err != nil {
http.Error(rw, "client not found", http.StatusNotFound)
return
}
client := &db.Client{ID: clientID, Rules: req.Rules}
client.Nickname = req.Nickname
client.Rules = req.Rules
if err := h.clientStore.UpdateClient(client); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
@@ -333,3 +391,130 @@ func (h *APIHandler) jsonResponse(rw http.ResponseWriter, data interface{}) {
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(data)
}
// pushConfigToClient 推送配置到客户端
func (h *APIHandler) pushConfigToClient(rw http.ResponseWriter, r *http.Request, clientID string) {
if r.Method != http.MethodPost {
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
return
}
online, _ := h.server.GetClientStatus(clientID)
if !online {
http.Error(rw, "client not online", http.StatusBadRequest)
return
}
if err := h.server.PushConfigToClient(clientID); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
h.jsonResponse(rw, map[string]string{"status": "ok"})
}
// disconnectClient 断开客户端连接
func (h *APIHandler) disconnectClient(rw http.ResponseWriter, r *http.Request, clientID string) {
if r.Method != http.MethodPost {
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if err := h.server.DisconnectClient(clientID); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
h.jsonResponse(rw, map[string]string{"status": "ok"})
}
// handlePlugins 处理插件列表
func (h *APIHandler) handlePlugins(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
return
}
plugins := h.server.GetPluginList()
h.jsonResponse(rw, plugins)
}
// handlePlugin 处理单个插件操作
func (h *APIHandler) handlePlugin(rw http.ResponseWriter, r *http.Request) {
path := r.URL.Path[len("/api/plugin/"):]
if path == "" {
http.Error(rw, "plugin name required", http.StatusBadRequest)
return
}
parts := splitPath(path)
pluginName := parts[0]
if len(parts) == 2 {
action := parts[1]
switch action {
case "enable":
h.enablePlugin(rw, r, pluginName)
return
case "disable":
h.disablePlugin(rw, r, pluginName)
return
}
}
http.Error(rw, "invalid action", http.StatusBadRequest)
}
func (h *APIHandler) enablePlugin(rw http.ResponseWriter, r *http.Request, name string) {
if r.Method != http.MethodPost {
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if err := h.server.EnablePlugin(name); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
h.jsonResponse(rw, map[string]string{"status": "ok"})
}
func (h *APIHandler) disablePlugin(rw http.ResponseWriter, r *http.Request, name string) {
if r.Method != http.MethodPost {
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if err := h.server.DisablePlugin(name); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
h.jsonResponse(rw, map[string]string{"status": "ok"})
}
// installPluginsToClient 安装插件到客户端
func (h *APIHandler) installPluginsToClient(rw http.ResponseWriter, r *http.Request, clientID string) {
if r.Method != http.MethodPost {
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
return
}
online, _ := h.server.GetClientStatus(clientID)
if !online {
http.Error(rw, "client not online", http.StatusBadRequest)
return
}
var req struct {
Plugins []string `json:"plugins"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
if len(req.Plugins) == 0 {
http.Error(rw, "no plugins specified", http.StatusBadRequest)
return
}
if err := h.server.InstallPluginsToClient(clientID, req.Plugins); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
h.jsonResponse(rw, map[string]string{"status": "ok"})
}

View File

@@ -0,0 +1,80 @@
package router
import (
"crypto/subtle"
"encoding/json"
"net/http"
"github.com/gotunnel/pkg/auth"
)
// AuthHandler 认证处理器
type AuthHandler struct {
username string
password string
jwtAuth *auth.JWTAuth
}
// NewAuthHandler 创建认证处理器
func NewAuthHandler(username, password string, jwtAuth *auth.JWTAuth) *AuthHandler {
return &AuthHandler{
username: username,
password: password,
jwtAuth: jwtAuth,
}
}
// RegisterAuthRoutes 注册认证路由
func RegisterAuthRoutes(r *Router, h *AuthHandler) {
r.HandleFunc("/api/auth/login", h.handleLogin)
r.HandleFunc("/api/auth/check", h.handleCheck)
}
// handleLogin 处理登录请求
func (h *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
// 验证用户名密码
userMatch := subtle.ConstantTimeCompare([]byte(req.Username), []byte(h.username)) == 1
passMatch := subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.password)) == 1
if !userMatch || !passMatch {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
// 生成 token
token, err := h.jwtAuth.GenerateToken(req.Username)
if err != nil {
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"token": token,
})
}
// handleCheck 检查 token 是否有效
func (h *AuthHandler) handleCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"valid": true})
}

View File

@@ -3,6 +3,9 @@ package router
import (
"crypto/subtle"
"net/http"
"strings"
"github.com/gotunnel/pkg/auth"
)
// Router 路由管理器
@@ -84,3 +87,37 @@ func BasicAuthMiddleware(auth *AuthConfig, next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
// JWTMiddleware JWT 认证中间件
func JWTMiddleware(jwtAuth *auth.JWTAuth, skipPaths []string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 只对 /api/ 路径进行认证
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
// 检查是否跳过认证
for _, path := range skipPaths {
if strings.HasPrefix(r.URL.Path, path) {
next.ServeHTTP(w, r)
return
}
}
// 从 Header 获取 token
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if _, err := jwtAuth.ValidateToken(token); err != nil {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -1,7 +1,9 @@
package tunnel
import (
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"log"
"net"
@@ -9,6 +11,7 @@ import (
"time"
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/internal/server/router"
"github.com/gotunnel/pkg/plugin"
"github.com/gotunnel/pkg/protocol"
"github.com/gotunnel/pkg/proxy"
@@ -19,11 +22,18 @@ import (
// 服务端常量
const (
authTimeout = 10 * time.Second
heartbeatTimeout = 10 * time.Second
udpBufferSize = 65535
authTimeout = 10 * time.Second
heartbeatTimeout = 10 * time.Second
udpBufferSize = 65535
)
// generateClientID 生成随机客户端 ID
func generateClientID() string {
bytes := make([]byte, 8)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
// Server 隧道服务端
type Server struct {
clientStore db.ClientStore
@@ -130,24 +140,44 @@ func (s *Server) handleConnection(conn net.Conn) {
}
if authReq.Token != s.token {
s.sendAuthResponse(conn, false, "invalid 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")
// 如果客户端没有提供 ID则生成一个新的
clientID := authReq.ClientID
if clientID == "" {
clientID = generateClientID()
// 创建新客户端记录
newClient := &db.Client{ID: clientID, Rules: []protocol.ProxyRule{}}
if err := s.clientStore.CreateClient(newClient); err != nil {
log.Printf("[Server] Create client error: %v", err)
s.sendAuthResponse(conn, false, "failed to create client", "")
return
}
log.Printf("[Server] New client registered: %s", clientID)
}
// 检查客户端是否存在
exists, err := s.clientStore.ClientExists(clientID)
if err != nil || !exists {
s.sendAuthResponse(conn, false, "client not found", "")
return
}
rules, _ := s.clientStore.GetClientRules(clientID)
if rules == nil {
rules = []protocol.ProxyRule{}
}
conn.SetReadDeadline(time.Time{})
if err := s.sendAuthResponse(conn, true, "ok"); err != nil {
if err := s.sendAuthResponse(conn, true, "ok", clientID); err != nil {
return
}
log.Printf("[Server] Client %s authenticated", authReq.ClientID)
s.setupClientSession(conn, authReq.ClientID, rules)
log.Printf("[Server] Client %s authenticated", clientID)
s.setupClientSession(conn, clientID, rules)
}
// setupClientSession 建立客户端会话
@@ -183,8 +213,8 @@ func (s *Server) setupClientSession(conn net.Conn, clientID string, rules []prot
}
// sendAuthResponse 发送认证响应
func (s *Server) sendAuthResponse(conn net.Conn, success bool, message string) error {
resp := protocol.AuthResponse{Success: success, Message: message}
func (s *Server) sendAuthResponse(conn net.Conn, success bool, message, clientID string) error {
resp := protocol.AuthResponse{Success: success, Message: message, ClientID: clientID}
msg, err := protocol.NewMessage(protocol.MsgTypeAuthResp, resp)
if err != nil {
return err
@@ -458,6 +488,103 @@ func (s *Server) GetBindPort() int {
return s.bindPort
}
// PushConfigToClient 推送配置到客户端
func (s *Server) PushConfigToClient(clientID string) error {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("client %s not found", clientID)
}
rules, err := s.clientStore.GetClientRules(clientID)
if err != nil {
return err
}
return s.sendProxyConfig(cs.Session, rules)
}
// DisconnectClient 断开客户端连接
func (s *Server) DisconnectClient(clientID string) error {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("client %s not found", clientID)
}
return cs.Session.Close()
}
// GetPluginList 获取插件列表
func (s *Server) GetPluginList() []router.PluginInfo {
var result []router.PluginInfo
if s.pluginRegistry == nil {
return result
}
for _, info := range s.pluginRegistry.List() {
result = append(result, router.PluginInfo{
Name: info.Metadata.Name,
Version: info.Metadata.Version,
Type: string(info.Metadata.Type),
Description: info.Metadata.Description,
Source: string(info.Metadata.Source),
Enabled: info.Enabled,
})
}
return result
}
// EnablePlugin 启用插件
func (s *Server) EnablePlugin(name string) error {
if s.pluginRegistry == nil {
return fmt.Errorf("plugin registry not initialized")
}
return s.pluginRegistry.Enable(name)
}
// DisablePlugin 禁用插件
func (s *Server) DisablePlugin(name string) error {
if s.pluginRegistry == nil {
return fmt.Errorf("plugin registry not initialized")
}
return s.pluginRegistry.Disable(name)
}
// InstallPluginsToClient 安装插件到客户端
func (s *Server) InstallPluginsToClient(clientID string, plugins []string) error {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("client %s not found", clientID)
}
return s.sendInstallPlugins(cs.Session, plugins)
}
// sendInstallPlugins 发送安装插件请求
func (s *Server) sendInstallPlugins(session *yamux.Session, plugins []string) error {
stream, err := session.Open()
if err != nil {
return err
}
defer stream.Close()
req := protocol.InstallPluginsRequest{Plugins: plugins}
msg, err := protocol.NewMessage(protocol.MsgTypeInstallPlugins, req)
if err != nil {
return err
}
return protocol.WriteMessage(stream, msg)
}
// startUDPListener 启动 UDP 监听
func (s *Server) startUDPListener(cs *ClientSession, rule protocol.ProxyRule) {
if err := s.portManager.Reserve(rule.RemotePort, cs.ID); err != nil {