| 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 | } |