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) 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) 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) } 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 } sc, open := m.resolveScope(scopeOf(req.TunnelID, req.GroupID)) switch { case req.Status == "rejected": m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的访问申请")) case !open: m.writePage(w, r, http.StatusOK, pageError, "通道未开启", problemHTML("对应通道当前未开启,请联系管理员")) 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)) 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 + `,计时从首次连接开始。到期后如需继续使用,请向管理员索取新的邀请链接。
` } // 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) }