Initial Release

This commit is contained in:
2026-05-24 19:40:12 -07:00
commit 531ba84e39
4 changed files with 465 additions and 0 deletions
+15
View File
@@ -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"]
+21
View File
@@ -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.
+130
View File
@@ -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
<!DOCTYPE html>
<html>
<head>
<link rel="icon" type="image/png" href="https://cdn.anime4all/site/assets/favicon.png">
<title>anime4all - Avatars</title>
</head>
<body>
<div class="container-navigation">
<a href="/">Homepage</a>
<a href="/avatars">Avatars</a>
<a href="/wallpapers">Wallpapers</a>
</div>
<div class="container-avatars">
<img src="https://cdn.anime4all.net/private/avatars/miku.png?token={{.token}}" alt="Avatar of 'Hatsune Miku'">
<img src="https://cdn.anime4all.net/private/avatars/teto.png?token={{.token}}" alt="Avatar of 'Kasane Teto'">
<img src="https://cdn.anime4all.net/private/avatars/neru.png?token={{.token}}" alt="Avatar of 'Akita Neru'">
</div>
</body>
</html>
```
+299
View File
@@ -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)
}