Initial Release
This commit is contained in:
+15
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user