b50289074d
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
226 lines
6.2 KiB
Go
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))
|
|
}
|