main
go 514 lines 15.5 KB
Raw
1 package main
2
3 import (
4 "crypto/sha256"
5 "crypto/subtle"
6 "embed"
7 "encoding/hex"
8 "errors"
9 "fmt"
10 "net"
11 "net/http"
12 "strings"
13
14 "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/installer"
15 "github.com/gosuda/portal-tunnel/v2/portal"
16 "github.com/gosuda/portal-tunnel/v2/portal/identity"
17 "github.com/gosuda/portal-tunnel/v2/portal/policy"
18 "github.com/gosuda/portal-tunnel/v2/types"
19 "github.com/gosuda/portal-tunnel/v2/utils"
20 "github.com/prometheus/client_golang/prometheus/promhttp"
21 )
22
23 //go:embed dist/*
24 var embeddedDistFS embed.FS
25
26 const (
27 controlBodyLimit = 1 << 16
28 )
29
30 type RelayAPI struct {
31 server *portal.Server
32 adminToken string
33 policyStatePath string
34 }
35
36 func NewRelayAPI(server *portal.Server, identityPath, adminToken string) (*RelayAPI, error) {
37 if server == nil {
38 return nil, errors.New("relay api requires portal server")
39 }
40 runtime := server.PolicyRuntime()
41 if runtime == nil {
42 return nil, errors.New("relay api requires policy runtime")
43 }
44 policyStatePath := identity.ResolveRelayPolicyPath(identityPath)
45 if policyStatePath == "" {
46 return nil, errors.New("relay api requires identity path")
47 }
48 if err := loadPolicyState(policyStatePath, server); err != nil {
49 return nil, err
50 }
51
52 api := &RelayAPI{
53 server: server,
54 adminToken: strings.TrimSpace(adminToken),
55 policyStatePath: strings.TrimSpace(policyStatePath),
56 }
57 return api, nil
58 }
59
60 func (api *RelayAPI) Handler() *http.ServeMux {
61 mux := http.NewServeMux()
62
63 mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
64 if !utils.RequireMethod(w, r, http.MethodGet) {
65 return
66 }
67 utils.WriteAPIData(w, http.StatusOK, map[string]any{
68 "service": "portal-relay",
69 "root": api.server.RelayIdentity().Name,
70 })
71 })
72 mux.HandleFunc(types.PathAdmin, api.serveAdmin)
73 mux.HandleFunc(types.PathAdminPrefix, api.serveAdmin)
74 mux.HandleFunc(types.PathPolicy, api.servePolicy)
75 mux.HandleFunc(types.PathPolicyPrefix, api.servePolicy)
76 mux.HandleFunc(types.PathState, api.servePublicState)
77 mux.HandleFunc(types.PathInstallShell, func(w http.ResponseWriter, r *http.Request) {
78 serveInstallScript(w, r, api.server.PortalURL(), false)
79 })
80 mux.HandleFunc(types.PathInstallPowerShell, func(w http.ResponseWriter, r *http.Request) {
81 serveInstallScript(w, r, api.server.PortalURL(), true)
82 })
83 mux.HandleFunc(types.PathInstallBinPrefix, serveInstallBinary)
84
85 return mux
86 }
87
88 func (api *RelayAPI) servePublicState(w http.ResponseWriter, r *http.Request) {
89 if !utils.RequireMethod(w, r, http.MethodGet) {
90 return
91 }
92
93 leases := api.server.PublicLeases()
94 utils.WriteAPIData(w, http.StatusOK, types.PublicStateResponse{
95 Leases: leases,
96 })
97 }
98
99 func loadPolicyState(path string, server *portal.Server) error {
100 path = strings.TrimSpace(path)
101 if path == "" {
102 return nil
103 }
104
105 var payload persistedPolicyState
106 loaded, err := utils.ReadJSONFileIfExists(path, &payload)
107 if err != nil {
108 return err
109 }
110 if !loaded {
111 return nil
112 }
113 return payload.apply(server)
114 }
115
116 func (api *RelayAPI) serveAdmin(w http.ResponseWriter, r *http.Request) {
117 path := strings.TrimSuffix(strings.TrimSpace(r.URL.Path), "/")
118 if path == "" {
119 path = types.PathRoot
120 }
121
122 switch path {
123 case types.PathAdmin:
124 http.NotFound(w, r)
125 return
126 case types.PathAdminAuthLogin:
127 if !utils.RequireMethod(w, r, http.MethodPost) {
128 return
129 }
130 api.handleAdminLogin(w, r)
131 return
132 case types.PathAdminLogout:
133 if !utils.RequireMethod(w, r, http.MethodPost) {
134 return
135 }
136 utils.WriteAPIData(w, http.StatusOK, map[string]any{})
137 return
138 case types.PathAdminAuthStatus:
139 if !utils.RequireMethod(w, r, http.MethodGet) {
140 return
141 }
142 utils.WriteAPIData(w, http.StatusOK, types.AdminAuthStatusResponse{
143 Authenticated: api.authenticatedAdmin(r),
144 })
145 return
146 }
147
148 if !api.authenticatedAdmin(r) {
149 utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "unauthorized")
150 return
151 }
152
153 switch path {
154 case types.PathAdmin + "/metrics":
155 promhttp.Handler().ServeHTTP(w, r)
156 return
157 default:
158 http.NotFound(w, r)
159 }
160 }
161
162 func (api *RelayAPI) servePolicy(w http.ResponseWriter, r *http.Request) {
163 path := strings.TrimSuffix(strings.TrimSpace(r.URL.Path), "/")
164 if path == "" {
165 path = types.PathRoot
166 }
167
168 if !api.authenticatedAdmin(r) {
169 utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "unauthorized")
170 return
171 }
172
173 runtime := api.server.PolicyRuntime()
174 invalidRequestBody := utils.InvalidRequestError(errors.New("invalid request body"))
175
176 switch path {
177 case types.PathPolicy:
178 switch r.Method {
179 case http.MethodGet:
180 utils.WriteAPIData(w, http.StatusOK, api.policySettings(runtime))
181 case http.MethodPost:
182 req, ok := utils.DecodeJSONRequestAs[types.PolicySettings](w, r, controlBodyLimit, invalidRequestBody)
183 if !ok {
184 return
185 }
186 if !api.applyPolicySettings(w, runtime, req) {
187 return
188 }
189 utils.WriteAPIData(w, http.StatusOK, api.policySettings(runtime))
190 default:
191 w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
192 utils.MethodNotAllowedError().Write(w)
193 }
194 case types.PathPolicyState:
195 if !utils.RequireMethod(w, r, http.MethodGet) {
196 return
197 }
198 leases := api.server.PolicyLeases()
199 utils.WriteAPIData(w, http.StatusOK, types.PolicyStateResponse{
200 Policy: api.policySettings(runtime),
201 Leases: leases,
202 })
203 case types.PathPolicyLeases:
204 if !utils.RequireMethod(w, r, http.MethodPost) {
205 return
206 }
207 req, ok := utils.DecodeJSONRequestAs[types.LeasePolicyUpdate](w, r, controlBodyLimit, invalidRequestBody)
208 if !ok {
209 return
210 }
211 identityKey, ok := normalizePolicyIdentityKey(w, req.IdentityKey)
212 if !ok {
213 return
214 }
215 if !applyLeasePolicyUpdate(w, runtime, identityKey, req) {
216 return
217 }
218 savePolicyState(api.policyStatePath, runtime)
219 utils.WriteAPIData(w, http.StatusOK, map[string]any{})
220 case types.PathPolicyIPs:
221 if !utils.RequireMethod(w, r, http.MethodPost) {
222 return
223 }
224 req, ok := utils.DecodeJSONRequestAs[types.IPPolicyUpdate](w, r, controlBodyLimit, invalidRequestBody)
225 if !ok {
226 return
227 }
228 ip := strings.TrimSpace(req.IP)
229 if net.ParseIP(ip) == nil {
230 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidIP, "invalid IP address")
231 return
232 }
233 if req.IsBanned {
234 runtime.IPFilter().BanIP(ip)
235 } else {
236 runtime.IPFilter().UnbanIP(ip)
237 }
238 savePolicyState(api.policyStatePath, runtime)
239 utils.WriteAPIData(w, http.StatusOK, map[string]any{})
240 default:
241 http.NotFound(w, r)
242 }
243 }
244
245 func (api *RelayAPI) policySettings(runtime *policy.Runtime) types.PolicySettings {
246 return types.PolicySettings{
247 ApprovalMode: string(runtime.Approver().Mode()),
248 UDP: types.PolicyPortSettings{
249 Enabled: runtime.IsUDPEnabled(),
250 MaxLeases: runtime.UDPMaxLeases(),
251 },
252 TCPPort: types.PolicyPortSettings{
253 Enabled: runtime.IsTCPPortEnabled(),
254 MaxLeases: runtime.TCPPortMaxLeases(),
255 },
256 }
257 }
258
259 func (api *RelayAPI) applyPolicySettings(w http.ResponseWriter, runtime *policy.Runtime, req types.PolicySettings) bool {
260 if req.UDP.MaxLeases < 0 || req.TCPPort.MaxLeases < 0 {
261 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "max_leases must be non-negative")
262 return false
263 }
264 if err := runtime.Approver().SetMode(policy.Mode(strings.TrimSpace(req.ApprovalMode))); err != nil {
265 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidMode, "approval_mode must be 'auto' or 'manual'")
266 return false
267 }
268 api.server.SetUDPPolicy(req.UDP.Enabled, req.UDP.MaxLeases)
269 api.server.SetTCPPortPolicy(req.TCPPort.Enabled, req.TCPPort.MaxLeases)
270 savePolicyState(api.policyStatePath, runtime)
271 return true
272 }
273
274 func normalizePolicyIdentityKey(w http.ResponseWriter, raw string) (string, bool) {
275 raw = strings.TrimSpace(raw)
276 name, address, ok := strings.Cut(raw, types.IdentityKeySeparator)
277 if !ok {
278 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid identity")
279 return "", false
280 }
281 normalizedIdentity, err := identity.NormalizeIdentity(types.Identity{Name: name, Address: address})
282 if err != nil {
283 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid identity")
284 return "", false
285 }
286 return normalizedIdentity.Key(), true
287 }
288
289 func applyLeasePolicyUpdate(w http.ResponseWriter, runtime *policy.Runtime, identityKey string, req types.LeasePolicyUpdate) bool {
290 if req.IsBanned == nil && req.IsApproved == nil && req.IsDenied == nil && req.BPS == nil {
291 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "lease policy update is empty")
292 return false
293 }
294 if req.IsApproved != nil && req.IsDenied != nil && *req.IsApproved && *req.IsDenied {
295 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "lease cannot be approved and denied")
296 return false
297 }
298 if req.BPS != nil {
299 if *req.BPS < 0 {
300 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "bps must be non-negative")
301 return false
302 }
303 if *req.BPS == 0 {
304 runtime.BPSManager().DeleteIdentityBPS(identityKey)
305 } else {
306 runtime.BPSManager().SetIdentityBPS(identityKey, *req.BPS)
307 }
308 }
309 if req.IsBanned != nil {
310 if *req.IsBanned {
311 runtime.BanIdentity(identityKey)
312 } else {
313 runtime.UnbanIdentity(identityKey)
314 }
315 }
316 approver := runtime.Approver()
317 if req.IsDenied != nil {
318 if *req.IsDenied {
319 approver.Deny(identityKey)
320 approver.Revoke(identityKey)
321 } else {
322 approver.Undeny(identityKey)
323 }
324 }
325 if req.IsApproved != nil {
326 if *req.IsApproved {
327 approver.Approve(identityKey)
328 approver.Undeny(identityKey)
329 } else {
330 approver.Revoke(identityKey)
331 }
332 }
333 return true
334 }
335
336 func (api *RelayAPI) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
337 req, ok := utils.DecodeJSONRequestAs[types.AdminAuthLoginRequest](w, r, controlBodyLimit, utils.InvalidRequestError(errors.New("invalid request body")))
338 if !ok {
339 return
340 }
341 if !api.tokenAllowed(req.Token) {
342 utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "invalid admin token")
343 return
344 }
345 utils.WriteAPIData(w, http.StatusOK, types.AdminAuthLoginResponse{
346 AccessToken: api.adminToken,
347 })
348 }
349
350 func (api *RelayAPI) authenticatedAdmin(r *http.Request) bool {
351 return api.tokenAllowed(adminAccessToken(r))
352 }
353
354 func adminAccessToken(r *http.Request) string {
355 parts := strings.Fields(r.Header.Get("Authorization"))
356 if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
357 return ""
358 }
359 return strings.TrimSpace(parts[1])
360 }
361
362 func (api *RelayAPI) tokenAllowed(raw string) bool {
363 token := strings.TrimSpace(raw)
364 expected := strings.TrimSpace(api.adminToken)
365 if token == "" || expected == "" || len(token) != len(expected) {
366 return false
367 }
368 return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1
369 }
370
371 func savePolicyState(path string, runtime *policy.Runtime) {
372 path = strings.TrimSpace(path)
373 if path == "" {
374 return
375 }
376
377 approver := runtime.Approver()
378 udpEnabled := runtime.IsUDPEnabled()
379 udpMaxLeases := runtime.UDPMaxLeases()
380 tcpPortEnabled := runtime.IsTCPPortEnabled()
381 tcpPortMaxLeases := runtime.TCPPortMaxLeases()
382 payload := persistedPolicyState{
383 ApprovalMode: string(approver.Mode()),
384 ApprovedIdentityKeys: approver.ApprovedKeys(),
385 DeniedIdentityKeys: approver.DeniedKeys(),
386 BannedIdentityKeys: runtime.BannedIdentityKeys(),
387 BannedIPs: runtime.IPFilter().BannedIPs(),
388 IdentityBPS: runtime.BPSManager().IdentityBPSLimits(),
389 UDPEnabled: &udpEnabled,
390 UDPMaxLeases: &udpMaxLeases,
391 TCPPortEnabled: &tcpPortEnabled,
392 TCPPortMaxLeases: &tcpPortMaxLeases,
393 }
394 _ = utils.WriteJSONFile(path, payload, 0o600)
395 }
396
397 type persistedPolicyState struct {
398 ApprovalMode string `json:"approval_mode"`
399 ApprovedIdentityKeys []string `json:"approved_identity_keys,omitempty"`
400 DeniedIdentityKeys []string `json:"denied_identity_keys,omitempty"`
401 BannedIdentityKeys []string `json:"banned_identity_keys,omitempty"`
402 BannedIPs []string `json:"banned_ips,omitempty"`
403 IdentityBPS map[string]int64 `json:"identity_bps,omitempty"`
404 UDPEnabled *bool `json:"udp_enabled,omitempty"`
405 UDPMaxLeases *int `json:"udp_max_leases,omitempty"`
406 TCPPortEnabled *bool `json:"tcp_port_enabled,omitempty"`
407 TCPPortMaxLeases *int `json:"tcp_port_max_leases,omitempty"`
408 }
409
410 func applyOptionalPolicy(enabled *bool, maxLeases *int, getEnabled func() bool, getMax func() int, set func(bool, int)) {
411 if enabled == nil && maxLeases == nil {
412 return
413 }
414 e := getEnabled()
415 m := getMax()
416 if enabled != nil {
417 e = *enabled
418 }
419 if maxLeases != nil {
420 m = *maxLeases
421 }
422 set(e, m)
423 }
424
425 func (s persistedPolicyState) apply(server *portal.Server) error {
426 if server == nil {
427 return nil
428 }
429 runtime := server.PolicyRuntime()
430 if runtime == nil {
431 return nil
432 }
433 if mode := strings.TrimSpace(s.ApprovalMode); mode != "" {
434 if err := runtime.Approver().SetMode(policy.Mode(mode)); err != nil {
435 return err
436 }
437 }
438 runtime.Approver().SetDecisions(
439 identity.NormalizeIdentityKeys(s.ApprovedIdentityKeys),
440 identity.NormalizeIdentityKeys(s.DeniedIdentityKeys),
441 )
442 runtime.SetBannedIdentityKeys(identity.NormalizeIdentityKeys(s.BannedIdentityKeys))
443 runtime.IPFilter().SetBannedIPs(s.BannedIPs)
444 runtime.BPSManager().SetIdentityBPSLimits(identity.NormalizeIdentityKeyBPS(s.IdentityBPS))
445 applyOptionalPolicy(s.UDPEnabled, s.UDPMaxLeases, runtime.IsUDPEnabled, runtime.UDPMaxLeases, server.SetUDPPolicy)
446 applyOptionalPolicy(s.TCPPortEnabled, s.TCPPortMaxLeases, runtime.IsTCPPortEnabled, runtime.TCPPortMaxLeases, server.SetTCPPortPolicy)
447 return nil
448 }
449
450 func serveInstallBinary(w http.ResponseWriter, r *http.Request) {
451 if r.Method != http.MethodGet && r.Method != http.MethodHead {
452 w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
453 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
454 return
455 }
456
457 slug := strings.Trim(strings.TrimPrefix(r.URL.Path, types.PathInstallBinPrefix), "/")
458 checksumRequest := strings.HasSuffix(slug, ".sha256")
459 if checksumRequest {
460 slug = strings.TrimSuffix(slug, ".sha256")
461 }
462
463 filename, ok := installer.AssetFilename(slug)
464 if !ok {
465 http.NotFound(w, r)
466 return
467 }
468 data, err := embeddedDistFS.ReadFile("dist/tunnel/" + filename)
469 if err != nil {
470 redirectURL := types.OfficialReleaseBaseURL + "/latest/download/" + filename
471 if checksumRequest {
472 redirectURL += ".sha256"
473 }
474 http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
475 return
476 }
477 sum := sha256.Sum256(data)
478 checksumHex := hex.EncodeToString(sum[:])
479
480 if checksumRequest {
481 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
482 if r.Method == http.MethodGet {
483 _, _ = fmt.Fprintf(w, "%s %s\n", checksumHex, filename)
484 }
485 return
486 }
487
488 w.Header().Set("Content-Type", "application/octet-stream")
489 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
490 w.Header().Set("X-Checksum-Sha256", checksumHex)
491 if r.Method == http.MethodGet {
492 _, _ = w.Write(data)
493 }
494 }
495
496 func serveInstallScript(w http.ResponseWriter, r *http.Request, portalURL string, isWindows bool) {
497 if r.Method != http.MethodGet && r.Method != http.MethodHead {
498 w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
499 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
500 return
501 }
502
503 script, filename, contentType, err := installer.RelayScript(portalURL, isWindows)
504 if err != nil {
505 http.Error(w, "failed to render install script", http.StatusInternalServerError)
506 return
507 }
508
509 w.Header().Set("Content-Type", contentType)
510 w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename))
511 if r.Method == http.MethodGet {
512 _, _ = w.Write([]byte(script))
513 }
514 }