Serve behind Traefik and rate limit password guesses
The deployment is a public hostname behind Traefik rather than a LAN-only box, which changes two things. TLS is now terminated by the proxy, so Basic credentials are no longer in cleartext. The container publishes no ports: doing so would leave an unencrypted copy of the app on the host, bypassing the proxy. The hostname lives in .env rather than compose.yaml, so no infrastructure detail is committed and the MIT publication option stays open. The password is now the only thing between the internet and the app, and a 500 ms sleep is not a defence at that exposure. Wrong guesses are rate limited per client address: five in a burst, then one per ten seconds, answered with 429. Two details that decide whether this works at all: - a request with no Authorization header is not charged. That is the handshake every browser session opens with, and counting it would lock the household out for simply opening the app a few times. - X-Forwarded-For is believed only when the connection arrived from a private address, i.e. through the proxy, and then only its last entry, which is the one the proxy observed. A direct client could otherwise forge a fresh address per attempt and walk past the limiter entirely. None of this substitutes for a strong password. It removes brute force as a practical route, nothing more. PRD §3, §9 and §10 are updated: "no external internet exposure" is no longer true.
This commit is contained in:
+3
-2
@@ -8,8 +8,9 @@ FOODSTER_TAG=latest
|
|||||||
# Shared household password. The app will not start without it.
|
# Shared household password. The app will not start without it.
|
||||||
FOODSTER_PASSWORD=changeme
|
FOODSTER_PASSWORD=changeme
|
||||||
|
|
||||||
# Host port to publish on.
|
# Hostname Traefik routes to. Kept here rather than in compose.yaml so no
|
||||||
FOODSTER_PORT=8080
|
# infrastructure detail is committed.
|
||||||
|
FOODSTER_HOST=foodster.example.com
|
||||||
|
|
||||||
# The database lives in ./data, bind-mounted into the container. These must
|
# The database lives in ./data, bind-mounted into the container. These must
|
||||||
# match whoever owns that directory on the host, or the container cannot
|
# match whoever owns that directory on the host, or the container cannot
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ polished, it may be released as FOSS under MIT.
|
|||||||
password gates the whole app (§9).
|
password gates the whole app (§9).
|
||||||
- No nutrition tracking, calorie counting, or dietary-goal optimization.
|
- No nutrition tracking, calorie counting, or dietary-goal optimization.
|
||||||
- No mobile-native apps. Web only (mobile-friendly responsive is enough).
|
- No mobile-native apps. Web only (mobile-friendly responsive is enough).
|
||||||
- No external internet exposure. Runs on the home LAN.
|
- No per-user accounts or sessions. The app *is* reachable from the internet
|
||||||
|
(§9, §10), gated by a single shared password over TLS.
|
||||||
|
|
||||||
## 4. Delivery stages
|
## 4. Delivery stages
|
||||||
|
|
||||||
@@ -335,11 +336,18 @@ build and no asset bundler.
|
|||||||
- **Auth**: HTTP Basic with one shared household password read from
|
- **Auth**: HTTP Basic with one shared household password read from
|
||||||
`FOODSTER_PASSWORD`; the username is ignored. Compared using
|
`FOODSTER_PASSWORD`; the username is ignored. Compared using
|
||||||
`subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor
|
`subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor
|
||||||
its length leaks through timing. A failed attempt sleeps 500 ms, which is
|
its length leaks through timing. `/healthz` is the only route outside auth.
|
||||||
throttle enough for a LAN-only app. Note that Basic credentials travel in
|
- **Exposure**: the app is served on a public hostname behind Traefik, which
|
||||||
cleartext over plain HTTP — acceptable on a private LAN, and the reason to
|
terminates TLS, so Basic credentials are encrypted in transit. A shared
|
||||||
add TLS if this is ever reachable from anywhere else. `/healthz` is the
|
password is therefore the only thing between the internet and the app, and
|
||||||
only route outside auth.
|
it is guarded by a per-address rate limiter: five wrong guesses, then one
|
||||||
|
per ten seconds, answered with `429`. Only requests that actually present
|
||||||
|
a wrong password spend the allowance — a request with no `Authorization`
|
||||||
|
header is the normal browser handshake that opens every session.
|
||||||
|
`X-Forwarded-For` is trusted only when the connection arrived from a
|
||||||
|
private address, so a direct client cannot forge a new identity per
|
||||||
|
attempt. None of this substitutes for a strong password; it only removes
|
||||||
|
brute force as a practical route.
|
||||||
- **Containers**: built with Podman in development, run under Docker Compose
|
- **Containers**: built with Podman in development, run under Docker Compose
|
||||||
in production. Images are OCI, so one image works with both engines.
|
in production. Images are OCI, so one image works with both engines.
|
||||||
|
|
||||||
@@ -380,6 +388,11 @@ on the server and run with Docker Compose.
|
|||||||
- `TZ` — default `Europe/Helsinki`.
|
- `TZ` — default `Europe/Helsinki`.
|
||||||
- The registry hostname exists only in `.env`, which is gitignored, because
|
- The registry hostname exists only in `.env`, which is gitignored, because
|
||||||
§11 leaves open the possibility of publishing this repository.
|
§11 leaves open the possibility of publishing this repository.
|
||||||
|
- **Routing**: Traefik on an external `traefik` network, matching on
|
||||||
|
`FOODSTER_HOST` and terminating TLS. The container publishes no ports —
|
||||||
|
doing so would put an unencrypted copy of the app on the host, bypassing
|
||||||
|
the proxy. The hostname lives in `.env` rather than `compose.yaml`, so no
|
||||||
|
infrastructure detail is committed.
|
||||||
- **Health**: `GET /healthz` returns the build version and is exempt from
|
- **Health**: `GET /healthz` returns the build version and is exempt from
|
||||||
auth. There is no Docker `HEALTHCHECK` directive, because a `scratch` image
|
auth. There is no Docker `HEALTHCHECK` directive, because a `scratch` image
|
||||||
has no shell to run one and `restart: unless-stopped` already covers a dead
|
has no shell to run one and `restart: unless-stopped` already covers a dead
|
||||||
|
|||||||
@@ -184,9 +184,23 @@ that is. Get them from `id -u` and `id -g`.
|
|||||||
## Security
|
## Security
|
||||||
|
|
||||||
Access is a single shared password over HTTP Basic — no accounts, no
|
Access is a single shared password over HTTP Basic — no accounts, no
|
||||||
sessions. Credentials are compared in constant time, but Basic auth sends
|
sessions. Credentials are compared in constant time over SHA-256 digests, so
|
||||||
them in cleartext, so this belongs on a private LAN. Put TLS in front of it
|
neither the password nor its length leaks through timing.
|
||||||
before exposing it anywhere else.
|
|
||||||
|
The app is served on a public hostname behind Traefik, which terminates TLS,
|
||||||
|
so the credentials are encrypted in transit. That leaves the password as the
|
||||||
|
only thing between the internet and the app, so wrong guesses are rate
|
||||||
|
limited per client address: five in a burst, then one per ten seconds,
|
||||||
|
answered with `429`. Requests carrying no `Authorization` header are not
|
||||||
|
charged — that is the handshake every browser session begins with, and
|
||||||
|
counting it would lock the household out for simply opening the app.
|
||||||
|
|
||||||
|
`X-Forwarded-For` is trusted only when the connection came from a private
|
||||||
|
address, meaning it arrived through the proxy. A client connecting directly
|
||||||
|
could otherwise forge a new address per attempt and skip the limiter.
|
||||||
|
|
||||||
|
**None of this replaces a strong `FOODSTER_PASSWORD`.** Rate limiting removes
|
||||||
|
brute force as a practical route; it does not make a guessable password safe.
|
||||||
|
|
||||||
## Mockups
|
## Mockups
|
||||||
|
|
||||||
|
|||||||
+23
-9
@@ -42,11 +42,6 @@ const (
|
|||||||
// companions — lives in one directory, so a deployment mounts a single
|
// companions — lives in one directory, so a deployment mounts a single
|
||||||
// path and a backup copies a single directory.
|
// path and a backup copies a single directory.
|
||||||
defaultDB = "./data/foodster.db"
|
defaultDB = "./data/foodster.db"
|
||||||
|
|
||||||
// failDelay throttles password guessing.
|
|
||||||
// ponytail: a fixed sleep is enough for a LAN-only app; swap in
|
|
||||||
// golang.org/x/time/rate keyed by IP if this is ever exposed.
|
|
||||||
failDelay = 500 * time.Millisecond
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -173,21 +168,40 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
|
|||||||
// accounts, so the username is ignored (PRD §9).
|
// accounts, so the username is ignored (PRD §9).
|
||||||
func auth(password string, next http.Handler) http.Handler {
|
func auth(password string, next http.Handler) http.Handler {
|
||||||
want := sha256.Sum256([]byte(password))
|
want := sha256.Sum256([]byte(password))
|
||||||
|
guesses := newThrottle()
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, given, ok := r.BasicAuth()
|
_, given, ok := r.BasicAuth()
|
||||||
|
|
||||||
|
// A request with no Authorization header is the normal browser
|
||||||
|
// handshake, not a guess: every session opens with one. Challenge it
|
||||||
|
// without spending the address's allowance.
|
||||||
|
if !ok {
|
||||||
|
challenge(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Hashing first keeps the comparison a fixed length, so neither the
|
// Hashing first keeps the comparison a fixed length, so neither the
|
||||||
// password nor its length leaks through timing.
|
// password nor its length leaks through timing.
|
||||||
got := sha256.Sum256([]byte(given))
|
got := sha256.Sum256([]byte(given))
|
||||||
if !ok || subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
|
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
|
||||||
time.Sleep(failDelay)
|
if !guesses.allow(clientIP(r)) {
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="Foodster", charset="UTF-8"`)
|
http.Error(w, "Liikaa yrityksiä.", http.StatusTooManyRequests)
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
challenge(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func challenge(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="Foodster", charset="UTF-8"`)
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
// today is the current calendar day in the configured location. Every date in
|
// today is the current calendar day in the configured location. Every date in
|
||||||
// this app goes through here rather than time.Local, which would be UTC
|
// this app goes through here rather than time.Local, which would be UTC
|
||||||
// whenever TZ is unset and quietly shift evening entries to the day before.
|
// whenever TZ is unset and quietly shift evening entries to the day before.
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The app is reachable from the internet, so a shared password needs more
|
||||||
|
// than a sleep in front of it. These allow a family fumbling the password a
|
||||||
|
// handful of quick retries, then roughly six a minute — useless for guessing,
|
||||||
|
// unnoticeable to anyone who knows it.
|
||||||
|
//
|
||||||
|
// This buys time; it is not the defence. A strong password is.
|
||||||
|
const (
|
||||||
|
guessBurst = 5
|
||||||
|
guessInterval = 10 * time.Second
|
||||||
|
|
||||||
|
// Bounds on the per-IP table, so a spray across many addresses cannot
|
||||||
|
// grow it without limit.
|
||||||
|
throttleMaxEntries = 4096
|
||||||
|
throttleIdle = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type visitor struct {
|
||||||
|
limiter *rate.Limiter
|
||||||
|
seen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// throttle rate-limits failed password attempts per client address.
|
||||||
|
//
|
||||||
|
// ponytail: one mutex over one map. At household traffic this will never be
|
||||||
|
// contended; shard it if that ever stops being true.
|
||||||
|
type throttle struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
visitors map[string]*visitor
|
||||||
|
}
|
||||||
|
|
||||||
|
func newThrottle() *throttle {
|
||||||
|
return &throttle{visitors: make(map[string]*visitor)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow reports whether another wrong guess from this address is permitted.
|
||||||
|
func (t *throttle) allow(ip string) bool {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if len(t.visitors) >= throttleMaxEntries {
|
||||||
|
t.pruneLocked(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := t.visitors[ip]
|
||||||
|
if v == nil {
|
||||||
|
v = &visitor{limiter: rate.NewLimiter(rate.Every(guessInterval), guessBurst)}
|
||||||
|
t.visitors[ip] = v
|
||||||
|
}
|
||||||
|
v.seen = now
|
||||||
|
|
||||||
|
return v.limiter.Allow()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *throttle) pruneLocked(now time.Time) {
|
||||||
|
for ip, v := range t.visitors {
|
||||||
|
if now.Sub(v.seen) > throttleIdle {
|
||||||
|
delete(t.visitors, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Still full of live entries: a spray is in progress. Drop the lot rather
|
||||||
|
// than grow without bound. Everyone gets a fresh allowance, which is the
|
||||||
|
// safe direction to fail — the password is still required.
|
||||||
|
if len(t.visitors) >= throttleMaxEntries {
|
||||||
|
clear(t.visitors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIP resolves the address to rate-limit against.
|
||||||
|
//
|
||||||
|
// X-Forwarded-For is only believed when the connection itself came from a
|
||||||
|
// private address, meaning it arrived through the reverse proxy on the
|
||||||
|
// container network. A client connecting directly could otherwise forge a
|
||||||
|
// fresh address on every attempt and walk straight past the limiter.
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
host = r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if ip == nil || !(ip.IsPrivate() || ip.IsLoopback()) {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
forwarded := r.Header.Get("X-Forwarded-For")
|
||||||
|
if forwarded == "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
// The nearest proxy appends the address it saw, so the last entry is the
|
||||||
|
// trustworthy one; anything before it was supplied by the client.
|
||||||
|
parts := strings.Split(forwarded, ",")
|
||||||
|
last := strings.TrimSpace(parts[len(parts)-1])
|
||||||
|
if net.ParseIP(last) == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestThrottleBlocksRepeatedGuesses(t *testing.T) {
|
||||||
|
th := newThrottle()
|
||||||
|
|
||||||
|
for i := 0; i < guessBurst; i++ {
|
||||||
|
if !th.allow("198.51.100.7") {
|
||||||
|
t.Fatalf("guess %d refused inside the burst", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if th.allow("198.51.100.7") {
|
||||||
|
t.Error("guess allowed past the burst")
|
||||||
|
}
|
||||||
|
// A different address has its own allowance.
|
||||||
|
if !th.allow("198.51.100.8") {
|
||||||
|
t.Error("a second address was blocked by the first one's guesses")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPIgnoresForwardedHeaderFromDirectClients(t *testing.T) {
|
||||||
|
// Connecting straight from the internet: X-Forwarded-For is attacker
|
||||||
|
// input, so a forged value must not create a fresh rate-limit bucket.
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
r.RemoteAddr = "203.0.113.9:44321"
|
||||||
|
r.Header.Set("X-Forwarded-For", "1.2.3.4")
|
||||||
|
|
||||||
|
if got := clientIP(r); got != "203.0.113.9" {
|
||||||
|
t.Errorf("clientIP = %q, want the real peer 203.0.113.9", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPTakesLastForwardedEntryBehindProxy(t *testing.T) {
|
||||||
|
// Arriving through Traefik on the container network. The proxy appends
|
||||||
|
// the address it saw, so the last entry is the trustworthy one and the
|
||||||
|
// forged entry in front of it must be ignored.
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
r.RemoteAddr = "172.18.0.4:53000"
|
||||||
|
r.Header.Set("X-Forwarded-For", "1.2.3.4, 198.51.100.22")
|
||||||
|
|
||||||
|
if got := clientIP(r); got != "198.51.100.22" {
|
||||||
|
t.Errorf("clientIP = %q, want 198.51.100.22", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPFallsBackWhenNoForwardedHeader(t *testing.T) {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
r.RemoteAddr = "172.18.0.4:53000"
|
||||||
|
|
||||||
|
if got := clientIP(r); got != "172.18.0.4" {
|
||||||
|
t.Errorf("clientIP = %q, want 172.18.0.4", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRateLimitsWrongPasswords(t *testing.T) {
|
||||||
|
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTeapot)
|
||||||
|
}))
|
||||||
|
|
||||||
|
send := func(pass string) int {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
r.RemoteAddr = "203.0.113.5:40000"
|
||||||
|
r.SetBasicAuth("", pass)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < guessBurst; i++ {
|
||||||
|
if code := send("wrong"); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("guess %d returned %d, want 401", i+1, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if code := send("wrong"); code != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("guess past the burst returned %d, want 429", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthDoesNotSpendAllowanceOnTheBrowserHandshake(t *testing.T) {
|
||||||
|
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTeapot)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Every session opens with a credential-less request. Charging those
|
||||||
|
// would lock a family out by simply opening the app a few times.
|
||||||
|
for i := 0; i < guessBurst*4; i++ {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
r.RemoteAddr = "203.0.113.6:40000"
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("handshake %d returned %d, want 401", i+1, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The correct password still works afterwards.
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
r.RemoteAddr = "203.0.113.6:40000"
|
||||||
|
r.SetBasicAuth("", "hunter2")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusTeapot {
|
||||||
|
t.Errorf("correct password returned %d, want the wrapped handler", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-8
@@ -2,16 +2,33 @@ services:
|
|||||||
app:
|
app:
|
||||||
image: ${FOODSTER_REPO:?set FOODSTER_REPO in .env}:${FOODSTER_TAG:-latest}
|
image: ${FOODSTER_REPO:?set FOODSTER_REPO in .env}:${FOODSTER_TAG:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# A bind mount rather than a named volume: the database sits in ./data on
|
|
||||||
# the host, where it can be listed, copied and backed up without going
|
# The database is a bind mount, not a named volume: it sits in ./data on
|
||||||
# through the container engine. The image runs as UID 65534, so the
|
# the host where it can be listed, copied and opened with any sqlite
|
||||||
# container has to be told which host user owns that directory.
|
# client. The image runs as UID 65534, so the container has to be told
|
||||||
|
# which host user owns that directory.
|
||||||
user: "${FOODSTER_UID:-1000}:${FOODSTER_GID:-1000}"
|
user: "${FOODSTER_UID:-1000}:${FOODSTER_GID:-1000}"
|
||||||
ports:
|
volumes:
|
||||||
- "${FOODSTER_PORT:-8080}:8080"
|
- ./data:/data
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
FOODSTER_PASSWORD: ${FOODSTER_PASSWORD:?set FOODSTER_PASSWORD in .env}
|
FOODSTER_PASSWORD: ${FOODSTER_PASSWORD:?set FOODSTER_PASSWORD in .env}
|
||||||
FOODSTER_DB: /data/foodster.db
|
FOODSTER_DB: /data/foodster.db
|
||||||
TZ: ${TZ:-Europe/Helsinki}
|
TZ: ${TZ:-Europe/Helsinki}
|
||||||
volumes:
|
|
||||||
- ./data:/data
|
# No published ports: Traefik reaches the container over the shared
|
||||||
|
# network. Publishing 8080 as well would put an unencrypted copy of the
|
||||||
|
# app on the host, bypassing TLS.
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.foodster.entrypoints=websecure
|
||||||
|
- traefik.http.routers.foodster.rule=Host(`${FOODSTER_HOST:?set FOODSTER_HOST in .env}`)
|
||||||
|
- traefik.http.routers.foodster.tls=true
|
||||||
|
- traefik.http.services.foodster.loadbalancer.server.port=8080
|
||||||
|
- traefik.docker.network=traefik
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ tool github.com/a-h/templ/cmd/templ
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/a-h/templ v0.3.1020
|
github.com/a-h/templ v0.3.1020
|
||||||
|
golang.org/x/time v0.15.0
|
||||||
modernc.org/sqlite v1.58.0
|
modernc.org/sqlite v1.58.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
Reference in New Issue
Block a user