1058 lines
28 KiB
Go
1058 lines
28 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/fatedier/frp/pkg/db"
|
|
"github.com/fatedier/frp/pkg/db/ent"
|
|
"github.com/fatedier/frp/pkg/db/ent/frpcclient"
|
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
"github.com/fatedier/frp/pkg/db/ent/proxy"
|
|
"github.com/fatedier/frp/pkg/db/ent/user"
|
|
)
|
|
|
|
type sessionInfo struct {
|
|
UserID int
|
|
Username string
|
|
Expires time.Time
|
|
}
|
|
|
|
var (
|
|
sessionsMu sync.RWMutex
|
|
sessions = map[string]*sessionInfo{}
|
|
)
|
|
|
|
const sessionCookieName = "frp_admin_session"
|
|
const sessionMaxAge = 24 * time.Hour
|
|
|
|
func generateSessionID() string {
|
|
b := make([]byte, 32)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func getSession(r *http.Request) *sessionInfo {
|
|
c, err := r.Cookie(sessionCookieName)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
sessionsMu.RLock()
|
|
s := sessions[c.Value]
|
|
sessionsMu.RUnlock()
|
|
if s == nil || time.Now().After(s.Expires) {
|
|
if s != nil {
|
|
sessionsMu.Lock()
|
|
delete(sessions, c.Value)
|
|
sessionsMu.Unlock()
|
|
}
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
func authMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
s := getSession(r)
|
|
if s == nil {
|
|
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
type Handler struct{}
|
|
|
|
func NewHandler() *Handler {
|
|
return &Handler{}
|
|
}
|
|
|
|
func (h *Handler) RegisterRoutes(r *mux.Router) {
|
|
s := r.PathPrefix("/admin").Subrouter()
|
|
|
|
s.HandleFunc("/setup", h.handleSetup).Methods("GET")
|
|
s.HandleFunc("/setup", h.handleSetupPost).Methods("POST")
|
|
|
|
s.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/admin/setup", http.StatusFound)
|
|
})
|
|
s.HandleFunc("/login", h.handleLogin).Methods("GET")
|
|
s.HandleFunc("/login", h.handleLoginPost).Methods("POST")
|
|
s.HandleFunc("/logout", h.handleLogout).Methods("POST")
|
|
|
|
// Frpc config API — key-based auth, no session required
|
|
s.HandleFunc("/api/frpc/proxy-config/{key}", h.handleFrpcProxyConfig).Methods("GET")
|
|
|
|
p := s.NewRoute().Subrouter()
|
|
p.Use(func(next http.Handler) http.Handler { return authMiddleware(next) })
|
|
|
|
// Client auth API — no session required
|
|
s.HandleFunc("/api/client/auth", h.handleClientAuth).Methods("POST")
|
|
|
|
p.HandleFunc("/dashboard", h.handleDashboard).Methods("GET")
|
|
p.HandleFunc("/clients", h.handleClients).Methods("GET")
|
|
p.HandleFunc("/clients/new", h.handleNewClient).Methods("GET")
|
|
p.HandleFunc("/clients/new", h.handleNewClientPost).Methods("POST")
|
|
p.HandleFunc("/clients/{id:[0-9]+}", h.handleClientDetail).Methods("GET")
|
|
p.HandleFunc("/clients/{id:[0-9]+}/provision", h.handleClientProvision).Methods("GET")
|
|
p.HandleFunc("/clients/{id:[0-9]+}/config", h.handleClientConfigDownload).Methods("GET")
|
|
p.HandleFunc("/clients/{id:[0-9]+}/generate-token", h.handleClientGenerateToken).Methods("POST")
|
|
p.HandleFunc("/proxies", h.handleProxies).Methods("GET")
|
|
p.HandleFunc("/proxies/new", h.handleNewProxy).Methods("GET")
|
|
p.HandleFunc("/proxies/new", h.handleNewProxyPost).Methods("POST")
|
|
p.HandleFunc("/proxies/{id:[0-9]+}", h.handleProxyDetail).Methods("GET")
|
|
p.HandleFunc("/settings", h.handleSettings).Methods("GET")
|
|
p.HandleFunc("/settings", h.handleSettingsSave).Methods("POST")
|
|
}
|
|
|
|
func (h *Handler) handleSetup(w http.ResponseWriter, r *http.Request) {
|
|
if db.HasAdmin() {
|
|
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
|
return
|
|
}
|
|
renderTemplate(w, "setup.html", map[string]any{
|
|
"Error": "",
|
|
})
|
|
}
|
|
|
|
func (h *Handler) handleSetupPost(w http.ResponseWriter, r *http.Request) {
|
|
if db.HasAdmin() {
|
|
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
|
return
|
|
}
|
|
r.ParseForm()
|
|
|
|
username := r.FormValue("username")
|
|
password := r.FormValue("password")
|
|
confirmPassword := r.FormValue("confirm_password")
|
|
name := r.FormValue("name")
|
|
|
|
if username == "" || password == "" {
|
|
renderTemplate(w, "setup.html", map[string]any{
|
|
"Error": "Username and password are required.",
|
|
})
|
|
return
|
|
}
|
|
if password != confirmPassword {
|
|
renderTemplate(w, "setup.html", map[string]any{
|
|
"Error": "Passwords do not match.",
|
|
})
|
|
return
|
|
}
|
|
if name == "" {
|
|
name = username
|
|
}
|
|
|
|
if err := db.CreateAdmin(username, password, name); err != nil {
|
|
renderTemplate(w, "setup.html", map[string]any{
|
|
"Error": "Failed to create admin user: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
cfg := db.DefaultServerConfig()
|
|
if v := r.FormValue("bind_addr"); v != "" {
|
|
cfg.BindAddr = v
|
|
}
|
|
if v := r.FormValue("bind_port"); v != "" {
|
|
cfg.BindPort = atoi(v, 7000)
|
|
}
|
|
if v := r.FormValue("dashboard_addr"); v != "" {
|
|
cfg.WebServer.Addr = v
|
|
}
|
|
if v := r.FormValue("dashboard_port"); v != "" {
|
|
cfg.WebServer.Port = atoi(v, 7500)
|
|
}
|
|
if v := r.FormValue("auth_token"); v != "" {
|
|
cfg.Auth.Token = v
|
|
}
|
|
|
|
if err := db.SaveServerConfig(cfg); err != nil {
|
|
renderTemplate(w, "setup.html", map[string]any{
|
|
"Error": "Failed to save configuration: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
|
}
|
|
|
|
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
if !db.HasAdmin() {
|
|
http.Redirect(w, r, "/admin/setup", http.StatusFound)
|
|
return
|
|
}
|
|
if getSession(r) != nil {
|
|
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
|
return
|
|
}
|
|
renderTemplate(w, "login.html", map[string]any{
|
|
"Error": "",
|
|
})
|
|
}
|
|
|
|
func (h *Handler) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|
username := r.FormValue("username")
|
|
password := r.FormValue("password")
|
|
|
|
if username == "" || password == "" {
|
|
renderTemplate(w, "login.html", map[string]any{
|
|
"Error": "Username and password are required.",
|
|
})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
u, err := db.Client().User.Query().Where(user.UsernameEQ(username)).Only(ctx)
|
|
if err != nil {
|
|
renderTemplate(w, "login.html", map[string]any{
|
|
"Error": "Invalid username or password.",
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil {
|
|
renderTemplate(w, "login.html", map[string]any{
|
|
"Error": "Invalid username or password.",
|
|
})
|
|
return
|
|
}
|
|
|
|
sessionID := generateSessionID()
|
|
sessionsMu.Lock()
|
|
sessions[sessionID] = &sessionInfo{
|
|
UserID: u.ID,
|
|
Username: u.Username,
|
|
Expires: time.Now().Add(sessionMaxAge),
|
|
}
|
|
sessionsMu.Unlock()
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: sessionID,
|
|
Path: "/admin",
|
|
HttpOnly: true,
|
|
MaxAge: int(sessionMaxAge.Seconds()),
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
|
}
|
|
|
|
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
c, err := r.Cookie(sessionCookieName)
|
|
if err == nil {
|
|
sessionsMu.Lock()
|
|
delete(sessions, c.Value)
|
|
sessionsMu.Unlock()
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: "",
|
|
Path: "/admin",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
})
|
|
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
|
}
|
|
|
|
func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
ctx := context.Background()
|
|
c := db.Client()
|
|
|
|
totalClients, _ := c.FrpcClient.Query().Count(ctx)
|
|
onlineClients, _ := c.FrpcClient.Query().Where(frpcclient.StatusEQ("online")).Count(ctx)
|
|
offlineClients, _ := c.FrpcClient.Query().Where(frpcclient.StatusEQ("offline")).Count(ctx)
|
|
totalUsers, _ := c.User.Query().Count(ctx)
|
|
activeProxies, _ := c.Proxy.Query().Where(proxy.StatusEQ("active")).Count(ctx)
|
|
|
|
allProxies, _ := c.Proxy.Query().All(ctx)
|
|
typeCounts := map[string]int{}
|
|
for _, p := range allProxies {
|
|
typeCounts[p.ProxyType]++
|
|
}
|
|
breakdown := make([]proxyBreakdown, 0, len(typeCounts))
|
|
for t, n := range typeCounts {
|
|
breakdown = append(breakdown, proxyBreakdown{Type: t, Count: n})
|
|
}
|
|
|
|
renderPage(w, r, "Dashboard", "dashboard.html", map[string]any{
|
|
"ActiveProxies": activeProxies,
|
|
"TotalClients": totalClients,
|
|
"TotalUsers": totalUsers,
|
|
"Uptime": "—",
|
|
"OnlineClients": onlineClients,
|
|
"OfflineClients": offlineClients,
|
|
"ProxyBreakdown": breakdown,
|
|
})
|
|
}
|
|
|
|
type proxyBreakdown struct {
|
|
Type string
|
|
Count int
|
|
}
|
|
|
|
type clientRow struct {
|
|
ID int
|
|
Name string
|
|
Addr string
|
|
Port int
|
|
Key string
|
|
Status string
|
|
LastSeen string
|
|
}
|
|
|
|
func (h *Handler) handleClients(w http.ResponseWriter, r *http.Request) {
|
|
ctx := context.Background()
|
|
all, err := db.Client().FrpcClient.Query().Order(ent.Desc(frpcclient.FieldCreatedAt)).All(ctx)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
rows := make([]clientRow, 0, len(all))
|
|
for _, c := range all {
|
|
lastSeen := ""
|
|
if !c.LastSeen.IsZero() {
|
|
lastSeen = c.LastSeen.Format("2006-01-02 15:04")
|
|
}
|
|
rows = append(rows, clientRow{
|
|
ID: c.ID, Name: c.Name, Addr: c.Addr, Port: c.Port,
|
|
Key: c.Key, Status: c.Status, LastSeen: lastSeen,
|
|
})
|
|
}
|
|
|
|
renderPage(w, r, "Clients", "clients.html", map[string]any{"Clients": rows})
|
|
}
|
|
|
|
func (h *Handler) handleClientDetail(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
id, err := strconv.Atoi(vars["id"])
|
|
if err != nil {
|
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
c, err := db.Client().FrpcClient.Get(ctx, id)
|
|
if err != nil {
|
|
http.Error(w, "client not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
proxies, _ := db.Client().Proxy.Query().Where(proxy.ClientIDEQ(c.ID)).All(ctx)
|
|
|
|
type proxyRow struct {
|
|
ID int
|
|
Name string
|
|
ProxyType string
|
|
LocalIP string
|
|
LocalPort int
|
|
RemotePort int
|
|
Status string
|
|
}
|
|
|
|
prows := make([]proxyRow, 0, len(proxies))
|
|
for _, p := range proxies {
|
|
prows = append(prows, proxyRow{
|
|
ID: p.ID, Name: p.Name, ProxyType: p.ProxyType,
|
|
LocalIP: p.LocalIP, LocalPort: p.LocalPort,
|
|
RemotePort: p.RemotePort, Status: p.Status,
|
|
})
|
|
}
|
|
|
|
lastSeen := ""
|
|
if !c.LastSeen.IsZero() {
|
|
lastSeen = c.LastSeen.Format("2006-01-02 15:04")
|
|
}
|
|
|
|
renderPage(w, r, "Client: "+c.Name, "client_detail.html", map[string]any{
|
|
"Client": map[string]any{
|
|
"ID": c.ID, "Name": c.Name, "Addr": c.Addr, "Port": c.Port,
|
|
"Key": c.Key, "Status": c.Status,
|
|
"CreatedAt": c.CreatedAt.Format("2006-01-02 15:04"),
|
|
"LastSeen": lastSeen, "Metadata": c.Metadata,
|
|
},
|
|
"Proxies": prows,
|
|
})
|
|
}
|
|
|
|
type proxyRow struct {
|
|
ID int
|
|
Name string
|
|
ProxyType string
|
|
ClientName string
|
|
LocalIP string
|
|
LocalPort int
|
|
RemotePort int
|
|
Status string
|
|
}
|
|
|
|
func (h *Handler) handleProxies(w http.ResponseWriter, r *http.Request) {
|
|
ctx := context.Background()
|
|
all, err := db.Client().Proxy.Query().Order(ent.Desc(proxy.FieldCreatedAt)).All(ctx)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
rows := make([]proxyRow, 0, len(all))
|
|
for _, p := range all {
|
|
clientName := ""
|
|
if p.ClientID > 0 {
|
|
c, err := db.Client().FrpcClient.Get(ctx, p.ClientID)
|
|
if err == nil {
|
|
clientName = c.Name
|
|
}
|
|
}
|
|
rows = append(rows, proxyRow{
|
|
ID: p.ID, Name: p.Name, ProxyType: p.ProxyType,
|
|
ClientName: clientName, LocalIP: p.LocalIP,
|
|
LocalPort: p.LocalPort, RemotePort: p.RemotePort, Status: p.Status,
|
|
})
|
|
}
|
|
|
|
renderPage(w, r, "Proxies", "proxies.html", map[string]any{"Proxies": rows})
|
|
}
|
|
|
|
func (h *Handler) handleProxyDetail(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
id, err := strconv.Atoi(vars["id"])
|
|
if err != nil {
|
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
p, err := db.Client().Proxy.Get(ctx, id)
|
|
if err != nil {
|
|
http.Error(w, "proxy not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
clientName := ""
|
|
if p.ClientID > 0 {
|
|
c, err := db.Client().FrpcClient.Get(ctx, p.ClientID)
|
|
if err == nil {
|
|
clientName = c.Name
|
|
}
|
|
}
|
|
|
|
renderPage(w, r, "Proxy: "+p.Name, "proxy_detail.html", map[string]any{
|
|
"Proxy": map[string]any{
|
|
"ID": p.ID, "Name": p.Name, "ProxyType": p.ProxyType,
|
|
"LocalIP": p.LocalIP, "LocalPort": p.LocalPort,
|
|
"RemotePort": p.RemotePort, "Status": p.Status,
|
|
"CustomDomains": p.CustomDomains, "ClientID": p.ClientID,
|
|
"CreatedAt": p.CreatedAt.Format("2006-01-02 15:04"),
|
|
"Metadata": p.Metadata,
|
|
},
|
|
"ClientName": clientName,
|
|
})
|
|
}
|
|
|
|
func generateClientKey() string {
|
|
b := make([]byte, 16)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func (h *Handler) handleNewClient(w http.ResponseWriter, r *http.Request) {
|
|
renderPage(w, r, "New Client", "client_new.html", nil)
|
|
}
|
|
|
|
func (h *Handler) handleNewClientPost(w http.ResponseWriter, r *http.Request) {
|
|
r.ParseForm()
|
|
name := r.FormValue("name")
|
|
if name == "" {
|
|
renderPage(w, r, "New Client", "client_new.html", map[string]any{
|
|
"Error": "Client name is required.",
|
|
})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
key := generateClientKey()
|
|
c, err := db.Client().FrpcClient.Create().
|
|
SetName(name).
|
|
SetKey(key).
|
|
SetStatus("offline").
|
|
SetMetadata("{}").
|
|
Save(ctx)
|
|
if err != nil {
|
|
if ent.IsConstraintError(err) {
|
|
renderPage(w, r, "New Client", "client_new.html", map[string]any{
|
|
"Error": "A client with this name already exists.",
|
|
})
|
|
return
|
|
}
|
|
renderPage(w, r, "New Client", "client_new.html", map[string]any{
|
|
"Error": "Failed to create client: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, fmt.Sprintf("/admin/clients/%d/provision", c.ID), http.StatusFound)
|
|
}
|
|
|
|
func (h *Handler) handleClientProvision(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
id, err := strconv.Atoi(vars["id"])
|
|
if err != nil {
|
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
c, err := db.Client().FrpcClient.Get(ctx, id)
|
|
if err != nil {
|
|
http.Error(w, "client not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
cfg, err := db.LoadServerConfig()
|
|
if err != nil {
|
|
http.Error(w, "failed to load config", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
serverAddr := cfg.BindAddr
|
|
serverPort := cfg.BindPort
|
|
authToken := cfg.Auth.Token
|
|
|
|
frpcConfig := fmt.Sprintf(`serverAddr = %q
|
|
serverPort = %d
|
|
auth.token = %q
|
|
`, serverAddr, serverPort, authToken)
|
|
|
|
oneLiner := fmt.Sprintf(`curl -sL https://github.com/fatedier/frp/releases/latest/download/frp_linux_amd64.tar.gz | tar xz && cat > frpc.toml << 'EOF'
|
|
%s
|
|
EOF
|
|
./frp_*/frpc -c frpc.toml`, frpcConfig)
|
|
|
|
renderPage(w, r, "Provision: "+c.Name, "client_provision.html", map[string]any{
|
|
"Client": map[string]any{
|
|
"ID": c.ID,
|
|
"Name": c.Name,
|
|
"Key": c.Key,
|
|
},
|
|
"ServerAddr": serverAddr,
|
|
"ServerPort": serverPort,
|
|
"AuthToken": authToken,
|
|
"FrpcConfig": frpcConfig,
|
|
"OneLiner": oneLiner,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) handleClientConfigDownload(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
id, err := strconv.Atoi(vars["id"])
|
|
if err != nil {
|
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
c, err := db.Client().FrpcClient.Get(ctx, id)
|
|
if err != nil {
|
|
http.Error(w, "client not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
cfg, err := db.LoadServerConfig()
|
|
if err != nil {
|
|
http.Error(w, "failed to load config", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
proxies, _ := db.Client().Proxy.Query().Where(proxy.ClientIDEQ(c.ID)).All(ctx)
|
|
|
|
content := fmt.Sprintf(`# frpc config for %s
|
|
# Auto-generated by frp admin dashboard
|
|
|
|
serverAddr = %q
|
|
serverPort = %d
|
|
auth.token = %q
|
|
`, c.Name, cfg.BindAddr, cfg.BindPort, cfg.Auth.Token)
|
|
|
|
for _, p := range proxies {
|
|
content += fmt.Sprintf(`
|
|
[[proxies]]
|
|
name = %q
|
|
type = %q
|
|
localIP = %q
|
|
localPort = %d
|
|
remotePort = %d
|
|
`, p.Name, p.ProxyType, p.LocalIP, p.LocalPort, p.RemotePort)
|
|
if p.CustomDomains != "" {
|
|
content += fmt.Sprintf("customDomains = [%q]\n", p.CustomDomains)
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=frpc-%s.toml", c.Name))
|
|
w.Write([]byte(content))
|
|
}
|
|
|
|
func (h *Handler) handleClientProvisionScript(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
id, err := strconv.Atoi(vars["id"])
|
|
if err != nil {
|
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
c, err := db.Client().FrpcClient.Get(ctx, id)
|
|
if err != nil {
|
|
http.Error(w, "client not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
key := c.Key
|
|
serverHost := r.Host
|
|
|
|
script := fmt.Sprintf(`#!/bin/bash
|
|
# frp auto-provisioning for: %s
|
|
set -e
|
|
CONFIG_URL="http://%s/admin/api/frpc/proxy-config/%s"
|
|
echo "==> Downloading frpc with server-config support..."
|
|
curl -sL https://github.com/fatedier/frp/releases/latest/download/frp_linux_amd64.tar.gz | tar xz
|
|
FRPC_DIR=$(ls -d frp_* 2>/dev/null | head -1)
|
|
echo "==> Starting frpc for %s..."
|
|
"$FRPC_DIR/frpc" --server-config "$CONFIG_URL"
|
|
`, c.Name, serverHost, key, c.Name)
|
|
|
|
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=provision-%s.sh", c.Name))
|
|
w.Write([]byte(script))
|
|
}
|
|
|
|
func (h *Handler) handleClientGenerateToken(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
id, err := strconv.Atoi(vars["id"])
|
|
if err != nil {
|
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
c, err := db.Client().FrpcClient.Get(ctx, id)
|
|
if err != nil {
|
|
http.Error(w, "client not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
tokenBytes := make([]byte, 32)
|
|
rand.Read(tokenBytes)
|
|
token := hex.EncodeToString(tokenBytes)
|
|
expires := time.Now().Add(30 * time.Minute).Unix()
|
|
|
|
meta := map[string]any{}
|
|
if c.Metadata != "" && c.Metadata != "{}" {
|
|
json.Unmarshal([]byte(c.Metadata), &meta)
|
|
}
|
|
meta["one_time_token"] = token
|
|
meta["one_time_token_expires"] = expires
|
|
metaBytes, _ := json.Marshal(meta)
|
|
|
|
db.Client().FrpcClient.Update().SetMetadata(string(metaBytes)).Where(frpcclient.ID(id)).Exec(ctx)
|
|
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
html := fmt.Sprintf(`<div style="padding:8px;border:1px solid var(--border);font-size:12px">
|
|
<p><strong>One-Time Token:</strong></p>
|
|
<pre class="text-mono" style="margin:4px 0;padding:8px;background:var(--bg2);border:1px solid var(--border);word-break:break-all">%s</pre>
|
|
<p style="color:var(--fg2);margin-top:4px">Valid for 30 minutes. Use with: <code class="text-mono">frpc auth token %s --server http://%s</code></p>
|
|
</div>`, token, token, r.Host)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Write([]byte(html))
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"token": token,
|
|
"expires": expires,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) handleClientAuth(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Token string `json:"token"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
ClientName string `json:"client_name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// One-time token auth
|
|
if req.Token != "" {
|
|
all, err := db.Client().FrpcClient.Query().All(ctx)
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
for _, c := range all {
|
|
meta := map[string]any{}
|
|
if c.Metadata != "" && c.Metadata != "{}" {
|
|
json.Unmarshal([]byte(c.Metadata), &meta)
|
|
}
|
|
storedToken, _ := meta["one_time_token"].(string)
|
|
if storedToken == "" || storedToken != req.Token {
|
|
continue
|
|
}
|
|
expires, _ := meta["one_time_token_expires"].(float64)
|
|
if time.Now().Unix() > int64(expires) {
|
|
http.Error(w, "token expired", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Clear the one-time token (one-time use)
|
|
delete(meta, "one_time_token")
|
|
delete(meta, "one_time_token_expires")
|
|
metaBytes, _ := json.Marshal(meta)
|
|
db.Client().FrpcClient.Update().SetMetadata(string(metaBytes)).Where(frpcclient.ID(c.ID)).Exec(ctx)
|
|
|
|
h.writeClientConfig(w, ctx, c.ID)
|
|
return
|
|
}
|
|
|
|
http.Error(w, "invalid token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Login auth
|
|
if req.Username != "" && req.Password != "" {
|
|
u, err := db.Client().User.Query().Where(user.UsernameEQ(req.Username)).Only(ctx)
|
|
if err != nil {
|
|
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(req.Password)); err != nil {
|
|
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if req.ClientName == "" {
|
|
// Return list of client names
|
|
all, _ := db.Client().FrpcClient.Query().Select(frpcclient.FieldName, frpcclient.FieldID).All(ctx)
|
|
names := make([]map[string]any, 0, len(all))
|
|
for _, c := range all {
|
|
names = append(names, map[string]any{"id": c.ID, "name": c.Name})
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{"clients": names, "requires_client_name": true})
|
|
return
|
|
}
|
|
|
|
client, err := db.Client().FrpcClient.Query().Where(frpcclient.NameEQ(req.ClientName)).Only(ctx)
|
|
if err != nil {
|
|
http.Error(w, "client not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
h.writeClientConfig(w, ctx, client.ID)
|
|
return
|
|
}
|
|
|
|
http.Error(w, "provide token or username/password", http.StatusBadRequest)
|
|
}
|
|
|
|
func (h *Handler) writeClientConfig(w http.ResponseWriter, ctx context.Context, clientID int) {
|
|
c, err := db.Client().FrpcClient.Get(ctx, clientID)
|
|
if err != nil {
|
|
http.Error(w, "client not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
cfg, err := db.LoadServerConfig()
|
|
if err != nil {
|
|
http.Error(w, "server config error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
proxies, _ := db.Client().Proxy.Query().Where(proxy.ClientIDEQ(c.ID)).All(ctx)
|
|
|
|
content := fmt.Sprintf(`# frpc config for %s
|
|
# Auto-generated by frp admin server
|
|
|
|
serverAddr = %q
|
|
serverPort = %d
|
|
auth.token = %q
|
|
`, c.Name, cfg.BindAddr, cfg.BindPort, cfg.Auth.Token)
|
|
|
|
for _, p := range proxies {
|
|
content += fmt.Sprintf(`
|
|
[[proxies]]
|
|
name = %q
|
|
type = %q
|
|
localIP = %q
|
|
localPort = %d
|
|
remotePort = %d
|
|
`, p.Name, p.ProxyType, p.LocalIP, p.LocalPort, p.RemotePort)
|
|
if p.CustomDomains != "" {
|
|
content += fmt.Sprintf("customDomains = [%q]\n", p.CustomDomains)
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Write([]byte(content))
|
|
}
|
|
|
|
func (h *Handler) handleFrpcProxyConfig(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
key := vars["key"]
|
|
if key == "" {
|
|
http.Error(w, "missing key", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
client, err := db.Client().FrpcClient.Query().Where(frpcclient.KeyEQ(key)).Only(ctx)
|
|
if err != nil {
|
|
http.Error(w, "invalid key", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
cfg, err := db.LoadServerConfig()
|
|
if err != nil {
|
|
http.Error(w, "server config error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
proxies, _ := db.Client().Proxy.Query().Where(proxy.ClientIDEQ(client.ID)).All(ctx)
|
|
|
|
content := fmt.Sprintf(`# frpc config for %s
|
|
# Auto-generated by frp admin server
|
|
|
|
serverAddr = %q
|
|
serverPort = %d
|
|
auth.token = %q
|
|
`, client.Name, cfg.BindAddr, cfg.BindPort, cfg.Auth.Token)
|
|
|
|
for _, p := range proxies {
|
|
content += fmt.Sprintf(`
|
|
[[proxies]]
|
|
name = %q
|
|
type = %q
|
|
localIP = %q
|
|
localPort = %d
|
|
remotePort = %d
|
|
`, p.Name, p.ProxyType, p.LocalIP, p.LocalPort, p.RemotePort)
|
|
if p.CustomDomains != "" {
|
|
content += fmt.Sprintf("customDomains = [%q]\n", p.CustomDomains)
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Write([]byte(content))
|
|
}
|
|
|
|
func (h *Handler) handleNewProxy(w http.ResponseWriter, r *http.Request) {
|
|
ctx := context.Background()
|
|
allClients, err := db.Client().FrpcClient.Query().Order(ent.Asc(frpcclient.FieldName)).All(ctx)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
selectedClient, _ := strconv.Atoi(r.URL.Query().Get("client_id"))
|
|
|
|
renderPage(w, r, "New Proxy", "proxy_new.html", map[string]any{
|
|
"Clients": allClients,
|
|
"SelectedClient": selectedClient,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) handleNewProxyPost(w http.ResponseWriter, r *http.Request) {
|
|
r.ParseForm()
|
|
ctx := context.Background()
|
|
|
|
name := r.FormValue("name")
|
|
proxyType := r.FormValue("proxy_type")
|
|
localIP := r.FormValue("local_ip")
|
|
localPortS := r.FormValue("local_port")
|
|
remotePortS := r.FormValue("remote_port")
|
|
clientIDS := r.FormValue("client_id")
|
|
customDomains := r.FormValue("custom_domains")
|
|
|
|
if name == "" || localIP == "" || localPortS == "" || clientIDS == "" {
|
|
allClients, _ := db.Client().FrpcClient.Query().Order(ent.Asc(frpcclient.FieldName)).All(ctx)
|
|
renderPage(w, r, "New Proxy", "proxy_new.html", map[string]any{
|
|
"Clients": allClients,
|
|
"Error": "Name, local IP, local port, and client are required.",
|
|
})
|
|
return
|
|
}
|
|
|
|
localPort := atoi(localPortS, 0)
|
|
remotePort := atoi(remotePortS, 0)
|
|
clientID := atoi(clientIDS, 0)
|
|
|
|
if localPort == 0 {
|
|
allClients, _ := db.Client().FrpcClient.Query().Order(ent.Asc(frpcclient.FieldName)).All(ctx)
|
|
renderPage(w, r, "New Proxy", "proxy_new.html", map[string]any{
|
|
"Clients": allClients,
|
|
"Error": "Invalid local port.",
|
|
})
|
|
return
|
|
}
|
|
|
|
if proxyType == "" {
|
|
proxyType = "tcp"
|
|
}
|
|
|
|
_, err := db.Client().Proxy.Create().
|
|
SetName(name).
|
|
SetProxyType(proxyType).
|
|
SetLocalIP(localIP).
|
|
SetLocalPort(localPort).
|
|
SetRemotePort(remotePort).
|
|
SetClientID(clientID).
|
|
SetCustomDomains(customDomains).
|
|
SetStatus("inactive").
|
|
SetMetadata("{}").
|
|
Save(ctx)
|
|
if err != nil {
|
|
allClients, _ := db.Client().FrpcClient.Query().Order(ent.Asc(frpcclient.FieldName)).All(ctx)
|
|
renderPage(w, r, "New Proxy", "proxy_new.html", map[string]any{
|
|
"Clients": allClients,
|
|
"Error": "Failed to create proxy: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/admin/proxies", http.StatusFound)
|
|
}
|
|
|
|
func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
|
|
cfg, err := db.LoadServerConfig()
|
|
if err != nil {
|
|
http.Error(w, "failed to load config", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
renderPage(w, r, "Settings", "settings.html", map[string]any{
|
|
"Config": cfg,
|
|
"Saved": r.URL.Query().Get("saved") == "1",
|
|
})
|
|
}
|
|
|
|
func (h *Handler) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
|
|
r.ParseForm()
|
|
|
|
cfg, err := db.LoadServerConfig()
|
|
if err != nil {
|
|
http.Error(w, "failed to load config", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if v := r.FormValue("bind_addr"); v != "" {
|
|
cfg.BindAddr = v
|
|
}
|
|
if v := r.FormValue("bind_port"); v != "" {
|
|
cfg.BindPort = atoi(v, 7000)
|
|
}
|
|
if v := r.FormValue("dashboard_addr"); v != "" {
|
|
cfg.WebServer.Addr = v
|
|
}
|
|
if v := r.FormValue("dashboard_port"); v != "" {
|
|
cfg.WebServer.Port = atoi(v, 7500)
|
|
}
|
|
if v := r.FormValue("dashboard_user"); v != "" {
|
|
cfg.WebServer.User = v
|
|
}
|
|
if v := r.FormValue("dashboard_password"); v != "" {
|
|
cfg.WebServer.Password = v
|
|
}
|
|
if v := r.FormValue("auth_token"); v != "" {
|
|
cfg.Auth.Token = v
|
|
}
|
|
if v := r.FormValue("auth_method"); v != "" {
|
|
cfg.Auth.Method = v1.AuthMethod(v)
|
|
}
|
|
if v := r.FormValue("log_to"); v != "" {
|
|
cfg.Log.To = v
|
|
}
|
|
if v := r.FormValue("log_level"); v != "" {
|
|
cfg.Log.Level = v
|
|
}
|
|
if v := r.FormValue("log_max_days"); v != "" {
|
|
cfg.Log.MaxDays = int64(atoi(v, 3))
|
|
}
|
|
if v := r.FormValue("kcp_bind_port"); v != "" {
|
|
cfg.KCPBindPort = atoi(v, 0)
|
|
}
|
|
if v := r.FormValue("quic_bind_port"); v != "" {
|
|
cfg.QUICBindPort = atoi(v, 0)
|
|
}
|
|
if v := r.FormValue("vhost_http_port"); v != "" {
|
|
cfg.VhostHTTPPort = atoi(v, 0)
|
|
}
|
|
if v := r.FormValue("vhost_https_port"); v != "" {
|
|
cfg.VhostHTTPSPort = atoi(v, 0)
|
|
}
|
|
if v := r.FormValue("subdomain_host"); v != "" {
|
|
cfg.SubDomainHost = v
|
|
}
|
|
if r.FormValue("tcp_mux") == "true" {
|
|
val := true
|
|
cfg.Transport.TCPMux = &val
|
|
} else if r.Form.Has("tcp_mux") {
|
|
val := false
|
|
cfg.Transport.TCPMux = &val
|
|
}
|
|
if v := r.FormValue("max_pool_count"); v != "" {
|
|
cfg.Transport.MaxPoolCount = int64(atoi(v, 0))
|
|
}
|
|
if v := r.FormValue("proxy_bind_addr"); v != "" {
|
|
cfg.ProxyBindAddr = v
|
|
}
|
|
cfg.EnablePrometheus = r.FormValue("enable_prometheus") == "true"
|
|
|
|
if err := db.SaveServerConfig(cfg); err != nil {
|
|
http.Error(w, "failed to save config: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/admin/settings?saved=1", http.StatusFound)
|
|
}
|
|
|
|
func atoi(s string, defaultVal int) int {
|
|
v := 0
|
|
fmt.Sscanf(s, "%d", &v)
|
|
if v == 0 {
|
|
return defaultVal
|
|
}
|
|
return v
|
|
}
|
|
|
|
func renderPage(w http.ResponseWriter, r *http.Request, title, tmplName string, data map[string]any) {
|
|
content := renderToString(tmplName, data)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
w.Write([]byte(content))
|
|
} else {
|
|
if err := renderLayout(w, title, template.HTML(content)); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|