Files
felhom.eu/documentation/audits/permanent-gate-2026-10-01/familygate/main.go
T

226 lines
6.2 KiB
Go

// familygate — THROWAWAY spike (permanent-gate-2026-10-01). A traefik forwardAuth answerer with a family list: each
// member signs in with their OWN name and password; the session lasts 30 days, survives a restart, and logout revokes
// it. Wrong passwords are counted per VISITOR, read by controller v0.286's rule (clientaddr.go): believed only from
// traefik; the rightmost X-Forwarded-For entry is the hop traefik saw; the tunnel's fixed address → CF-Connecting-IP.
// Never shipped: the build, if the operator says go, lives in the controller.
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
cookieName = "felhom_family"
life = 30 * 24 * time.Hour
tunnelAddr = "172.16.253.2"
maxWrong = 5
window = time.Minute
dataDir = "/data"
)
type sess struct {
User string `json:"u"`
Host string `json:"h"`
Exp time.Time `json:"e"`
}
var (
mu sync.Mutex
users map[string]string // name -> bcrypt
sessions = map[string]sess{}
wrong = map[string][]time.Time{}
traefik []string
trAt time.Time
)
func save() {
b, _ := json.Marshal(sessions)
_ = os.WriteFile(dataDir+"/sessions.json", b, 0o600)
}
func isTraefik(ip string) bool {
if time.Since(trAt) > 30*time.Second {
traefik, _ = net.LookupHost("traefik")
trAt = time.Now()
}
for _, a := range traefik {
if a == ip {
return true
}
}
return false
}
func visitor(r *http.Request) string {
peer, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
peer = r.RemoteAddr
}
if !isTraefik(peer) {
return peer
}
var hops []string
for _, v := range r.Header.Values("X-Forwarded-For") {
for _, h := range strings.Split(v, ",") {
if h = strings.TrimSpace(h); h != "" {
hops = append(hops, h)
}
}
}
if len(hops) == 0 || net.ParseIP(hops[len(hops)-1]) == nil {
return peer
}
hop := hops[len(hops)-1]
if hop == tunnelAddr {
if cf := strings.TrimSpace(r.Header.Get("CF-Connecting-IP")); net.ParseIP(cf) != nil {
return cf
}
}
return hop
}
func valid(r *http.Request, host string) (string, bool) {
c, err := r.Cookie(cookieName)
if err != nil {
return "", false
}
mu.Lock()
defer mu.Unlock()
s, ok := sessions[c.Value]
if !ok || time.Now().After(s.Exp) || s.Host != host {
return "", false
}
return s.User, true
}
// /auth — traefik forwardAuth.
func auth(w http.ResponseWriter, r *http.Request) {
host := strings.ToLower(r.Header.Get("X-Forwarded-Host"))
if i := strings.LastIndex(host, ":"); i != -1 {
host = host[:i]
}
uri := r.Header.Get("X-Forwarded-Uri")
if u, ok := valid(r, host); ok {
w.Header().Set("X-Family-User", u)
w.WriteHeader(200)
return
}
m := r.Header.Get("X-Forwarded-Method")
if (m == "" || m == "GET") && strings.Contains(r.Header.Get("Accept"), "text/html") {
http.Redirect(w, r, "https://"+host+"/__family/login?"+url.Values{"rd": {uri}}.Encode(), http.StatusFound)
return
}
log.Printf("refused %s %s%s from %s", m, host, uri, visitor(r))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
fmt.Fprint(w, `{"error":"sign in with your family login"}`)
}
func page(w http.ResponseWriter, msg, rd string, code int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
fmt.Fprintf(w, `<!doctype html><title>Belépés</title><p>%s</p><form method=post action="/__family/login">
<input name=user placeholder="Neved"><input name=password type=password placeholder="Jelszavad">
<input type=hidden name=rd value="%s"><button>Belépés</button></form>`, html.EscapeString(msg), html.EscapeString(rd))
}
func login(w http.ResponseWriter, r *http.Request) {
rd := r.FormValue("rd")
if !strings.HasPrefix(rd, "/") || strings.HasPrefix(rd, "//") {
rd = "/"
}
if r.Method != http.MethodPost {
page(w, "", rd, 200)
return
}
v := visitor(r)
now := time.Now()
mu.Lock()
var keep []time.Time
for _, t := range wrong[v] {
if now.Sub(t) < window {
keep = append(keep, t)
}
}
wrong[v] = keep
if len(keep) >= maxWrong {
mu.Unlock()
log.Printf("locked: visitor %s (%d wrong in %s)", v, len(keep), window)
page(w, "Túl sok hibás próbálkozás erről a címről. Próbáld újra egy perc múlva.", rd, 429)
return
}
hash, known := users[r.FormValue("user")]
mu.Unlock()
if !known || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil {
mu.Lock()
wrong[v] = append(wrong[v], now)
mu.Unlock()
log.Printf("wrong password from visitor %s", v)
page(w, "Hibás név vagy jelszó.", rd, 401)
return
}
b := make([]byte, 32)
_, _ = rand.Read(b)
id := hex.EncodeToString(b)
host := strings.ToLower(strings.Split(r.Host, ":")[0])
mu.Lock()
delete(wrong, v)
sessions[id] = sess{User: r.FormValue("user"), Host: host, Exp: now.Add(life)}
save()
mu.Unlock()
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: id, Path: "/", MaxAge: int(life.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
log.Printf("signed in: %s on %s from visitor %s", r.FormValue("user"), host, v)
http.Redirect(w, r, rd, http.StatusFound)
}
func logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(cookieName); err == nil {
mu.Lock()
delete(sessions, c.Value)
save()
mu.Unlock()
}
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true})
http.Redirect(w, r, "/__family/login", http.StatusFound)
}
func main() {
if len(os.Args) == 3 && os.Args[1] == "hash" { // familygate hash <password> — for the users file
h, _ := bcrypt.GenerateFromPassword([]byte(os.Args[2]), bcrypt.DefaultCost)
fmt.Println(string(h))
return
}
raw, err := os.ReadFile(dataDir + "/users.json")
if err != nil {
log.Fatal(err)
}
if err := json.Unmarshal(raw, &users); err != nil {
log.Fatal(err)
}
if b, err := os.ReadFile(dataDir + "/sessions.json"); err == nil {
_ = json.Unmarshal(b, &sessions)
}
_ = hmac.New(sha256.New, nil)
http.HandleFunc("/auth", auth)
http.HandleFunc("/__family/login", login)
http.HandleFunc("/__family/logout", logout)
log.Printf("familygate: %d members, %d sessions", len(users), len(sessions))
log.Fatal(http.ListenAndServe(":8080", nil))
}