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