feat: ent ORM, admin UI, client auth, Fyne GUI, Windows/MSI packaging

This commit is contained in:
kannn
2026-05-29 08:58:22 +00:00
Unverified
parent 8563a5fc74
commit a0a42a4966
81 changed files with 17144 additions and 89 deletions
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
package admin
import (
"bytes"
"embed"
"html/template"
"io"
"strings"
)
//go:embed templates/*.html
var templateFS embed.FS
var tmpl *template.Template
func init() {
funcMap := template.FuncMap{
"lower": strings.ToLower,
}
tmpl = template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
}
func renderTemplate(w io.Writer, name string, data map[string]any) error {
return tmpl.ExecuteTemplate(w, name, data)
}
func renderToString(name string, data map[string]any) string {
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "<p>Error rendering template</p>"
}
return buf.String()
}
func renderLayout(w io.Writer, title string, content template.HTML) error {
return tmpl.ExecuteTemplate(w, "layout.html", map[string]any{
"Title": title,
"Content": content,
})
}
+46
View File
@@ -0,0 +1,46 @@
<div class="toolbar">
<h1>Client: {{.Client.Name}}</h1>
<div class="spacer"></div>
<a href="/admin/clients/{{.Client.ID}}/provision" class="btn btn-sm" hx-get="/admin/clients/{{.Client.ID}}/provision" hx-target="#main-content" hx-push-url="true">Provision</a>
<a href="/admin/clients" class="btn btn-sm btn-outline" hx-get="/admin/clients" hx-target="#main-content" hx-push-url="true">Back</a>
</div>
<div class="grid-2 mb-16">
<div class="card">
<h3>Details</h3>
<table style="margin-top:8px">
<tbody>
<tr><td style="width:120px;color:var(--fg2);font-size:12px">Status</td><td>{{if eq .Client.Status "online"}}<span style="font-weight:600">Online</span>{{else}}<span style="color:var(--fg2)">Offline</span>{{end}}</td></tr>
<tr><td style="color:var(--fg2);font-size:12px">Address</td><td class="text-mono">{{if .Client.Addr}}{{.Client.Addr}}:{{.Client.Port}}{{else}}—{{end}}</td></tr>
<tr><td style="color:var(--fg2);font-size:12px">Auth Key</td><td class="text-mono">{{.Client.Key}}</td></tr>
<tr><td style="color:var(--fg2);font-size:12px">Created</td><td>{{.Client.CreatedAt}}</td></tr>
<tr><td style="color:var(--fg2);font-size:12px">Last Seen</td><td>{{if .Client.LastSeen}}{{.Client.LastSeen}}{{else}}—{{end}}</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h3>One-Time Token</h3>
<p style="font-size:12px;color:var(--fg2);margin-bottom:8px">Generate a one-time token for client auth:</p>
<button class="btn btn-sm" hx-post="/admin/clients/{{.Client.ID}}/generate-token" hx-target="#token-result" hx-swap="innerHTML">Generate Token</button>
<div id="token-result" style="margin-top:8px"></div>
</div>
</div>
<h2 style="font-size:16px;font-weight:600;margin-bottom:12px">Proxies</h2>
<table>
<thead>
<tr><th>Name</th><th>Type</th><th>Local</th><th>Remote</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{{range .Proxies}}
<tr>
<td><a href="/admin/proxies/{{.ID}}" hx-get="/admin/proxies/{{.ID}}" hx-target="#main-content" hx-push-url="true">{{.Name}}</a></td>
<td style="color:var(--fg2)">{{.ProxyType}}</td>
<td class="text-mono">{{.LocalIP}}:{{.LocalPort}}</td>
<td class="text-mono">{{if .RemotePort}}{{.RemotePort}}{{else}}—{{end}}</td>
<td>{{if eq .Status "active"}}<span style="font-weight:600">active</span>{{else}}<span style="color:var(--fg2)">{{.Status}}</span>{{end}}</td>
<td><a href="/admin/proxies/{{.ID}}" class="btn btn-sm btn-outline" hx-get="/admin/proxies/{{.ID}}" hx-target="#main-content" hx-push-url="true">View</a></td>
</tr>
{{else}}
<tr><td colspan="6"><div class="empty">No proxies for this client. <a href="/admin/proxies/new?client_id={{$.Client.ID}}" hx-get="/admin/proxies/new?client_id={{$.Client.ID}}" hx-target="#main-content" hx-push-url="true">Create one</a>.</div></td></tr>
{{end}}
</tbody>
</table>
+18
View File
@@ -0,0 +1,18 @@
<div class="toolbar">
<h1>New Client</h1>
</div>
{{if .Error}}
<div style="margin-bottom:16px;padding:12px;border:1px solid var(--border);font-size:13px">{{.Error}}</div>
{{end}}
<form hx-post="/admin/clients/new" hx-target="body" hx-push-url="true" style="max-width:400px">
<div style="margin-bottom:16px">
<label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Client Name</label>
<input type="text" name="name" required autofocus placeholder="my-server">
</div>
<div class="toolbar">
<button type="submit">Create Client</button>
<a href="/admin/clients" class="btn btn-outline" hx-get="/admin/clients" hx-target="#main-content" hx-push-url="true">Cancel</a>
</div>
</form>
@@ -0,0 +1,39 @@
<div class="toolbar">
<h1>Provision: {{.Client.Name}}</h1>
<div class="spacer"></div>
<a href="/admin/clients/{{.Client.ID}}" class="btn btn-sm btn-outline" hx-get="/admin/clients/{{.Client.ID}}" hx-target="#main-content" hx-push-url="true">Back</a>
</div>
<div style="border:1px solid var(--border);padding:20px;margin-bottom:16px">
<h2 style="font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2);margin-bottom:12px;text-transform:uppercase">Client Details</h2>
<table>
<tr><td style="width:120px;color:var(--fg2);font-size:12px">Name</td><td>{{.Client.Name}}</td></tr>
<tr><td style="color:var(--fg2);font-size:12px">Key</td><td class="text-mono">{{.Client.Key}}</td></tr>
</table>
</div>
<div style="border:1px solid var(--border);padding:20px;margin-bottom:16px">
<h2 style="font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2);margin-bottom:12px;text-transform:uppercase">One-Liner Setup</h2>
<p style="font-size:12px;color:var(--fg2);margin-bottom:8px">Run this on your client machine. It downloads frpc and auto-updates from this server:</p>
<pre class="text-mono" style="padding:12px;background:var(--bg2);border:1px solid var(--border);overflow-x:auto;white-space:pre-wrap;word-break:break-all;font-size:12px">curl -sL https://github.com/fatedier/frp/releases/latest/download/frp_linux_amd64.tar.gz | tar xz \
&& ./frp_*/frpc --server-config http://{{.ServerAddr}}:{{.ServerPort}}/admin/api/frpc/proxy-config/{{.Client.Key}}</pre>
</div>
<div style="border:1px solid var(--border);padding:20px;margin-bottom:16px">
<h2 style="font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2);margin-bottom:12px;text-transform:uppercase">Download Install Script</h2>
<p style="font-size:12px;color:var(--fg2);margin-bottom:8px">Download a standalone provisioning script:</p>
<a href="/admin/clients/{{.Client.ID}}/provision.sh" class="btn btn-sm" download>Download provision-{{.Client.Name}}.sh</a>
</div>
<div style="border:1px solid var(--border);padding:20px;margin-bottom:16px">
<h2 style="font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2);margin-bottom:12px;text-transform:uppercase">Current Config</h2>
<pre class="text-mono" style="padding:12px;background:var(--bg2);border:1px solid var(--border);overflow-x:auto;white-space:pre-wrap;font-size:12px">{{.FrpcConfig}}</pre>
<div class="toolbar" style="margin-top:12px">
<a href="/admin/clients/{{.Client.ID}}/config" class="btn btn-sm" download>Download frpc.toml</a>
</div>
</div>
<div style="border:1px solid var(--border);padding:20px;margin-bottom:16px">
<h2 style="font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2);margin-bottom:12px;text-transform:uppercase">Add Proxies</h2>
<p style="font-size:13px;color:var(--fg2);margin-bottom:4px">Create proxies from the <a href="/admin/proxies/new?client_id={{.Client.ID}}" hx-get="/admin/proxies/new?client_id={{.Client.ID}}" hx-target="#main-content" hx-push-url="true">new proxy page</a>. The client will auto-reload within 30 seconds.</p>
</div>
+24
View File
@@ -0,0 +1,24 @@
<div class="toolbar">
<h1>Clients</h1>
<div class="spacer"></div>
<a href="/admin/clients/new" class="btn btn-sm" hx-get="/admin/clients/new" hx-target="#main-content" hx-push-url="true">New Client</a>
</div>
<table>
<thead>
<tr><th>Name</th><th>Address</th><th>Status</th><th>Key</th><th>Last Seen</th><th></th></tr>
</thead>
<tbody>
{{range .Clients}}
<tr>
<td><a href="/admin/clients/{{.ID}}" hx-get="/admin/clients/{{.ID}}" hx-target="#main-content" hx-push-url="true">{{.Name}}</a></td>
<td class="text-mono">{{if .Addr}}{{.Addr}}:{{.Port}}{{else}}—{{end}}</td>
<td>{{if eq .Status "online"}}<span style="font-weight:600">online</span>{{else}}<span style="color:var(--fg2)">offline</span>{{end}}</td>
<td class="text-mono" style="max-width:120px;overflow:hidden;text-overflow:ellipsis">{{.Key}}</td>
<td>{{if .LastSeen}}{{.LastSeen}}{{else}}—{{end}}</td>
<td><a href="/admin/clients/{{.ID}}/provision" class="btn btn-sm btn-outline" hx-get="/admin/clients/{{.ID}}/provision" hx-target="#main-content" hx-push-url="true">Provision</a></td>
</tr>
{{else}}
<tr><td colspan="6"><div class="empty">No clients registered. <a href="/admin/clients/new" hx-get="/admin/clients/new" hx-target="#main-content" hx-push-url="true">Create one</a>.</div></td></tr>
{{end}}
</tbody>
</table>
+36
View File
@@ -0,0 +1,36 @@
<h1>Dashboard</h1>
<div class="grid-4">
<div class="card">
<h3>Active Proxies</h3>
<div class="value">{{.ActiveProxies}}</div>
</div>
<div class="card">
<h3>Clients</h3>
<div class="value">{{.TotalClients}}</div>
</div>
<div class="card">
<h3>Users</h3>
<div class="value">{{.TotalUsers}}</div>
</div>
<div class="card">
<h3>Uptime</h3>
<div class="value" style="font-size:20px">{{.Uptime}}</div>
</div>
</div>
<div class="grid-2">
<div class="card">
<h3>Clients by Status</h3>
<table style="margin-top:8px">
<tr><td style="width:100px">Online</td><td>{{.OnlineClients}}</td></tr>
<tr><td>Offline</td><td>{{.OfflineClients}}</td></tr>
</table>
</div>
<div class="card">
<h3>Proxies by Type</h3>
<table style="margin-top:8px">
{{range .ProxyBreakdown}}
<tr><td style="width:100px;text-transform:capitalize">{{.Type}}</td><td>{{.Count}}</td></tr>
{{end}}
</table>
</div>
</div>
+87
View File
@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{if .Title}}{{.Title}} — {{end}}frp Admin</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#fff;--bg2:#f5f5f5;--fg:#000;--fg2:#888;--border:#ddd;--accent:#000;--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;--radius:0}
@media(prefers-color-scheme:dark){
:root{--bg:#000;--bg2:#111;--fg:#fff;--fg2:#666;--border:#333;--accent:#fff}
}
html,body{height:100%}
body{font-family:var(--font);background:var(--bg);color:var(--fg);font-size:14px;line-height:1.5}
a{color:var(--fg);text-decoration:underline}
a:hover{text-decoration:none}
table{width:100%;border-collapse:collapse}
th,td{padding:10px 12px;text-align:left;border-bottom:1px solid var(--border);font-size:13px}
th{font-weight:600;font-size:11px;letter-spacing:.3px;color:var(--fg2)}
tr:hover td{background:var(--bg2)}
input,select,textarea{padding:8px 12px;border:1px solid var(--border);background:var(--bg);color:var(--fg);font-size:13px;border-radius:var(--radius);width:100%;-webkit-appearance:none;appearance:none}
input:focus,select:focus,textarea:focus{outline:1px solid var(--fg)}
button,.btn{padding:8px 16px;border:1px solid var(--fg);background:var(--fg);color:var(--bg);font-size:13px;cursor:pointer;border-radius:var(--radius);font-family:var(--font)}
button:hover,.btn:hover{opacity:.8}
.btn-outline{background:transparent;color:var(--fg)}
.btn-outline:hover{background:var(--fg);color:var(--bg)}
.btn-sm{padding:4px 10px;font-size:12px}
.card{background:var(--bg2);border:1px solid var(--border);padding:20px}
.card h3{margin-bottom:8px;font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2)}
.card .value{font-size:28px;font-weight:700;color:var(--fg)}
.layout{display:flex;min-height:100vh}
.sidebar{width:220px;background:var(--bg2);border-right:1px solid var(--border);padding:24px 0;flex-shrink:0}
.sidebar .brand{padding:0 20px 24px;font-size:16px;font-weight:700;letter-spacing:-.5px;border-bottom:1px solid var(--border);margin-bottom:8px}
.sidebar nav a{display:block;padding:8px 20px;color:var(--fg2);font-size:13px;text-decoration:none}
.sidebar nav a:hover{color:var(--fg);background:var(--border)}
.main{flex:1;padding:24px 32px;max-width:1200px}
.main>h1{font-size:22px;font-weight:700;margin-bottom:24px}
.login-page{display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg2)}
.login-card{width:360px;padding:32px;background:var(--bg);border:1px solid var(--border)}
.login-card h1{font-size:20px;margin-bottom:24px;text-align:center;letter-spacing:-.5px}
.login-card .field{margin-bottom:16px}
.login-card .field label{display:block;font-size:12px;color:var(--fg2);margin-bottom:4px}
.login-card .error{color:var(--fg);font-size:13px;margin-bottom:12px;text-align:center}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px}
.grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:24px}
.grid-4{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:24px}
.toolbar{display:flex;gap:8px;margin-bottom:16px;align-items:center}
.toolbar .spacer{flex:1}
.flex{display:flex;gap:8px;align-items:center}
.text-right{text-align:right}
.text-mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;font-size:12px}
.mb-8{margin-bottom:8px;margin-top:0!important}
.mt-8{margin-top:8px}
.mb-16{margin-bottom:16px}
.empty{padding:40px;text-align:center;color:var(--fg2);font-size:14px}
@media(max-width:768px){
.sidebar{width:56px;overflow:hidden}
.sidebar .brand{font-size:0;padding:12px 16px}
.sidebar .brand::after{content:"F";font-size:20px}
.sidebar nav a{font-size:0;padding:12px 16px;text-align:center}
.sidebar nav a::after{content:attr(data-label);font-size:13px;display:block;text-align:center}
.sidebar nav a[data-label]::first-letter{font-size:14px}
.main{padding:16px}
.grid-2,.grid-3,.grid-4{grid-template-columns:1fr}
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="brand">frp Admin</div>
<nav>
<a href="/admin/dashboard" data-label="Dashboard" hx-get="/admin/dashboard" hx-target="#main-content" hx-push-url="true">Dashboard</a>
<a href="/admin/clients" data-label="Clients" hx-get="/admin/clients" hx-target="#main-content" hx-push-url="true">Clients</a>
<a href="/admin/proxies" data-label="Proxies" hx-get="/admin/proxies" hx-target="#main-content" hx-push-url="true">Proxies</a>
<a href="/admin/settings" data-label="Settings" hx-get="/admin/settings" hx-target="#main-content" hx-push-url="true">Settings</a>
<hr style="border:none;border-top:1px solid var(--border);margin:8px 20px">
<a href="/admin/logout" data-label="Logout" hx-post="/admin/logout" hx-trigger="click" hx-target="body" hx-push-url="true">Logout</a>
</nav>
</aside>
<main class="main" id="main-content">
{{.Content}}
</main>
</div>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Login — frp Admin</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#fff;--bg2:#f5f5f5;--fg:#000;--fg2:#888;--border:#ddd;--accent:#000;--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;--radius:0}
@media(prefers-color-scheme:dark){
:root{--bg:#000;--bg2:#111;--fg:#fff;--fg2:#666;--border:#333;--accent:#fff}
}
html,body{height:100%}
body{font-family:var(--font);background:var(--bg2);color:var(--fg);font-size:14px;display:flex;align-items:center;justify-content:center;min-height:100vh}
.login-card{width:360px;padding:32px;background:var(--bg);border:1px solid var(--border)}
.login-card h1{font-size:20px;margin-bottom:24px;text-align:center;letter-spacing:-.5px}
.login-card .field{margin-bottom:16px}
.login-card .field label{display:block;font-size:12px;color:var(--fg2);margin-bottom:4px}
.login-card .error{font-size:13px;margin-bottom:12px;text-align:center}
input{padding:8px 12px;border:1px solid var(--border);background:var(--bg);color:var(--fg);font-size:13px;width:100%;-webkit-appearance:none;appearance:none}
input:focus{outline:1px solid var(--fg)}
button{padding:8px 16px;border:1px solid var(--fg);background:var(--fg);color:var(--bg);font-size:13px;cursor:pointer;font-family:var(--font);width:100%}
button:hover{opacity:.8}
</style>
</head>
<body>
<div class="login-card">
<h1>frp Admin</h1>
{{if .Error}}
<div class="error">{{.Error}}</div>
{{end}}
<form method="post" action="/admin/login">
<div class="field">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Sign In</button>
</form>
</div>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<div class="toolbar">
<h1>Proxies</h1>
<div class="spacer"></div>
<a href="/admin/proxies/new" class="btn btn-sm" hx-get="/admin/proxies/new" hx-target="#main-content" hx-push-url="true">New Proxy</a>
</div>
<table>
<thead>
<tr><th>Name</th><th>Type</th><th>Client</th><th>Local</th><th>Remote</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{{range .Proxies}}
<tr>
<td><a href="/admin/proxies/{{.ID}}" hx-get="/admin/proxies/{{.ID}}" hx-target="#main-content" hx-push-url="true">{{.Name}}</a></td>
<td style="color:var(--fg2)">{{.ProxyType}}</td>
<td>{{.ClientName}}</td>
<td class="text-mono">{{.LocalIP}}:{{.LocalPort}}</td>
<td class="text-mono">{{if .RemotePort}}{{.RemotePort}}{{else}}—{{end}}</td>
<td>{{if eq .Status "active"}}<span style="font-weight:600">active</span>{{else if eq .Status "inactive"}}<span style="color:var(--fg2)">inactive</span>{{else}}<span style="color:var(--fg2)">{{.Status}}</span>{{end}}</td>
<td><a href="/admin/proxies/{{.ID}}" class="btn btn-sm btn-outline" hx-get="/admin/proxies/{{.ID}}" hx-target="#main-content" hx-push-url="true">View</a></td>
</tr>
{{else}}
<tr><td colspan="7"><div class="empty">No proxies found. <a href="/admin/proxies/new" hx-get="/admin/proxies/new" hx-target="#main-content" hx-push-url="true">Create one</a>.</div></td></tr>
{{end}}
</tbody>
</table>
+35
View File
@@ -0,0 +1,35 @@
<div class="toolbar">
<h1>Proxy: {{.Proxy.Name}}</h1>
<div class="spacer"></div>
<a href="/admin/proxies" class="btn btn-sm btn-outline" hx-get="/admin/proxies" hx-target="#main-content" hx-push-url="true">Back</a>
</div>
<div class="grid-2">
<div class="card">
<h3>Configuration</h3>
<table style="margin-top:8px">
<tbody>
<tr><td style="width:120px;color:var(--fg2);font-size:12px">Type</td><td style="color:var(--fg2)">{{.Proxy.ProxyType}}</td></tr>
<tr><td style="color:var(--fg2);font-size:12px">Status</td><td>{{if eq .Proxy.Status "active"}}<span style="font-weight:600">Active</span>{{else}}<span style="color:var(--fg2)">{{.Proxy.Status}}</span>{{end}}</td></tr>
<tr><td style="color:var(--fg2);font-size:12px">Local</td><td class="text-mono">{{.Proxy.LocalIP}}:{{.Proxy.LocalPort}}</td></tr>
<tr><td style="color:var(--fg2);font-size:12px">Remote Port</td><td class="text-mono">{{if .Proxy.RemotePort}}{{.Proxy.RemotePort}}{{else}}—{{end}}</td></tr>
<tr><td style="color:var(--fg2);font-size:12px">Custom Domains</td><td>{{if .Proxy.CustomDomains}}{{.Proxy.CustomDomains}}{{else}}—{{end}}</td></tr>
<tr><td style="color:var(--fg2);font-size:12px">Created</td><td>{{.Proxy.CreatedAt}}</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h3>Client</h3>
<table style="margin-top:8px">
<tbody>
<tr><td style="width:120px;color:var(--fg2);font-size:12px">Client ID</td><td class="text-mono">{{.Proxy.ClientID}}</td></tr>
{{if .ClientName}}
<tr><td style="color:var(--fg2);font-size:12px">Name</td><td><a href="/admin/clients/{{.Proxy.ClientID}}" hx-get="/admin/clients/{{.Proxy.ClientID}}" hx-target="#main-content" hx-push-url="true">{{.ClientName}}</a></td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<div class="card mt-8">
<h3>Metadata</h3>
<pre class="text-mono" style="margin-top:8px;white-space:pre-wrap;font-size:12px">{{if .Proxy.Metadata}}{{.Proxy.Metadata}}{{else}}None{{end}}</pre>
</div>
+61
View File
@@ -0,0 +1,61 @@
<div class="toolbar">
<h1>New Proxy</h1>
</div>
{{if .Error}}
<div style="margin-bottom:16px;padding:12px;border:1px solid var(--border);font-size:13px">{{.Error}}</div>
{{end}}
<form hx-post="/admin/proxies/new" hx-target="body" hx-push-url="true" style="max-width:500px">
<div style="margin-bottom:16px">
<label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Client</label>
<select name="client_id" required>
<option value="">— Select Client —</option>
{{range .Clients}}
<option value="{{.ID}}"{{if eq .ID $.SelectedClient}} selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div style="margin-bottom:16px">
<label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Proxy Type</label>
<select name="proxy_type">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div style="margin-bottom:16px">
<label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Name</label>
<input type="text" name="name" required autofocus placeholder="ssh-proxy">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px">
<div>
<label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Local IP</label>
<input type="text" name="local_ip" value="127.0.0.1" required>
</div>
<div>
<label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Local Port</label>
<input type="number" name="local_port" required>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px">
<div>
<label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Remote Port</label>
<input type="number" name="remote_port" placeholder="0 = auto">
</div>
<div>
<label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Custom Domains</label>
<input type="text" name="custom_domains" placeholder="example.com">
</div>
</div>
<div class="toolbar" style="margin-top:24px">
<button type="submit">Create Proxy</button>
<a href="/admin/proxies" class="btn btn-outline" hx-get="/admin/proxies" hx-target="#main-content" hx-push-url="true">Cancel</a>
</div>
</form>
+107
View File
@@ -0,0 +1,107 @@
<div class="toolbar">
<h1>Settings</h1>
<div class="spacer"></div>
{{if .Saved}}
<span style="font-size:12px;color:var(--fg2)">Saved. Restart to apply.</span>
{{end}}
</div>
<form hx-post="/admin/settings" hx-target="body" hx-push-url="true">
<div style="border:1px solid var(--border);padding:20px;margin-bottom:16px">
<h2 style="font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2);margin-bottom:16px;text-transform:uppercase">Server</h2>
<div class="grid-2" style="margin-bottom:0">
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Bind Address</label><input type="text" name="bind_addr" value="{{.Config.BindAddr}}"></div>
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Bind Port</label><input type="number" name="bind_port" value="{{.Config.BindPort}}"></div>
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Proxy Bind Address</label><input type="text" name="proxy_bind_addr" value="{{.Config.ProxyBindAddr}}"></div>
<div></div>
</div>
</div>
<div style="border:1px solid var(--border);padding:20px;margin-bottom:16px">
<h2 style="font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2);margin-bottom:16px;text-transform:uppercase">Dashboard</h2>
<div class="grid-2" style="margin-bottom:0">
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Address</label><input type="text" name="dashboard_addr" value="{{.Config.WebServer.Addr}}"></div>
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Port</label><input type="number" name="dashboard_port" value="{{.Config.WebServer.Port}}"></div>
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Username</label><input type="text" name="dashboard_user" value="{{.Config.WebServer.User}}" autocomplete="off"></div>
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Password</label><input type="text" name="dashboard_password" value="{{.Config.WebServer.Password}}" autocomplete="off"></div>
</div>
</div>
<div style="border:1px solid var(--border);padding:20px;margin-bottom:16px">
<h2 style="font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2);margin-bottom:16px;text-transform:uppercase">Authentication</h2>
<div class="grid-2" style="margin-bottom:0">
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Auth Method</label>
<select name="auth_method">
<option value="token"{{if eq .Config.Auth.Method "token"}} selected{{end}}>Token</option>
<option value="oidc"{{if eq .Config.Auth.Method "oidc"}} selected{{end}}>OIDC</option>
</select>
</div>
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Token</label><input type="text" name="auth_token" value="{{.Config.Auth.Token}}" autocomplete="off"></div>
</div>
</div>
<div style="border:1px solid var(--border);padding:20px;margin-bottom:16px">
<h2 style="font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2);margin-bottom:16px;text-transform:uppercase">HTTP / VHost</h2>
<div class="grid-2" style="margin-bottom:0">
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">VHost HTTP Port</label><input type="number" name="vhost_http_port" value="{{.Config.VhostHTTPPort}}" placeholder="0 = disabled"></div>
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">VHost HTTPS Port</label><input type="number" name="vhost_https_port" value="{{.Config.VhostHTTPSPort}}" placeholder="0 = disabled"></div>
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Subdomain Host</label><input type="text" name="subdomain_host" value="{{.Config.SubDomainHost}}"></div>
<div></div>
</div>
</div>
<div style="border:1px solid var(--border);padding:20px;margin-bottom:16px">
<h2 style="font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2);margin-bottom:16px;text-transform:uppercase">Transport</h2>
<div class="grid-2" style="margin-bottom:0">
<div style="display:flex;align-items:center;gap:8px;padding-top:20px">
<input type="checkbox" name="tcp_mux" value="true" style="width:auto"{{if .Config.Transport.TCPMux}} checked{{end}}>
<label style="font-size:13px">TCP Multiplexing</label>
</div>
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Max Pool Count</label><input type="number" name="max_pool_count" value="{{.Config.Transport.MaxPoolCount}}" placeholder="0 = unlimited"></div>
</div>
</div>
<div style="border:1px solid var(--border);padding:20px;margin-bottom:16px">
<h2 style="font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2);margin-bottom:16px;text-transform:uppercase">Logging</h2>
<div class="grid-2" style="margin-bottom:0">
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Output</label>
<select name="log_to">
<option value="console"{{if eq .Config.Log.To "console"}} selected{{end}}>Console</option>
<option value="file"{{if eq .Config.Log.To "file"}} selected{{end}}>File</option>
</select>
</div>
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Level</label>
<select name="log_level">
<option value="trace"{{if eq .Config.Log.Level "trace"}} selected{{end}}>Trace</option>
<option value="debug"{{if eq .Config.Log.Level "debug"}} selected{{end}}>Debug</option>
<option value="info"{{if eq .Config.Log.Level "info"}} selected{{end}}>Info</option>
<option value="warn"{{if eq .Config.Log.Level "warn"}} selected{{end}}>Warn</option>
<option value="error"{{if eq .Config.Log.Level "error"}} selected{{end}}>Error</option>
</select>
</div>
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">Max Days</label><input type="number" name="log_max_days" value="{{.Config.Log.MaxDays}}"></div>
<div></div>
</div>
</div>
<div style="border:1px solid var(--border);padding:20px;margin-bottom:16px">
<h2 style="font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2);margin-bottom:16px;text-transform:uppercase">Extra Ports</h2>
<div class="grid-2" style="margin-bottom:0">
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">KCP Bind Port</label><input type="number" name="kcp_bind_port" value="{{.Config.KCPBindPort}}" placeholder="0 = disabled"></div>
<div><label style="display:block;font-size:12px;color:var(--fg2);margin-bottom:4px">QUIC Bind Port</label><input type="number" name="quic_bind_port" value="{{.Config.QUICBindPort}}" placeholder="0 = disabled"></div>
</div>
</div>
<div style="border:1px solid var(--border);padding:20px;margin-bottom:16px">
<h2 style="font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2);margin-bottom:16px;text-transform:uppercase">Misc</h2>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
<input type="checkbox" name="enable_prometheus" value="true" style="width:auto"{{if .Config.EnablePrometheus}} checked{{end}}>
<label style="font-size:13px">Enable Prometheus Metrics</label>
</div>
</div>
<div class="toolbar" style="margin-top:24px">
<button type="submit">Save Configuration</button>
<a href="/admin/dashboard" class="btn btn-outline" hx-get="/admin/dashboard" hx-target="#main-content" hx-push-url="true">Cancel</a>
</div>
</form>
+87
View File
@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Setup — frp Admin</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#fff;--bg2:#f5f5f5;--fg:#000;--fg2:#888;--border:#ddd;--accent:#000;--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;--radius:0}
@media(prefers-color-scheme:dark){
:root{--bg:#000;--bg2:#111;--fg:#fff;--fg2:#666;--border:#333;--accent:#fff}
}
html,body{height:100%}
body{font-family:var(--font);background:var(--bg2);color:var(--fg);font-size:14px;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}
.setup-card{width:520px;padding:32px;background:var(--bg);border:1px solid var(--border)}
.setup-card h1{font-size:20px;margin-bottom:4px;text-align:center;letter-spacing:-.5px}
.setup-card p{font-size:13px;color:var(--fg2);text-align:center;margin-bottom:24px}
.setup-card .error{font-size:13px;margin-bottom:12px;text-align:center}
.setup-card .section-title{font-size:12px;font-weight:600;letter-spacing:.3px;color:var(--fg2);text-transform:uppercase;margin-bottom:12px;padding-top:8px;border-top:1px solid var(--border)}
.setup-card .field{margin-bottom:16px}
.setup-card .field label{display:block;font-size:12px;color:var(--fg2);margin-bottom:4px}
input{padding:8px 12px;border:1px solid var(--border);background:var(--bg);color:var(--fg);font-size:13px;width:100%;-webkit-appearance:none;appearance:none}
input:focus{outline:1px solid var(--fg)}
button{padding:8px 16px;border:1px solid var(--fg);background:var(--fg);color:var(--bg);font-size:13px;cursor:pointer;font-family:var(--font);width:100%;margin-top:8px}
button:hover{opacity:.8}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
</style>
</head>
<body>
<div class="setup-card">
<h1>frp Admin Setup</h1>
<p>Create an admin account and configure your server.</p>
{{if .Error}}
<div class="error">{{.Error}}</div>
{{end}}
<form method="post" action="/admin/setup">
<div class="section-title">Admin Account</div>
<div class="grid-2">
<div class="field">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="field">
<label for="name">Display Name</label>
<input type="text" id="name" name="name" placeholder="optional">
</div>
</div>
<div class="grid-2">
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div class="field">
<label for="confirm_password">Confirm Password</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
</div>
<div class="section-title">Server Configuration</div>
<div class="grid-2">
<div class="field">
<label for="bind_addr">Bind Address</label>
<input type="text" id="bind_addr" name="bind_addr" value="0.0.0.0">
</div>
<div class="field">
<label for="bind_port">Bind Port</label>
<input type="number" id="bind_port" name="bind_port" value="7000">
</div>
<div class="field">
<label for="dashboard_addr">Dashboard Address</label>
<input type="text" id="dashboard_addr" name="dashboard_addr" value="0.0.0.0">
</div>
<div class="field">
<label for="dashboard_port">Dashboard Port</label>
<input type="number" id="dashboard_port" name="dashboard_port" value="7500">
</div>
<div class="field">
<label for="auth_token">Auth Token</label>
<input type="text" id="auth_token" name="auth_token" placeholder="optional">
</div>
</div>
<button type="submit">Complete Setup</button>
</form>
</div>
</body>
</html>