main
go 361 lines 11.3 KB
Raw
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "net"
7 "net/http"
8 "net/url"
9 "os"
10 "path/filepath"
11 "strings"
12 "time"
13
14 "github.com/gosuda/portal-tunnel/v2/portal/auth"
15 "github.com/gosuda/portal-tunnel/v2/types"
16 "github.com/gosuda/portal-tunnel/v2/utils"
17 )
18
19 const (
20 controlRequestBodyLimit = 8 << 10
21 endpointFilename = "agent-endpoint.json"
22 agentCookieName = "portal_agent"
23 )
24
25 var (
26 ErrNotRunning = errors.New("portal agent is not running")
27 controlHTTPClient = utils.NewHTTPClient(utils.WithHTTPTimeout(5 * time.Second))
28 )
29
30 type endpoint struct {
31 ControlAddr string `json:"control_addr"`
32 Token string `json:"token"`
33 }
34
35 type controlHandler struct {
36 manager *manager
37 token string
38 auth *auth.WalletAuthenticator
39 shutdown func()
40 }
41
42 func (s *controlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
43 if s.serveWalletAuth(w, r) {
44 return
45 }
46
47 auth := strings.TrimSpace(r.Header.Get("Authorization"))
48 bearerAuthenticated := strings.HasPrefix(auth, "Bearer ") && strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) == s.token
49 walletAddress, walletAuthenticated := s.authenticatedWallet(r)
50 allowed := bearerAuthenticated || (walletAuthenticated && r.URL.Path == types.PathAgentStatus)
51 if !allowed {
52 utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "unauthorized")
53 return
54 }
55
56 switch {
57 case r.URL.Path == types.PathAgentStatus:
58 if !utils.RequireMethod(w, r, http.MethodGet) {
59 return
60 }
61 status := s.manager.Snapshot()
62 if walletAuthenticated {
63 status.WalletAddress = walletAddress
64 }
65 utils.WriteAPIData(w, http.StatusOK, status)
66 case r.URL.Path == types.PathAgentShutdown:
67 if !utils.RequireMethod(w, r, http.MethodPost) {
68 return
69 }
70 utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
71 if s.shutdown != nil {
72 go s.shutdown()
73 }
74 case r.URL.Path == types.PathAgentTunnels:
75 if !utils.RequireMethod(w, r, http.MethodPost) {
76 return
77 }
78 req, ok := utils.DecodeJSONRequest[types.AgentTunnelRequest](w, r, controlRequestBodyLimit)
79 if !ok {
80 return
81 }
82 if err := s.manager.AddTunnel(req); err != nil {
83 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
84 return
85 }
86 utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
87 case strings.HasPrefix(r.URL.Path, types.PathAgentTunnelsPrefix):
88 rest := strings.TrimPrefix(r.URL.Path, types.PathAgentTunnelsPrefix)
89 tunnelID, action, ok := strings.Cut(rest, "/")
90 tunnelID, err := url.PathUnescape(tunnelID)
91 if err != nil || strings.TrimSpace(tunnelID) == "" {
92 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid tunnel id")
93 return
94 }
95 if !ok {
96 switch r.Method {
97 case http.MethodDelete:
98 if err := s.manager.DeleteTunnel(tunnelID); err != nil {
99 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
100 return
101 }
102 case http.MethodPatch:
103 req, ok := utils.DecodeJSONRequest[types.AgentTunnelUpdateRequest](w, r, controlRequestBodyLimit)
104 if !ok {
105 return
106 }
107 if err := s.manager.UpdateTunnel(tunnelID, req); err != nil {
108 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
109 return
110 }
111 default:
112 utils.MethodNotAllowedError().Write(w)
113 return
114 }
115 utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
116 return
117 }
118
119 switch action {
120 case "relays":
121 switch r.Method {
122 case http.MethodPost:
123 case http.MethodDelete:
124 default:
125 utils.MethodNotAllowedError().Write(w)
126 return
127 }
128
129 req, ok := utils.DecodeJSONRequest[types.AgentRelayRequest](w, r, controlRequestBodyLimit)
130 if !ok {
131 return
132 }
133 var err error
134 if r.Method == http.MethodPost {
135 err = s.manager.ConnectRelay(tunnelID, req.RelayURL)
136 } else {
137 err = s.manager.DisconnectRelay(tunnelID, req.RelayURL)
138 }
139 if err != nil {
140 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
141 return
142 }
143 utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
144 case "multi-hop":
145 switch r.Method {
146 case http.MethodPost:
147 req, ok := utils.DecodeJSONRequest[types.AgentMultiHopRequest](w, r, controlRequestBodyLimit)
148 if !ok {
149 return
150 }
151 if err := s.manager.SetMultiHop(tunnelID, req.Relays); err != nil {
152 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
153 return
154 }
155 case http.MethodDelete:
156 if err := s.manager.SetMultiHop(tunnelID, nil); err != nil {
157 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
158 return
159 }
160 default:
161 utils.MethodNotAllowedError().Write(w)
162 return
163 }
164 utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
165 default:
166 utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeNotFound, "not found")
167 }
168 default:
169 utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeNotFound, "not found")
170 }
171 }
172
173 func (s *controlHandler) serveWalletAuth(w http.ResponseWriter, r *http.Request) bool {
174 switch r.URL.Path {
175 case types.PathAgentAuthChallenge:
176 if !utils.RequireMethod(w, r, http.MethodPost) {
177 return true
178 }
179 req, ok := utils.DecodeJSONRequest[types.WalletAuthChallengeRequest](w, r, controlRequestBodyLimit)
180 if !ok {
181 return true
182 }
183 resp, err := s.auth.IssueChallenge(req, agentAuthDomain(r), agentAuthURI(r, types.PathAgentAuthLogin), time.Now().UTC())
184 if err != nil {
185 writeAgentWalletAuthError(w, err)
186 return true
187 }
188 utils.WriteAPIData(w, http.StatusCreated, resp)
189 return true
190 case types.PathAgentAuthLogin:
191 if !utils.RequireMethod(w, r, http.MethodPost) {
192 return true
193 }
194 req, ok := utils.DecodeJSONRequest[types.WalletAuthLoginRequest](w, r, controlRequestBodyLimit)
195 if !ok {
196 return true
197 }
198 token, walletAddress, err := s.auth.Login(req, time.Now().UTC())
199 if err != nil {
200 writeAgentWalletAuthError(w, err)
201 return true
202 }
203 http.SetCookie(w, &http.Cookie{
204 Name: agentCookieName,
205 Value: token,
206 Path: types.PathAgentPrefix,
207 HttpOnly: true,
208 Secure: true,
209 SameSite: http.SameSiteStrictMode,
210 MaxAge: 86400,
211 })
212 utils.WriteAPIData(w, http.StatusOK, types.WalletAuthLoginResponse{WalletAddress: walletAddress})
213 return true
214 case types.PathAgentAuthLogout:
215 if !utils.RequireMethod(w, r, http.MethodPost) {
216 return true
217 }
218 if cookie, err := r.Cookie(agentCookieName); err == nil && cookie.Value != "" {
219 s.auth.DeleteSession(cookie.Value)
220 }
221 http.SetCookie(w, &http.Cookie{
222 Name: agentCookieName,
223 Value: "",
224 Path: types.PathAgentPrefix,
225 HttpOnly: true,
226 Secure: true,
227 SameSite: http.SameSiteStrictMode,
228 MaxAge: -1,
229 })
230 utils.WriteAPIData(w, http.StatusOK, map[string]any{})
231 return true
232 case types.PathAgentAuthStatus:
233 if !utils.RequireMethod(w, r, http.MethodGet) {
234 return true
235 }
236 walletAddress, authenticated := s.authenticatedWallet(r)
237 utils.WriteAPIData(w, http.StatusOK, types.WalletAuthStatusResponse{
238 Authenticated: authenticated,
239 WalletAddress: walletAddress,
240 })
241 return true
242 default:
243 return false
244 }
245 }
246
247 func (s *controlHandler) authenticatedWallet(r *http.Request) (string, bool) {
248 if s == nil || s.auth == nil {
249 return "", false
250 }
251 cookie, err := r.Cookie(agentCookieName)
252 if err != nil {
253 return "", false
254 }
255 return s.auth.ValidateSession(cookie.Value)
256 }
257
258 func agentAuthDomain(r *http.Request) string {
259 domain := strings.TrimSpace(r.Host)
260 if domain != "" {
261 return domain
262 }
263 return "localhost"
264 }
265
266 func agentAuthURI(r *http.Request, endpointPath string) string {
267 scheme := "https"
268 if r.TLS == nil {
269 scheme = "http"
270 }
271 return (&url.URL{
272 Scheme: scheme,
273 Host: agentAuthDomain(r),
274 Path: endpointPath,
275 }).String()
276 }
277
278 func writeAgentWalletAuthError(w http.ResponseWriter, err error) {
279 switch {
280 case errors.Is(err, auth.ErrWalletAuthUnauthorized):
281 utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, err.Error())
282 case errors.Is(err, auth.ErrWalletAuthChallengeNotFound), errors.Is(err, auth.ErrWalletAuthChallengeExpired), errors.Is(err, auth.ErrWalletAuthInvalidSignature):
283 utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, err.Error())
284 default:
285 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
286 }
287 }
288
289 func Status(ctx context.Context, stateDir string) (types.AgentStatusResponse, error) {
290 var status types.AgentStatusResponse
291 err := controlRequest(ctx, stateDir, http.MethodGet, types.PathAgentStatus, nil, &status)
292 return status, err
293 }
294
295 func Shutdown(ctx context.Context, stateDir string) error {
296 return controlRequest(ctx, stateDir, http.MethodPost, types.PathAgentShutdown, nil, nil)
297 }
298
299 func AddTunnel(ctx context.Context, stateDir string, req types.AgentTunnelRequest) error {
300 return controlRequest(ctx, stateDir, http.MethodPost, types.PathAgentTunnels, req, nil)
301 }
302
303 func DeleteTunnel(ctx context.Context, stateDir, tunnelID string) error {
304 path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID)
305 return controlRequest(ctx, stateDir, http.MethodDelete, path, nil, nil)
306 }
307
308 func ConnectRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error {
309 path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/relays"
310 return controlRequest(ctx, stateDir, http.MethodPost, path, types.AgentRelayRequest{RelayURL: relayURL}, nil)
311 }
312
313 func DisconnectRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error {
314 path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/relays"
315 return controlRequest(ctx, stateDir, http.MethodDelete, path, types.AgentRelayRequest{RelayURL: relayURL}, nil)
316 }
317
318 func SetMultiHop(ctx context.Context, stateDir, tunnelID string, relayURLs []string) error {
319 path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/multi-hop"
320 if relayURLs == nil {
321 return controlRequest(ctx, stateDir, http.MethodDelete, path, nil, nil)
322 }
323 return controlRequest(ctx, stateDir, http.MethodPost, path, types.AgentMultiHopRequest{Relays: relayURLs}, nil)
324 }
325
326 func UpdateTunnel(ctx context.Context, stateDir, tunnelID string, req types.AgentTunnelUpdateRequest) error {
327 path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID)
328 return controlRequest(ctx, stateDir, http.MethodPatch, path, req, nil)
329 }
330
331 func controlRequest(ctx context.Context, stateDir, method, path string, payload any, out any) error {
332 stateDir = strings.TrimSpace(stateDir)
333 if stateDir == "" {
334 return errors.New("state dir is required")
335 }
336 var endpoint endpoint
337 if err := utils.ReadJSONFile(filepath.Join(stateDir, endpointFilename), &endpoint); err != nil {
338 if os.IsNotExist(err) {
339 return ErrNotRunning
340 }
341 return err
342 }
343 if strings.TrimSpace(endpoint.ControlAddr) == "" || strings.TrimSpace(endpoint.Token) == "" {
344 return errors.New("agent endpoint state is incomplete")
345 }
346 baseURL, err := url.Parse("http://" + endpoint.ControlAddr)
347 if err != nil {
348 return err
349 }
350 headers := http.Header{"Authorization": []string{"Bearer " + endpoint.Token}}
351 err = utils.HTTPDoAPIPath(ctx, controlHTTPClient, baseURL, method, path, payload, headers, out)
352 if controlDialError(err) {
353 return ErrNotRunning
354 }
355 return err
356 }
357
358 func controlDialError(err error) bool {
359 var opErr *net.OpError
360 return errors.As(err, &opErr) && opErr.Op == "dial"
361 }