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 = `
访问地址
` + b.String() + `
` } 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
%s
PortRelay 端口访问授权
%s
`, html.EscapeString(title), color, icon, html.EscapeString(title), body) }