package server
import (
"fmt"
"html"
"io"
"log"
"net"
"net/http"
"net/url"
"strings"
"time"
"unicode"
"github.com/DeepFal/PortRelay/core/protocol"
)
const maxNameRunes = 32
func (m *Manager) handleHTTPRequest(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case path == "/health":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","server":"PortRelay"}`))
case strings.HasPrefix(path, "/i/") && r.Method != http.MethodPost:
m.serveInviteForm(w, r, strings.TrimPrefix(path, "/i/"))
case path == "/auth" && r.Method == http.MethodPost:
m.serveInviteSubmit(w, r)
case path == "/auth":
m.serveInviteForm(w, r, r.URL.Query().Get("code"))
case path == "/auth/status":
m.serveRequestStatus(w, r)
case path == "/renew" && r.Method == http.MethodPost:
m.serveRenewSubmit(w, r)
default:
http.NotFound(w, r)
}
}
func clientIPOf(r *http.Request) string {
ip := r.RemoteAddr
if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
ip = h
}
if parsed := net.ParseIP(ip); parsed != nil {
return parsed.String()
}
return ""
}
// accessHost returns the host LAN users should connect to: the configured public host,
// otherwise the host the visitor used to open the link (it reached this server).
func (m *Manager) accessHost(r *http.Request) string {
if m.PublicHost != "" {
return m.PublicHost
}
if h, _, err := net.SplitHostPort(r.Host); err == nil {
return h
}
return r.Host
}
// accessScope is what an invite or request grants access to: one tunnel or a whole tunnel group
type accessScope struct {
key string
group bool
name string
tunnels []protocol.TunnelConfig // 当前开启的通道,按端口排序
expires *time.Time
}
// resolveScope looks up the open tunnels of a scope; false when none is open
func (m *Manager) resolveScope(scope string) (accessScope, bool) {
tunnels := m.scopeTunnels(scope)
if len(tunnels) == 0 {
return accessScope{}, false
}
_, isGroup := splitScope(scope)
sc := accessScope{key: scope, group: isGroup, expires: scopeExpiresOf(tunnels)}
for _, t := range tunnels {
sc.tunnels = append(sc.tunnels, t.Snapshot())
}
sc.name = sc.tunnels[0].Name
if isGroup {
sc.name = sc.tunnels[0].GroupName
if sc.name == "" {
sc.name = "通道组"
}
}
return sc, true
}
// inviteTarget resolves an invite code to the tunnel or group it grants access to
func (m *Manager) inviteTarget(code string) (accessScope, protocol.Invite, string) {
code = strings.TrimSpace(code)
if code == "" {
return accessScope{}, protocol.Invite{}, "缺少邀请码"
}
scope, inv, ok := m.state.findInvite(code)
if !ok {
return accessScope{}, protocol.Invite{}, "链接无效或已过期,请向管理员索取新的邀请链接"
}
sc, ok := m.resolveScope(scope)
if !ok {
return accessScope{}, protocol.Invite{}, "对应通道当前未开启,请联系管理员"
}
return sc, inv, ""
}
func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code string) {
ip := clientIPOf(r)
// 用过这个链接的设备:显示它的授权状态,到期前后都可以申请延长(链接或通道失效后也一样)
if ip != "" {
if da, ok := m.state.deviceAccess(strings.TrimSpace(code), ip); ok {
m.serveDeviceAccess(w, r, strings.TrimSpace(code), ip, da)
return
}
}
sc, inv, problem := m.inviteTarget(code)
if problem == "" && ip == "" {
problem = "无法识别客户端 IP"
}
if problem == "" {
if err := m.state.checkInvite(sc.key, inv.Code, ip); err != nil {
problem = err.Error()
}
}
if problem != "" {
m.writePage(w, r, http.StatusNotFound, pageError, "无法使用此链接", problemHTML(problem))
return
}
action := "确认并获取访问权限"
approvalTip := ""
if inv.RequireApproval {
action = "提交访问申请"
approvalTip = `
此邀请需要管理员审批,提交后请保持此页面打开,批准后会自动显示结果。
`
}
body := fmt.Sprintf(`
%s
%s`,
m.detailRows(r, sc, ip, durationText(inv.LeaseDuration)),
html.EscapeString(inv.Code), maxNameRunes, action, approvalTip)
m.writePage(w, r, http.StatusOK, pageForm, "申请访问 "+sc.name, body)
}
// serveDeviceAccess shows a returning device its access and, when it can, the renewal button
func (m *Manager) serveDeviceAccess(w http.ResponseWriter, r *http.Request, code, ip string, da deviceAccess) {
if da.pending != nil {
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(da.pending.ID), http.StatusSeeOther)
return
}
sc, open := m.resolveScope(da.scope)
rows := func(validity string) string {
if open {
return m.detailRows(r, sc, ip, validity)
}
return closedRows(da, ip, validity)
}
renewForm := fmt.Sprintf(`
`, html.EscapeString(code))
switch {
case da.lease != nil && da.lease.Deferred && open:
m.writePage(w, r, http.StatusOK, pageSuccess, "已获得访问权限", m.successBody(r, sc, ip, *da.lease))
case da.lease != nil && da.lease.Deferred:
m.writePage(w, r, http.StatusOK, pageSuccess, "已获得访问权限", rows(leaseValidity(*da.lease)))
case da.lease != nil:
left := time.Until(da.lease.ExpiresAt).Round(time.Minute)
body := rows(fmt.Sprintf("至 %s(剩余 %s)", da.lease.ExpiresAt.Format("01-02 15:04"), protocol.DescribeMinutes(max(1, int(left/time.Minute))))) +
`需要更多时间时可以申请延长,管理员批准后在原到期时间上顺延。
` + renewForm
m.writePage(w, r, http.StatusOK, pageSuccess, "访问已授权", body)
case da.ended != nil && da.ended.Revoked:
m.writePage(w, r, http.StatusForbidden, pageError, "授权已撤销", problemHTML(errRevoked.Error()))
case da.ended != nil:
closedTip := ""
if !open {
closedTip = "通道当前已关闭,批准后会自动重新开启。"
}
body := rows("已于 "+da.ended.EndedAt.Format("01-02 15:04")+" 结束") +
fmt.Sprintf(`可在 %s 前申请延长,管理员批准并确定时长后即可继续使用。%s
`,
da.ended.EndedAt.Add(renewWindow).Format("15:04"), closedTip) + renewForm
m.writePage(w, r, http.StatusOK, pageForm, "访问已结束", body)
default:
m.writePage(w, r, http.StatusGone, pageError, "访问已结束", problemHTML(errNothingToRenew.Error()))
}
}
// closedRows describes a scope whose tunnels are all closed (only its last known name is left)
func closedRows(da deviceAccess, ip, validity string) string {
label := "目标通道"
if _, isGroup := splitScope(da.scope); isGroup {
label = "目标通道组"
}
name := da.name
if name == "" {
name = "(已关闭)"
}
return fmt.Sprintf(`
%s %s
您的设备 IP %s
访问有效期 %s
`,
label, html.EscapeString(name), html.EscapeString(ip), html.EscapeString(validity))
}
func (m *Manager) serveRenewSubmit(w http.ResponseWriter, r *http.Request) {
r.Body = io.NopCloser(io.LimitReader(r.Body, 8<<10))
if err := r.ParseForm(); err != nil {
m.writePage(w, r, http.StatusBadRequest, pageError, "请求无效", problemHTML("表单内容无法解析"))
return
}
ip := clientIPOf(r)
da, ok := m.state.deviceAccess(strings.TrimSpace(r.PostForm.Get("code")), ip)
if ip == "" || !ok {
m.writePage(w, r, http.StatusNotFound, pageError, "无法申请延长", problemHTML("没有找到这台设备的授权,请联系管理员获取新的邀请链接"))
return
}
req, err := m.state.addRenewal(da.scope, ip)
if err != nil {
m.writePage(w, r, http.StatusConflict, pageError, "无法申请延长", problemHTML(err.Error()))
return
}
log.Printf("[WebAuth] %s (%s) asked to extend access to %s", req.Name, ip, da.name)
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(req.ID), http.StatusSeeOther)
}
func sanitizeName(s string) string {
s = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, s)
return truncateRunes(s, maxNameRunes)
}
func (m *Manager) serveInviteSubmit(w http.ResponseWriter, r *http.Request) {
r.Body = io.NopCloser(io.LimitReader(r.Body, 8<<10))
if err := r.ParseForm(); err != nil {
m.writePage(w, r, http.StatusBadRequest, pageError, "请求无效", problemHTML("表单内容无法解析"))
return
}
ip := clientIPOf(r)
name := sanitizeName(r.PostForm.Get("name"))
sc, inv, problem := m.inviteTarget(r.PostForm.Get("code"))
switch {
case problem != "":
case ip == "":
problem = "无法识别客户端 IP"
case name == "":
problem = "请填写您的姓名"
}
if problem != "" {
m.writePage(w, r, http.StatusBadRequest, pageError, "无法完成授权", problemHTML(problem))
return
}
if inv.RequireApproval {
req, err := m.state.addRequest(sc.key, inv.Code, name, ip)
if err != nil {
m.writePage(w, r, http.StatusConflict, pageError, "无法提交申请", problemHTML(err.Error()))
return
}
log.Printf("[WebAuth] Access request from %s (%s) on %s awaiting approval", name, ip, sc.name)
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(req.ID), http.StatusSeeOther)
return
}
lease, err := m.state.useInviteAndGrant(sc.key, inv.Code, name, ip, sc.expires)
if err != nil {
m.writePage(w, r, http.StatusConflict, pageError, "无法完成授权", problemHTML(err.Error()))
return
}
m.syncScope(sc.key)
log.Printf("[WebAuth] Granted %s (%s) access to %s (%d port(s)): %s",
name, ip, sc.name, len(sc.tunnels), leaseValidity(lease))
m.writePage(w, r, http.StatusOK, pageSuccess, "授权成功", m.successBody(r, sc, ip, lease))
}
func (m *Manager) serveRequestStatus(w http.ResponseWriter, r *http.Request) {
req, ok := m.state.request(r.URL.Query().Get("id"))
if !ok {
m.writePage(w, r, http.StatusNotFound, pageError, "申请已失效", problemHTML("申请不存在或已过期,请重新打开邀请链接提交"))
return
}
scope := scopeOf(req.TunnelID, req.GroupID)
sc, open := m.resolveScope(scope)
switch {
case req.Status == "rejected" && req.Renewal:
m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的延长申请"))
case req.Status == "rejected":
m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的访问申请"))
case req.Renewal && req.Status == "approved" && !open:
// 批准后 Agent 正在重新开启通道
w.Header().Set("Refresh", "3")
m.writePage(w, r, http.StatusOK, pagePending, "已批准,正在开启通道", `管理员已批准延长,通道正在重新开启,此页面会自动刷新。
`)
case req.Renewal && !open:
da := deviceAccess{scope: scope, name: m.state.scopeDisplayName(scope)}
w.Header().Set("Refresh", "5")
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", closedRows(da, req.IP, "等待批准")+
fmt.Sprintf(`%s,您的延长申请已提交,管理员批准时会确定延长多久;通道当前已关闭,批准后会自动重新开启。此页面会自动刷新。
`, html.EscapeString(req.Name)))
case !open:
m.writePage(w, r, http.StatusOK, pageError, "通道未开启", problemHTML("对应通道当前未开启,请联系管理员"))
case req.Status == "approved" && req.Renewal:
lease, _ := m.state.lease(sc.key, req.IP)
m.writePage(w, r, http.StatusOK, pageSuccess, "延长已批准", m.detailRows(r, sc, req.IP, leaseValidity(lease))+
`管理员已批准延长,现在可以继续使用。到期后 2 小时内还可以重新打开邀请链接申请延长。
`)
case req.Status == "approved":
lease, _ := m.state.lease(sc.key, req.IP)
m.writePage(w, r, http.StatusOK, pageSuccess, "申请已批准", m.successBody(r, sc, req.IP, lease))
case req.Renewal:
w.Header().Set("Refresh", "5")
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", m.detailRows(r, sc, req.IP, "等待批准")+
fmt.Sprintf(`%s,您的延长申请已提交,管理员批准时会确定延长多久。此页面会自动刷新。
`, html.EscapeString(req.Name)))
default:
duration := ""
if _, inv, ok := m.state.findInvite(req.Invite); ok {
duration = inv.LeaseDuration
}
body := m.detailRows(r, sc, req.IP, durationText(duration)) +
fmt.Sprintf(`%s,您的申请已提交,正在等待管理员批准。此页面会自动刷新。
`, html.EscapeString(req.Name))
w.Header().Set("Refresh", "5")
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", body)
}
}
// durationText describes how long access lasts once granted
func durationText(d string) string {
if d == protocol.LeaseDurationTunnel {
return protocol.DescribeLeaseDuration(d)
}
return protocol.DescribeLeaseDuration(d) + "(从首次连接开始计算)"
}
// leaseValidity describes a granted lease: not yet started, or running until its expiry
func leaseValidity(l leaseInfo) string {
if l.Deferred {
return fmt.Sprintf("首次连接后 %s,请在 %s 前连接", protocol.DescribeLeaseDuration(l.Duration), l.ExpiresAt.Format("01-02 15:04"))
}
return "至 " + l.ExpiresAt.Format("01-02 15:04")
}
func (m *Manager) successBody(r *http.Request, sc accessScope, ip string, l leaseInfo) string {
where := "上面的访问地址"
if sc.group {
where = "上面任意一个访问地址"
}
return m.detailRows(r, sc, ip, leaseValidity(l)) +
`您的设备已获得访问权限,现在可以直接用客户端连接` + where + `,计时从首次连接开始。到期后 2 小时内,可以重新打开这个邀请链接申请延长。
`
}
// detailRows renders the info table; a group lists every access address with its target
func (m *Manager) detailRows(r *http.Request, sc accessScope, ip string, validity string) string {
host := html.EscapeString(m.accessHost(r))
var target, addresses string
if sc.group {
target = fmt.Sprintf(`目标通道组 %s(%d 个地址)
`,
html.EscapeString(sc.name), len(sc.tunnels))
var b strings.Builder
for _, t := range sc.tunnels {
fmt.Fprintf(&b, `%s:%d %s
`,
host, t.ListenPort, html.EscapeString(t.Target))
}
addresses = ``
} else {
t := sc.tunnels[0]
target = fmt.Sprintf(`目标通道 %s
`, html.EscapeString(t.Name))
addresses = fmt.Sprintf(`访问地址 %s:%d
`, host, t.ListenPort)
}
return target + addresses + fmt.Sprintf(`
您的设备 IP %s
访问有效期 %s
`,
html.EscapeString(ip), html.EscapeString(validity))
}
func problemHTML(msg string) string {
return fmt.Sprintf(`%s
`, html.EscapeString(msg))
}
type pageKind int
const (
pageSuccess pageKind = iota
pageError
pagePending
pageForm
)
func (m *Manager) writePage(w http.ResponseWriter, r *http.Request, status int, kind pageKind, title, body string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_, _ = w.Write([]byte(renderPage(kind, title, body)))
}
func renderPage(kind pageKind, title, body string) string {
color := "#34C759"
icon := ` `
switch kind {
case pageError:
color = "#FF3B30"
icon = ` `
case pagePending:
color = "#FF9500"
icon = ` `
case pageForm:
color = "#007AFF"
icon = ` `
}
return fmt.Sprintf(`
%s - PortRelay
%s
`, html.EscapeString(title), color, icon, html.EscapeString(title), body)
}