Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
813e82ef5c | ||
|
|
eb95bd0a03 |
+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
|
||||||
|
|||||||
@@ -181,12 +181,36 @@ That directory must exist and be owned by the user compose runs as — `make up`
|
|||||||
creates it, and `FOODSTER_UID`/`FOODSTER_GID` in `.env` tell the container who
|
creates it, and `FOODSTER_UID`/`FOODSTER_GID` in `.env` tell the container who
|
||||||
that is. Get them from `id -u` and `id -g`.
|
that is. Get them from `id -u` and `id -g`.
|
||||||
|
|
||||||
|
If the app exits with `cannot open /data/foodster.db ... unable to open
|
||||||
|
database file (14)`, the ownership does not match. Docker creates a missing
|
||||||
|
bind-mount directory as root, and the container is not root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls -ldn data # whose is it?
|
||||||
|
sudo chown -R 1000:1000 data # match FOODSTER_UID / FOODSTER_GID
|
||||||
|
docker compose restart
|
||||||
|
```
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
|||||||
+34
-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() {
|
||||||
@@ -133,6 +128,17 @@ func openDB(path string) (*sql.DB, error) {
|
|||||||
// sidesteps SQLITE_BUSY entirely. Raise it if reads ever contend.
|
// sidesteps SQLITE_BUSY entirely. Raise it if reads ever contend.
|
||||||
db.SetMaxOpenConns(1)
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
// sql.Open is lazy, so without this the first failure surfaces from
|
||||||
|
// whatever query ran first and says nothing useful. The usual cause is a
|
||||||
|
// bind-mounted directory owned by a different user than the container
|
||||||
|
// runs as, so name the path and the uid.
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"cannot open %s as uid %d gid %d: %w (is that directory writable by this user?)",
|
||||||
|
path, os.Getuid(), os.Getgid(), err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := migrate(db); err != nil {
|
if err := migrate(db); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -173,21 +179,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