mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
544 lines
18 KiB
Go
544 lines
18 KiB
Go
package api
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
const (
|
|
canonicalUnifiedAgentReportPath = "/api/agents/agent/report"
|
|
legacyUnifiedAgentReportPath = "/api/agents/host/report"
|
|
defaultInstallScriptReleaseRepo = "rcourtman/Pulse"
|
|
)
|
|
|
|
func installScriptReleaseRepo() string {
|
|
repo := strings.TrimSpace(os.Getenv("PULSE_GITHUB_REPO"))
|
|
if repo == "" {
|
|
return defaultInstallScriptReleaseRepo
|
|
}
|
|
return repo
|
|
}
|
|
|
|
func githubReleaseDownloadURL(assetName string) string {
|
|
return fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", installScriptReleaseRepo(), assetName)
|
|
}
|
|
|
|
func githubReleaseAssetURL(tag, assetName string) string {
|
|
return fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", installScriptReleaseRepo(), strings.TrimSpace(tag), assetName)
|
|
}
|
|
|
|
func githubLatestReleaseAPIURL() string {
|
|
return fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", installScriptReleaseRepo())
|
|
}
|
|
|
|
func (r *Router) handleDownloadUnifiedInstallScript(w http.ResponseWriter, req *http.Request) {
|
|
handleDownloadInstallScriptCommon(w, req, "/opt/pulse/scripts/install.sh", filepath.Join(r.projectRoot, "scripts", "install.sh"), "install.sh", "text/x-shellscript", r.proxyInstallScriptFromGitHub)
|
|
}
|
|
|
|
func (r *Router) handleDownloadUnifiedInstallScriptPS(w http.ResponseWriter, req *http.Request) {
|
|
handleDownloadInstallScriptCommon(w, req, "/opt/pulse/scripts/install.ps1", filepath.Join(r.projectRoot, "scripts", "install.ps1"), "install.ps1", "text/plain", r.proxyInstallScriptFromGitHub)
|
|
}
|
|
|
|
type proxyFunc func(http.ResponseWriter, *http.Request, string)
|
|
|
|
func handleDownloadInstallScriptCommon(w http.ResponseWriter, req *http.Request, prodPath, fallbackPath, scriptName, contentType string, fallbackProxy proxyFunc) {
|
|
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
w.Header().Set("Pragma", "no-cache")
|
|
w.Header().Set("Expires", "0")
|
|
|
|
scriptPath := prodPath
|
|
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
|
scriptPath = fallbackPath
|
|
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
|
log.Info().Msgf("Local %s not found, proxying from GitHub releases", scriptName)
|
|
fallbackProxy(w, req, scriptName)
|
|
return
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Content-Disposition", "inline; filename=\""+scriptName+"\"")
|
|
http.ServeFile(w, req, scriptPath)
|
|
}
|
|
|
|
// normalizeUnifiedAgentArch normalizes architecture strings for the unified agent.
|
|
func normalizeUnifiedAgentArch(arch string) string {
|
|
arch = strings.ToLower(strings.TrimSpace(arch))
|
|
switch arch {
|
|
case "linux-amd64", "amd64", "x86_64":
|
|
return "linux-amd64"
|
|
case "linux-arm64", "arm64", "aarch64":
|
|
return "linux-arm64"
|
|
case "linux-armv7", "armv7", "armv7l", "armhf":
|
|
return "linux-armv7"
|
|
case "linux-armv6", "armv6":
|
|
return "linux-armv6"
|
|
case "linux-386", "386", "i386", "i686":
|
|
return "linux-386"
|
|
case "darwin-amd64", "macos-amd64":
|
|
return "darwin-amd64"
|
|
case "darwin-arm64", "macos-arm64":
|
|
return "darwin-arm64"
|
|
case "freebsd-amd64":
|
|
return "freebsd-amd64"
|
|
case "freebsd-arm64":
|
|
return "freebsd-arm64"
|
|
case "windows-amd64":
|
|
return "windows-amd64"
|
|
case "windows-arm64":
|
|
return "windows-arm64"
|
|
case "windows-386":
|
|
return "windows-386"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// handleDownloadUnifiedAgent serves the pulse-agent binary
|
|
func (r *Router) handleDownloadUnifiedAgent(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Prevent caching - always serve the latest version
|
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
w.Header().Set("Pragma", "no-cache")
|
|
w.Header().Set("Expires", "0")
|
|
|
|
archParam := strings.TrimSpace(req.URL.Query().Get("arch"))
|
|
|
|
// Validate architecture if provided
|
|
if archParam != "" && normalizeUnifiedAgentArch(archParam) == "" {
|
|
http.Error(w, "Invalid architecture specified", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
searchPaths := make([]string, 0, 6)
|
|
|
|
// If a specific architecture is requested, only look for that architecture
|
|
// Do NOT fall back to generic binary - that could serve the wrong architecture
|
|
normalized := normalizeUnifiedAgentArch(archParam)
|
|
if normalized != "" {
|
|
searchPaths = append(searchPaths,
|
|
filepath.Join(pulseBinDir(), "pulse-agent-"+normalized),
|
|
filepath.Join("/opt/pulse", "pulse-agent-"+normalized),
|
|
filepath.Join("/app", "pulse-agent-"+normalized),
|
|
filepath.Join(r.projectRoot, "bin", "pulse-agent-"+normalized),
|
|
)
|
|
} else {
|
|
// No specific architecture requested - allow fallback to generic binary
|
|
searchPaths = append(searchPaths,
|
|
filepath.Join(pulseBinDir(), "pulse-agent"),
|
|
"/opt/pulse/pulse-agent",
|
|
filepath.Join("/app", "pulse-agent"),
|
|
filepath.Join(r.projectRoot, "bin", "pulse-agent"),
|
|
)
|
|
}
|
|
|
|
invalidCandidates := make([]string, 0, len(searchPaths))
|
|
|
|
for _, candidate := range searchPaths {
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
|
|
info, err := os.Stat(candidate)
|
|
if err != nil || info.IsDir() {
|
|
continue
|
|
}
|
|
|
|
if err := validateUnifiedAgentBinary(candidate); err != nil {
|
|
log.Warn().Err(err).Str("path", candidate).Msg("Skipping incompatible local unified agent binary")
|
|
invalidCandidates = append(invalidCandidates, fmt.Sprintf("%s (%v)", candidate, err))
|
|
continue
|
|
}
|
|
|
|
checksum, err := r.cachedSHA256(candidate, info)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("path", candidate).Msg("Failed to compute unified agent checksum")
|
|
continue
|
|
}
|
|
|
|
file, err := os.Open(candidate)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("path", candidate).Msg("Failed to open unified agent binary for download")
|
|
continue
|
|
}
|
|
defer file.Close()
|
|
|
|
w.Header().Set("X-Checksum-Sha256", checksum)
|
|
http.ServeContent(w, req, filepath.Base(candidate), info.ModTime(), file)
|
|
return
|
|
}
|
|
|
|
if len(invalidCandidates) > 0 {
|
|
log.Warn().Strs("paths", invalidCandidates).Msg("Ignoring stale local unified agent binaries")
|
|
}
|
|
|
|
// In dev mode, never fall through to GitHub releases — the released binary
|
|
// would lack current fixes. Return a clear 404 with build instructions.
|
|
if r.serverVersion == "dev" {
|
|
reason := fmt.Sprintf("Agent binary not found for %q in dev mode.", normalized)
|
|
if len(invalidCandidates) > 0 {
|
|
reason = fmt.Sprintf("Local agent binary for %q is stale or incompatible in dev mode:\n %s",
|
|
normalized,
|
|
strings.Join(invalidCandidates, "\n "),
|
|
)
|
|
}
|
|
http.Error(w, reason+"\nBuild with:\n GOOS=linux GOARCH=amd64 go build -o bin/pulse-agent-linux-amd64 ./cmd/pulse-agent", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Fallback: proxy from GitHub releases for the binary
|
|
// This handles LXC/barebone installations that don't have agent binaries locally.
|
|
// We proxy instead of redirecting because agents require the X-Checksum-Sha256 header,
|
|
// which GitHub doesn't provide.
|
|
if normalized != "" {
|
|
r.proxyAgentBinaryFromGitHub(w, req, normalized)
|
|
return
|
|
}
|
|
|
|
// No architecture specified and no local binary - can't redirect without knowing arch
|
|
if len(invalidCandidates) > 0 {
|
|
http.Error(w, "Local agent binary is stale or incompatible. Specify ?arch=linux-amd64 (or your architecture) after rebuilding the local agent artifact.", http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, "Agent binary not found. Specify ?arch=linux-amd64 (or your architecture)", http.StatusNotFound)
|
|
}
|
|
|
|
func validateUnifiedAgentBinary(path string) error {
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("read binary: %w", err)
|
|
}
|
|
if bytes.Contains(content, []byte(legacyUnifiedAgentReportPath)) {
|
|
return fmt.Errorf("references deprecated host endpoint %s", legacyUnifiedAgentReportPath)
|
|
}
|
|
if !bytes.Contains(content, []byte(canonicalUnifiedAgentReportPath)) {
|
|
return fmt.Errorf("missing canonical host endpoint %s", canonicalUnifiedAgentReportPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// proxyAgentBinaryFromGitHub downloads an agent binary from GitHub releases and serves
|
|
// it to the requesting agent with the X-Checksum-Sha256 header. This is used when the
|
|
// binary isn't available locally (e.g., LXC/bare-metal installations updated via web UI).
|
|
// We must proxy instead of redirecting because the agent requires the checksum header
|
|
// for security verification, and GitHub doesn't provide it.
|
|
func (r *Router) proxyAgentBinaryFromGitHub(w http.ResponseWriter, req *http.Request, normalized string) {
|
|
binaryName := "pulse-agent-" + normalized
|
|
if strings.HasPrefix(normalized, "windows-") {
|
|
binaryName += ".exe"
|
|
}
|
|
githubURL := githubReleaseDownloadURL(binaryName)
|
|
|
|
log.Info().Str("arch", normalized).Str("url", githubURL).Msg("Local agent binary not found, proxying from GitHub releases")
|
|
|
|
client := r.installScriptClient
|
|
if client == nil {
|
|
client = &http.Client{
|
|
Timeout: 5 * time.Minute,
|
|
}
|
|
}
|
|
|
|
resp, err := client.Get(githubURL)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("url", githubURL).Msg("Failed to fetch agent binary from GitHub")
|
|
http.Error(w, "Failed to fetch agent binary", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
content, checksum, readErr := readBinaryWithChecksum(resp.Body)
|
|
if readErr != nil {
|
|
log.Error().Err(readErr).Msg("Failed to read agent binary from GitHub")
|
|
http.Error(w, "Failed to read agent binary", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
serveProxiedAgentBinary(w, content, checksum, "github-proxy")
|
|
return
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
log.Error().Int("status", resp.StatusCode).Str("url", githubURL).Msg("GitHub returned non-200 status for agent binary")
|
|
http.Error(w, "Agent binary not found on GitHub", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
archiveContent, checksum, archiveErr := r.fetchAgentBinaryFromReleaseArchive(client, normalized)
|
|
if archiveErr != nil {
|
|
log.Error().Err(archiveErr).Str("arch", normalized).Msg("Failed archive fallback for agent binary")
|
|
http.Error(w, "Agent binary not found on GitHub", http.StatusNotFound)
|
|
return
|
|
}
|
|
serveProxiedAgentBinary(w, archiveContent, checksum, "github-proxy-archive")
|
|
}
|
|
|
|
const maxAgentBinarySize = 100 * 1024 * 1024
|
|
|
|
func readBinaryWithChecksum(body io.Reader) ([]byte, string, error) {
|
|
limitedReader := io.LimitReader(body, maxAgentBinarySize+1)
|
|
hasher := sha256.New()
|
|
content, err := io.ReadAll(io.TeeReader(limitedReader, hasher))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if int64(len(content)) > maxAgentBinarySize {
|
|
return nil, "", fmt.Errorf("binary exceeds size limit")
|
|
}
|
|
return content, hex.EncodeToString(hasher.Sum(nil)), nil
|
|
}
|
|
|
|
func serveProxiedAgentBinary(w http.ResponseWriter, content []byte, checksum, servedFrom string) {
|
|
w.Header().Set("X-Checksum-Sha256", checksum)
|
|
w.Header().Set("X-Served-From", servedFrom)
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Write(content)
|
|
}
|
|
|
|
func (r *Router) fetchAgentBinaryFromReleaseArchive(client *http.Client, normalized string) ([]byte, string, error) {
|
|
tag, err := fetchLatestReleaseTag(client)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
version := strings.TrimPrefix(tag, "v")
|
|
|
|
archiveName := fmt.Sprintf("pulse-agent-v%s-%s.tar.gz", version, normalized)
|
|
entryName := "pulse-agent-" + normalized
|
|
isWindows := strings.HasPrefix(normalized, "windows-")
|
|
if isWindows {
|
|
archiveName = fmt.Sprintf("pulse-agent-v%s-%s.zip", version, normalized)
|
|
entryName += ".exe"
|
|
}
|
|
|
|
archiveURL := githubReleaseAssetURL(tag, archiveName)
|
|
resp, err := client.Get(archiveURL)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to fetch release archive: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, "", fmt.Errorf("release archive returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
archiveReader := io.LimitReader(resp.Body, maxAgentBinarySize+1)
|
|
archiveBytes, err := io.ReadAll(archiveReader)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed reading release archive: %w", err)
|
|
}
|
|
if int64(len(archiveBytes)) > maxAgentBinarySize {
|
|
return nil, "", fmt.Errorf("release archive exceeded size limit")
|
|
}
|
|
|
|
var binary []byte
|
|
if isWindows {
|
|
binary, err = extractFromZip(archiveBytes, entryName)
|
|
} else {
|
|
binary, err = extractFromTarGz(archiveBytes, entryName)
|
|
}
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if int64(len(binary)) > maxAgentBinarySize {
|
|
return nil, "", fmt.Errorf("extracted binary exceeded size limit")
|
|
}
|
|
sum := sha256.Sum256(binary)
|
|
return binary, hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func fetchLatestReleaseTag(client *http.Client) (string, error) {
|
|
req, err := http.NewRequest(http.MethodGet, githubLatestReleaseAPIURL(), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
req.Header.Set("User-Agent", "pulse-agent-download-proxy")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("latest release lookup returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var payload struct {
|
|
TagName string `json:"tag_name"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
|
|
return "", fmt.Errorf("failed decoding latest release payload: %w", err)
|
|
}
|
|
tag := strings.TrimSpace(payload.TagName)
|
|
if tag == "" {
|
|
return "", fmt.Errorf("latest release payload missing tag_name")
|
|
}
|
|
return tag, nil
|
|
}
|
|
|
|
func extractFromTarGz(archive []byte, entryName string) ([]byte, error) {
|
|
gzReader, err := gzip.NewReader(bytes.NewReader(archive))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open tar.gz: %w", err)
|
|
}
|
|
defer gzReader.Close()
|
|
|
|
tr := tar.NewReader(gzReader)
|
|
for {
|
|
header, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed reading tar entry: %w", err)
|
|
}
|
|
if filepath.Base(header.Name) != entryName {
|
|
continue
|
|
}
|
|
content, err := io.ReadAll(io.LimitReader(tr, maxAgentBinarySize+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed reading binary from tar.gz: %w", err)
|
|
}
|
|
if int64(len(content)) > maxAgentBinarySize {
|
|
return nil, fmt.Errorf("binary from tar.gz exceeded size limit")
|
|
}
|
|
return content, nil
|
|
}
|
|
return nil, fmt.Errorf("binary %q not found in tar.gz", entryName)
|
|
}
|
|
|
|
func extractFromZip(archive []byte, entryName string) ([]byte, error) {
|
|
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open zip: %w", err)
|
|
}
|
|
for _, file := range zr.File {
|
|
if filepath.Base(file.Name) != entryName {
|
|
continue
|
|
}
|
|
rc, err := file.Open()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed opening binary in zip: %w", err)
|
|
}
|
|
content, readErr := io.ReadAll(io.LimitReader(rc, maxAgentBinarySize+1))
|
|
rc.Close()
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("failed reading binary from zip: %w", readErr)
|
|
}
|
|
if int64(len(content)) > maxAgentBinarySize {
|
|
return nil, fmt.Errorf("binary from zip exceeded size limit")
|
|
}
|
|
return content, nil
|
|
}
|
|
return nil, fmt.Errorf("binary %q not found in zip", entryName)
|
|
}
|
|
|
|
func (r *Router) installScriptReleaseAssetURL(scriptName string) (string, error) {
|
|
rawVersion := strings.TrimSpace(r.serverVersion)
|
|
if rawVersion == "" {
|
|
return "", fmt.Errorf("server version is unavailable")
|
|
}
|
|
if strings.EqualFold(rawVersion, "dev") {
|
|
return "", fmt.Errorf("development builds must serve local install scripts")
|
|
}
|
|
|
|
version, err := updates.ParseVersion(rawVersion)
|
|
if err != nil {
|
|
return "", fmt.Errorf("server version %q is not a published release version", rawVersion)
|
|
}
|
|
if !version.IsPublishedReleaseAssetVersion() {
|
|
return "", fmt.Errorf("server version %q is not a published release asset version", rawVersion)
|
|
}
|
|
|
|
return fmt.Sprintf(
|
|
"https://github.com/%s/releases/download/v%s/%s",
|
|
installScriptReleaseRepo(),
|
|
version.String(),
|
|
scriptName,
|
|
), nil
|
|
}
|
|
|
|
// proxyInstallScriptFromGitHub fetches an install script from the exact GitHub
|
|
// release asset that matches the running server version. This is used as a
|
|
// fallback when scripts aren't available locally (for example in LXC updates).
|
|
func (r *Router) proxyInstallScriptFromGitHub(w http.ResponseWriter, req *http.Request, scriptName string) {
|
|
githubURL, err := r.installScriptReleaseAssetURL(scriptName)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("server_version", strings.TrimSpace(r.serverVersion)).Str("script", scriptName).Msg("Install script fallback unavailable for current server build")
|
|
http.Error(w, "Install script unavailable for current server build", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
client := r.installScriptClient
|
|
if client == nil {
|
|
client = &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
}
|
|
}
|
|
|
|
proxyReq, err := http.NewRequestWithContext(req.Context(), req.Method, githubURL, nil)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("url", githubURL).Msg("Failed to create install script fallback request")
|
|
http.Error(w, "Failed to fetch install script", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp, err := client.Do(proxyReq)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("url", githubURL).Msg("Failed to fetch install script from GitHub")
|
|
http.Error(w, "Failed to fetch install script", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Error().Int("status", resp.StatusCode).Str("url", githubURL).Msg("GitHub returned non-200 status for install script")
|
|
http.Error(w, "Install script not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Read the script content
|
|
content, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to read install script from GitHub")
|
|
http.Error(w, "Failed to read install script", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Determine content type based on script extension
|
|
contentType := "text/x-shellscript"
|
|
if strings.HasSuffix(scriptName, ".ps1") {
|
|
contentType = "text/plain"
|
|
}
|
|
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Content-Disposition", "inline; filename=\""+scriptName+"\"")
|
|
w.Header().Set("X-Served-From", "github-fallback")
|
|
if req.Method == http.MethodHead {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
w.Write(content)
|
|
}
|