From 531ba84e399a2aee2b64eb8999f7c49d68415d87 Mon Sep 17 00:00:00 2001 From: bakonpancakz Date: Sun, 24 May 2026 19:40:12 -0700 Subject: [PATCH] Initial Release --- Dockerfile | 15 +++ LICENSE | 21 ++++ README.md | 130 +++++++++++++++++++++++ main.go | 299 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 465 insertions(+) create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 main.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7e1c669 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# Build +FROM golang:1.26.3-alpine3.22 AS build +WORKDIR /app +COPY . . +RUN go build -o gatekeeper.elf main.go + +# Runtime +FROM alpine:latest AS runtime +WORKDIR /app +COPY --from=build /app/gatekeeper.elf . + +ENV HTTP_ADDRESS="0.0.0.0:8080" +EXPOSE 8080 + +CMD ["./gatekeeper.elf"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6a5decc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2026 bakonpancakz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f669797 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# 📦 `tools-gatekeeper` + +A lightweight web server that protects **private S3 files** using **token-based authentication**. + +> Originally tested with [DigitalOcean Spaces](https://www.digitalocean.com/products/spaces/) and served via [Cloudflare CDN](https://www.cloudflare.com/cdn/). +> Compatible with **any S3-compatible provider**. + +## ⚙️ Configuration +You can configure the Gatekeeper through **environment variables**. + +> **Required variables** are marked with an asterisk `*` + +| Environment Variable | Description | +| -------------------- | -------------------------------------------------------------------- | +| `*S3_ACCESS_KEY` | S3-compatible API access key | +| `*S3_SECRET_KEY` | S3-compatible API secret key | +| `*S3_ENDPOINT` | S3 Endpoint (e.g. `https://nyc3.digitaloceanspaces.com`) | +| `*S3_REGION` | S3 Region (e.g. `us-east-1`) | +| `S3_BUCKET` | S3 Bucket Name | +| `HTTP_ADDRESS` | Address and port to accept requests on, defaults to `localhost:8080` | +| `HTTP_CORS_ORIGIN` | Enables CORS for the given origin. Leave blank to disable. | +| `HTTP_KEY` | Shared secret used for token verification. | + +## 🔰 Generating Tokens +Gatekeeper protects all files under the `/private` path in your bucket. +Any request to `/private/...` must include a valid token in the query parameter: + +In this example we'll be hosting a anime website and our directory structure looks like this: +``` +. cdn.anime4all.net +|__ /site +| |__ /images +| | |__ index-hero.png +| | |__ index-carousel-1.png +| | |__ index-carousel-2.png +| | |__ index-carousel-3.png +| |__ /assets +| |__ /favicon.png +| |__ /index.css +| |__ /index.js +|__ /private + |__ /wallpapers + | |__ codegeass_16x9_1080.png + | |__ cowboybebop_4x3_480.png + | |__ evangelion_4x3_480.png + |__ /avatars + |__ miku.png + |__ teto.png + |__ neru.png +``` + +To access our super cool avatars and wallpapers, users must be logged in — those files live inside the /private directory and require a valid token. + +--- + +### Step 1. Create Payload +Our gatekeeper expects the token payload to at minimum include two fields: + +* `pattern:` Our filename matching pattern +* `expires:` The UNIX Timestamp for when our token expires + +> **Note:** You can include arbitrary fields like `user_id` for your own logging purposes or whatnot. +> The gatekeeper will ignore them. + +Our website displays all avatars on a single page so we'll give our user full access to the private avatars directory, which matches the pattern `/private/avatars/*`. +If we wanted to give them access to a single file we could alternatively do `/private/avatars/teto.png`. + +> **Note:** The gatekeeper uses the [path.Match](https://pkg.go.dev/path#Match) function internally for pattern matching. You can read it's documentation to learn more. + +```go +payload, err := json.Marshal(map[string]any{ + "pattern": "/private/avatars/*", // Allow all files in avatars folder + "expires": time.Now().Unix() + 3600 // Token expires in 1 hour +}) +if err != nil { + panic(err) +} +``` +Best practice is to ensure your server clock is correct and that the token lifetime isn't longer than it should be! + +--- + +### Step 2. Generating Signature +After we stringified our JSON payload we will sign its bytes with HMAC-SHA256 and our secret key which we share between the gatekeeper and our webserver. In GO we can do this using the hmac standard library. +```go +signer := hmac.New(sha256.New, []byte("your-shared-secret-key")) // envvar: HTTP_SIGNATURE +signer.Write(payload) +signature := signer.Sum(nil) +``` + +--- + +### Step 3. Compiling Token +Finally we encode both the payload and signature into base64 (URL encoding is fine, but raw encoding is shorter), +and concatenate them together using a period as the delimiter. + +```go +encodedPayload := base64.RawURLEncoding.EncodeToString(payload) +encodedSignature := base64.RawURLEncoding.EncodeToString(signature) +token := fmt.Sprintf("%s.%s", encodedPayload, encodedSignature) +``` + +--- + +### Step 4. Rendering +Our anime site also uses Go Templates (not php lol) so we generate a URL with our hostname, path, and our freshly baked token in the query parameter: +```html + + + + + + anime4all - Avatars + + + +
+ Homepage + Avatars + Wallpapers +
+
+ Avatar of 'Hatsune Miku' + Avatar of 'Kasane Teto' + Avatar of 'Akita Neru' +
+ + + +``` \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..8323ef3 --- /dev/null +++ b/main.go @@ -0,0 +1,299 @@ +package main + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/signal" + "path" + "strings" + "sync" + "syscall" + "time" +) + +var ( + S3_HOST string + S3_SECRET_KEY = EnvString("S3_SECRET_KEY", "xyz") + S3_ACCESS_KEY = EnvString("S3_ACCESS_KEY", "123") + S3_ENDPOINT = EnvString("S3_ENDPOINT", "https://bucket.s3.region.host.tld") + S3_REGION = EnvString("S3_REGION", "region") + S3_BUCKET = EnvString("S3_BUCKET", "bucket") + HTTP_KEY = EnvString("HTTP_KEY", "teto") + HTTP_ADDRESS = EnvString("HTTP_ADDRESS", "localhost:8080") + HTTP_CORS_ORIGIN = EnvString("HTTP_CORS_ORIGIN", "") +) + +// ---------------------------------------------------------------------------- +// HELPER FUNCTIONS +// ---------------------------------------------------------------------------- + +func EnvString(field, initial string) string { + if value := os.Getenv(field); value == "" { + return initial + } else { + return value + } +} + +func SHA256HEX(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + +func SHA256HMAC(key, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} + +func ValidateToken(r *http.Request) bool { + requestPath := r.URL.Path + requestToken := r.URL.Query().Get("token") + if requestToken == "" { + return false + } + + // Decode Token Segments + s := strings.SplitN(requestToken, ".", 2) + if len(s) != 2 { + return false + } + payload, err := base64.RawURLEncoding.DecodeString(s[0]) + if err != nil { + return false + } + signature, err := base64.RawURLEncoding.DecodeString(s[1]) + if err != nil { + return false + } + + // Validate Token Signature + hash := hmac.New(sha256.New, []byte(HTTP_KEY)) + hash.Write(payload) + if !hmac.Equal(signature, hash.Sum(nil)) { + return false + } + + // Parse Token Data + var TokenData struct { + Pattern string `json:"pattern"` + Expires int64 `json:"expires"` + } + if err := json.Unmarshal(payload, &TokenData); err != nil { + return false + } + if time.Now().Unix() > TokenData.Expires { + return false + } + if ok, err := path.Match(TokenData.Pattern, requestPath); err != nil || !ok { + return false + } + + // LGTM + return true +} + +func SignRequest(r *http.Request) { + // Timestamp + t := time.Now().UTC() + dateStamp := t.Format("20060102") + dataAmazon := t.Format("20060102T150405Z") + + // Hash Content + payload := []byte{} + payloadHashHexSHA := SHA256HEX(payload) + + // Apply Headers + r.Header.Set("Host", S3_HOST) + r.Header.Set("x-amz-date", dataAmazon) + r.Header.Set("x-amz-content-sha256", payloadHashHexSHA) + r.Host = S3_HOST + + // 1. Create a canonical request + signedHeaders := "host;x-amz-content-sha256;x-amz-date" + canonicalHeaders := fmt.Sprintf( + "host:%s\nx-amz-content-sha256:%s\nx-amz-date:%s\n", + r.Host, payloadHashHexSHA, dataAmazon, + ) + canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", + r.Method, + r.URL.EscapedPath(), + r.URL.Query().Encode(), + canonicalHeaders, + signedHeaders, + payloadHashHexSHA, + ) + + // Step 2: String to sign + service := "s3" + scope := fmt.Sprintf("%s/%s/%s/aws4_request", dateStamp, S3_REGION, service) + stringToSign := fmt.Sprintf("AWS4-HMAC-SHA256\n%s\n%s\n%s", + dataAmazon, scope, SHA256HEX([]byte(canonicalRequest)), + ) + + // Step 3: Derive signing key + kDate := SHA256HMAC([]byte("AWS4"+S3_SECRET_KEY), []byte(dateStamp)) + kRegion := SHA256HMAC(kDate, []byte(S3_REGION)) + kService := SHA256HMAC(kRegion, []byte(service)) + kSigning := SHA256HMAC(kService, []byte("aws4_request")) + + // Step 4: Signature + signature := hex.EncodeToString(SHA256HMAC(kSigning, []byte(stringToSign))) + authHeader := fmt.Sprintf( + "AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", + S3_ACCESS_KEY, scope, signedHeaders, signature, + ) + r.Header.Set("Authorization", authHeader) +} + +// ---------------------------------------------------------------------------- +// SERVICE FUNCTIONS +// ---------------------------------------------------------------------------- + +func SetupMux() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + + // Handle CORS Requests + if HTTP_CORS_ORIGIN != "" { + w.Header().Add("Access-Control-Allow-Origin", HTTP_CORS_ORIGIN) + w.Header().Add("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Add("Access-Control-Allow-Headers", "Content-Type") + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusNoContent) + return + } + } + + // Request Validation + if r.Method != "GET" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if r.URL.Path == "/" { + w.WriteHeader(http.StatusNotFound) + return + } + + // Check Access Token for Private Endpoints + if strings.HasPrefix(r.URL.Path, "/private") { + w.Header().Add("Cache-Control", "no-store") + if !ValidateToken(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + } + + // Request Bucket File + url := fmt.Sprint(S3_ENDPOINT, r.URL.Path) + req, err := http.NewRequest(http.MethodGet, url, http.NoBody) + if err != nil { + return + } + SignRequest(req) + + res, err := http.DefaultClient.Do(req) + if err != nil { + http.Error(w, "Upstream Error", http.StatusBadGateway) + return + } + defer res.Body.Close() + + // Stream File Contents + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("Content-Type", res.Header.Get("Content-Type")) + w.WriteHeader(res.StatusCode) + io.Copy(w, res.Body) + }) + return mux +} + +func StartupHTTP(stop context.Context, await *sync.WaitGroup) { + var listener net.Listener + var err error + + if strings.HasPrefix(HTTP_ADDRESS, "unix/") { + path := strings.TrimPrefix(HTTP_ADDRESS, "unix/") + os.Remove(path) + listener, err = net.Listen("unix", path) + if err == nil { + os.Chmod(path, 0660) + } + } else { + listener, err = net.Listen("tcp", HTTP_ADDRESS) + } + if err != nil { + log.Fatalf("Listen Failed: %s\n", err) + return + } + + svr := http.Server{ + Handler: SetupMux(), + MaxHeaderBytes: 4096, + IdleTimeout: 10 * time.Second, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } + + // Shutdown Logic + await.Add(1) + go func() { + defer await.Done() + <-stop.Done() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + if err := svr.Shutdown(shutdownCtx); err != nil { + log.Printf("Shutdown Error: %s\n", err) + return + } + log.Println("Server Closed") + }() + + log.Printf("Listening @ %s\n", HTTP_ADDRESS) + if err := svr.Serve(listener); err != http.ErrServerClosed { + log.Printf("Startup Failed: %s\n", err) + return + } +} + +func main() { + time.Local = time.UTC + + // Startup Services + var stopCtx, stop = context.WithCancel(context.Background()) + var stopWg sync.WaitGroup + go StartupHTTP(stopCtx, &stopWg) + + // Await Shutdown Signal + cancel := make(chan os.Signal, 1) + signal.Notify(cancel, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + <-cancel + stop() + + // Begin Shutdown Process + log.Println("Shutting Down!") + timeout, finish := context.WithTimeout(context.Background(), time.Minute) + defer finish() + go func() { + <-timeout.Done() + if timeout.Err() == context.DeadlineExceeded { + log.Fatalln("Shutdown Deadline Exceeded") + return + } + }() + stopWg.Wait() + os.Exit(0) +}