Compare commits
3 Commits
3a860914d5
...
ce36939d31
| Author | SHA1 | Date | |
|---|---|---|---|
| ce36939d31 | |||
| 8a10981ecc | |||
| 34ce8a8fc3 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,6 +24,9 @@ vendor/
|
||||
# AI workspace
|
||||
.ki-workspace/
|
||||
|
||||
# Python (tools/subset-icons.py)
|
||||
.venv/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -133,6 +133,7 @@ func main() {
|
||||
|
||||
// Build middleware chain (innermost → outermost)
|
||||
var h http.Handler = mux
|
||||
h = security.GzipMiddleware()(h)
|
||||
h = security.CSRFMiddleware(cfg.SecureCookies)(h)
|
||||
h = security.SizeLimitMiddleware(cfg.MaxRequestSize)(h)
|
||||
h = security.RateLimitMiddleware(cfg.RateLimitLogin)(h)
|
||||
|
||||
@@ -36,7 +36,7 @@ internal/
|
||||
logging/ ← Structured logging with levels
|
||||
mail/ ← SMTP email service (notifications, invitations)
|
||||
models/ ← Data models (User, SSHKey, Server, etc.)
|
||||
security/ ← CSRF, security headers, rate limiting, proxy detection
|
||||
security/ ← CSRF, security headers, rate limiting, gzip compression, proxy detection
|
||||
servers/ ← Server and server group management, access assignments
|
||||
sshutil/ ← SSH key generation (RSA, Ed25519, Ed448)
|
||||
updater/ ← Background update checker (Gitea releases API)
|
||||
@@ -59,7 +59,7 @@ web/
|
||||
8. **Configure security** subsystem (trusted proxy parsing)
|
||||
9. **Set up HTTP routes** and load templates
|
||||
10. **Start session cleanup** goroutine (removes expired sessions every minute)
|
||||
11. **Apply middleware chain**: request logger → security headers → rate limiting → size limiting → CSRF
|
||||
11. **Apply middleware chain**: request logger → security headers → rate limiting → size limiting → CSRF → gzip compression
|
||||
12. **Start cron scheduler** (checks for pending jobs every 30 seconds)
|
||||
13. **Start key enforcement worker** (if enabled in Admin Settings)
|
||||
14. **Start HTTP server**
|
||||
@@ -95,6 +95,7 @@ Client → [Nginx/Caddy] → Keywarden HTTP Server
|
||||
├── Rate Limit Middleware (login endpoints)
|
||||
├── Size Limit Middleware
|
||||
├── CSRF Middleware (double-submit cookie)
|
||||
├── Gzip Compression Middleware
|
||||
│
|
||||
├── Public Routes (/login, /invite/*)
|
||||
├── Auth Routes (requireAuth → all authenticated users)
|
||||
|
||||
@@ -82,6 +82,7 @@ keywarden/
|
||||
│ │ ├── csrf.go # CSRF double-submit cookie middleware
|
||||
│ │ ├── headers.go # Security headers middleware (CSP, X-Frame-Options, etc.)
|
||||
│ │ ├── proxy.go # Trusted proxy IP extraction
|
||||
│ │ ├── gzip.go # Gzip compression middleware
|
||||
│ │ ├── ratelimit.go # IP-based rate limiting middleware
|
||||
│ │ └── sizelimit.go # Request body size limit middleware
|
||||
│ ├── servers/servers.go # Server and group management, access assignments
|
||||
@@ -91,6 +92,9 @@ keywarden/
|
||||
│ ├── embed.go # Go embed directives
|
||||
│ ├── static/ # CSS, JS, fonts (Tabler UI)
|
||||
│ └── templates/ # HTML templates
|
||||
├── tools/
|
||||
│ ├── subset-icons.py # Tabler Icons font/CSS subset tool
|
||||
│ └── tabler-icons-full/ # Full Tabler Icons source files
|
||||
├── docs/ # Documentation
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
|
||||
@@ -141,6 +141,12 @@ Login endpoints (`POST /login`, `POST /login/mfa`) are rate-limited per IP addre
|
||||
|
||||
A background goroutine cleans up expired rate limit entries every 5 minutes.
|
||||
|
||||
## Gzip Compression
|
||||
|
||||
HTTP responses are compressed using gzip for clients that send `Accept-Encoding: gzip`. Only compressible content types are compressed (HTML, CSS, JS, JSON, SVG). Already-compressed formats (woff2, images) are passed through unchanged.
|
||||
|
||||
The middleware uses a `sync.Pool` of gzip writers for efficient memory reuse.
|
||||
|
||||
## Request Size Limiting
|
||||
|
||||
Request bodies are limited to prevent denial-of-service via large uploads.
|
||||
|
||||
112
internal/security/gzip.go
Normal file
112
internal/security/gzip.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// compressibleTypes lists MIME types that benefit from gzip compression.
|
||||
// Binary formats like woff2, images, etc. are already compressed.
|
||||
var compressibleTypes = map[string]bool{
|
||||
"text/html": true,
|
||||
"text/css": true,
|
||||
"text/plain": true,
|
||||
"text/javascript": true,
|
||||
"application/javascript": true,
|
||||
"application/json": true,
|
||||
"application/xml": true,
|
||||
"image/svg+xml": true,
|
||||
}
|
||||
|
||||
var gzipWriterPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
w, _ := gzip.NewWriterLevel(io.Discard, gzip.BestSpeed)
|
||||
return w
|
||||
},
|
||||
}
|
||||
|
||||
// GzipMiddleware compresses HTTP responses for clients that accept gzip.
|
||||
// Only compressible content types (text, CSS, JS, JSON, SVG) are compressed;
|
||||
// already-compressed formats (woff2, images) are passed through unchanged.
|
||||
func GzipMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
gz := gzipWriterPool.Get().(*gzip.Writer)
|
||||
gz.Reset(w)
|
||||
|
||||
grw := &gzipResponseWriter{
|
||||
ResponseWriter: w,
|
||||
gz: gz,
|
||||
}
|
||||
|
||||
next.ServeHTTP(grw, r)
|
||||
|
||||
if grw.compressed {
|
||||
gz.Close()
|
||||
}
|
||||
gzipWriterPool.Put(gz)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type gzipResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
gz *gzip.Writer
|
||||
compressed bool
|
||||
decided bool
|
||||
}
|
||||
|
||||
func (w *gzipResponseWriter) WriteHeader(code int) {
|
||||
if w.decided {
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
return
|
||||
}
|
||||
w.decided = true
|
||||
|
||||
// Only compress successful full responses (not 304, 206, redirects, errors)
|
||||
if code == http.StatusOK {
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if idx := strings.Index(ct, ";"); idx >= 0 {
|
||||
ct = strings.TrimSpace(ct[:idx])
|
||||
}
|
||||
if compressibleTypes[ct] {
|
||||
w.compressed = true
|
||||
w.Header().Del("Content-Length")
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.Header().Add("Vary", "Accept-Encoding")
|
||||
}
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
if !w.decided {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
if w.compressed {
|
||||
return w.gz.Write(b)
|
||||
}
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Flush implements http.Flusher for streaming responses.
|
||||
func (w *gzipResponseWriter) Flush() {
|
||||
if w.compressed {
|
||||
w.gz.Flush()
|
||||
}
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
181
tools/subset-icons.py
Normal file
181
tools/subset-icons.py
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tabler Icons Subset Tool for Keywarden
|
||||
=======================================
|
||||
Scans all HTML templates for used ti-* icon classes, then generates:
|
||||
1. A subsetted woff2 font with only the needed glyphs
|
||||
2. A minimal CSS file with only the matching icon rules
|
||||
|
||||
Prerequisites (one-time):
|
||||
pip install fonttools brotli
|
||||
|
||||
Usage:
|
||||
python tools/subset-icons.py
|
||||
|
||||
Source files (full Tabler Icons 3.6.0) are stored in tools/tabler-icons-full/.
|
||||
Output goes directly to web/static/css/ and web/static/css/fonts/.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# ── Paths (relative to project root) ──
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
|
||||
|
||||
TEMPLATE_DIR = os.path.join(PROJECT_ROOT, "web", "templates")
|
||||
FULL_CSS = os.path.join(SCRIPT_DIR, "tabler-icons-full", "tabler-icons.min.css")
|
||||
FULL_FONT = os.path.join(SCRIPT_DIR, "tabler-icons-full", "tabler-icons.woff2")
|
||||
|
||||
OUT_CSS = os.path.join(PROJECT_ROOT, "web", "static", "css", "tabler-icons.min.css")
|
||||
OUT_FONT = os.path.join(PROJECT_ROOT, "web", "static", "css", "fonts", "tabler-icons.woff2")
|
||||
|
||||
|
||||
def find_used_icons():
|
||||
"""Scan all .html templates for ti-* class names."""
|
||||
icons = set()
|
||||
pattern = re.compile(r"ti-[a-z][a-z0-9-]+")
|
||||
for root, _, files in os.walk(TEMPLATE_DIR):
|
||||
for f in files:
|
||||
if not f.endswith(".html"):
|
||||
continue
|
||||
with open(os.path.join(root, f), encoding="utf-8") as fh:
|
||||
for match in pattern.finditer(fh.read()):
|
||||
icons.add(match.group(0))
|
||||
# ti-spin is a CSS animation class, not an icon glyph
|
||||
icons.discard("ti-spin")
|
||||
return sorted(icons)
|
||||
|
||||
|
||||
def extract_codepoints(css_text, icons):
|
||||
"""Extract Unicode codepoints from the full CSS for each icon."""
|
||||
codepoints = []
|
||||
missing = []
|
||||
for icon in icons:
|
||||
pat = re.escape("." + icon) + r':before\{content:"\\([0-9a-f]+)"\}'
|
||||
m = re.search(pat, css_text)
|
||||
if m:
|
||||
codepoints.append(m.group(1))
|
||||
else:
|
||||
missing.append(icon)
|
||||
return codepoints, missing
|
||||
|
||||
|
||||
def build_subset_css(css_text, icons):
|
||||
"""Build a minimal CSS containing only the @font-face, .ti base rule,
|
||||
and the individual icon rules for the used icons."""
|
||||
# Extract header comment + @font-face + .ti base rule
|
||||
m = re.match(r'(/\*[\s\S]*?\*/)(@font-face\{[^}]+\})(\.ti\{[^}]+\})', css_text)
|
||||
if not m:
|
||||
print("ERROR: Could not parse base CSS rules from full source")
|
||||
sys.exit(1)
|
||||
|
||||
# Patch @font-face: keep only woff2 and add font-display:swap
|
||||
font_face = m.group(2)
|
||||
# Remove woff and truetype sources, keep only woff2
|
||||
font_face = re.sub(
|
||||
r',url\("[^"]*\.woff\?[^"]*"\)\s*format\("woff"\)', '', font_face
|
||||
)
|
||||
font_face = re.sub(
|
||||
r',url\("[^"]*\.ttf[^"]*"\)\s*format\("truetype"\)', '', font_face
|
||||
)
|
||||
# Add font-display:swap if not present
|
||||
if "font-display" not in font_face:
|
||||
font_face = font_face.replace(
|
||||
"font-weight:400;",
|
||||
"font-weight:400;font-display:swap;"
|
||||
)
|
||||
|
||||
header = m.group(1) + font_face + m.group(3)
|
||||
|
||||
# Extract individual icon rules
|
||||
rules = []
|
||||
for icon in icons:
|
||||
pat = re.escape("." + icon) + r':before\{content:"\\[0-9a-f]+"\}'
|
||||
match = re.search(pat, css_text)
|
||||
if match:
|
||||
rules.append(match.group(0))
|
||||
|
||||
# Keep .ti-spin animation if present
|
||||
result = header + "".join(rules)
|
||||
spin_kf = re.search(r'@keyframes\s+spin\{[^}]+\{[^}]+\}\}', css_text)
|
||||
spin_cls = re.search(r'\.ti-spin\{[^}]+\}', css_text)
|
||||
if spin_kf:
|
||||
result += spin_kf.group(0)
|
||||
if spin_cls:
|
||||
result += spin_cls.group(0)
|
||||
|
||||
return result, len(rules)
|
||||
|
||||
|
||||
def subset_font(codepoints):
|
||||
"""Run pyftsubset to create a woff2 with only the needed glyphs."""
|
||||
unicodes = ",".join(f"U+{cp}" for cp in codepoints)
|
||||
cmd = [
|
||||
sys.executable, "-m", "fontTools.subset",
|
||||
FULL_FONT,
|
||||
f"--output-file={OUT_FONT}",
|
||||
"--flavor=woff2",
|
||||
"--no-layout-closure",
|
||||
"--drop-tables+=GSUB,GPOS,GDEF",
|
||||
f"--unicodes={unicodes}",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print("ERROR: pyftsubset failed:")
|
||||
print(result.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
# Verify source files exist
|
||||
for path in (FULL_CSS, FULL_FONT):
|
||||
if not os.path.exists(path):
|
||||
print(f"ERROR: Source file missing: {path}")
|
||||
print("These should be in tools/tabler-icons-full/")
|
||||
sys.exit(1)
|
||||
|
||||
# Try importing fonttools
|
||||
try:
|
||||
import fontTools # noqa: F401
|
||||
except ImportError:
|
||||
print("ERROR: fonttools not installed. Run: pip install fonttools brotli")
|
||||
sys.exit(1)
|
||||
|
||||
print("Scanning templates for icon usage...")
|
||||
icons = find_used_icons()
|
||||
print(f" Found {len(icons)} unique icons")
|
||||
|
||||
print("Reading full CSS source...")
|
||||
with open(FULL_CSS, encoding="utf-8") as f:
|
||||
full_css = f.read()
|
||||
|
||||
print("Extracting Unicode codepoints...")
|
||||
codepoints, missing = extract_codepoints(full_css, icons)
|
||||
if missing:
|
||||
print(f" WARNING: No codepoint found for: {', '.join(missing)}")
|
||||
print(f" Mapped {len(codepoints)} codepoints")
|
||||
|
||||
print("Subsetting font...")
|
||||
subset_font(codepoints)
|
||||
orig_size = os.path.getsize(FULL_FONT)
|
||||
new_size = os.path.getsize(OUT_FONT)
|
||||
print(f" {orig_size//1024} KB -> {new_size//1024} KB ({100-round(new_size/orig_size*100,1)}% smaller)")
|
||||
|
||||
print("Building subset CSS...")
|
||||
css_out, rule_count = build_subset_css(full_css, icons)
|
||||
with open(OUT_CSS, "w", encoding="utf-8") as f:
|
||||
f.write(css_out)
|
||||
orig_css_size = os.path.getsize(FULL_CSS)
|
||||
new_css_size = os.path.getsize(OUT_CSS)
|
||||
print(f" {rule_count} icon rules, {orig_css_size//1024} KB -> {new_css_size//1024} KB")
|
||||
|
||||
print("\nDone! Subsetted files written to:")
|
||||
print(f" Font: {os.path.relpath(OUT_FONT, PROJECT_ROOT)}")
|
||||
print(f" CSS: {os.path.relpath(OUT_CSS, PROJECT_ROOT)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
4
tools/tabler-icons-full/tabler-icons.min.css
vendored
Normal file
4
tools/tabler-icons-full/tabler-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
tools/tabler-icons-full/tabler-icons.woff2
Normal file
BIN
tools/tabler-icons-full/tabler-icons.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2
web/static/css/tabler-icons.min.css
vendored
2
web/static/css/tabler-icons.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -8,7 +8,7 @@
|
||||
<title>{{.Title}} - {{appName}}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<!-- Preload icon font to prevent re-decode lag on tab restore -->
|
||||
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2?v3.6.0" as="font" type="font/woff2" crossorigin>
|
||||
<!-- Resolve theme BEFORE loading CSS to prevent FOUC (flash of unstyled content) -->
|
||||
<script>
|
||||
(function() {
|
||||
@@ -160,7 +160,7 @@
|
||||
[data-bs-theme="dark"] .page-body { background: #0F1829; }
|
||||
html[data-bs-theme="dark"],
|
||||
html[data-bs-theme="dark"] body { background-color: #0F1829 !important; }
|
||||
.page-body { content-visibility: auto; contain-intrinsic-size: auto 500px; }
|
||||
/* content-visibility removed – causes Firefox to freeze/re-layout on tab hover */
|
||||
|
||||
/* ── Narrower dashboard stat cards ── */
|
||||
.stat-card-narrow { max-width: 220px; }
|
||||
|
||||
Reference in New Issue
Block a user