Skip to content

Commit b14b0a4

Browse files
explodedclaude
andcommitted
Standardise project against deployment standards
- Security headers: remove deprecated X-XSS-Protection, add HSTS in prod - HTTP server: WriteTimeout 5s→10s, IdleTimeout 120s→60s per standard - Workflow: checkout@v6, setup-go@v6, go-version-file: go.mod - server-setup.sh: add app dir, .env template, systemd service with hardening - .gitignore: add *.db, *.db-wal, *.db-shm - .gitattributes: add *.sql (lf) and *.bat (crlf) - Tests: verify HSTS in prod, absence of X-XSS-Protection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f923e5f commit b14b0a4

File tree

7 files changed

+184
-25
lines changed

7 files changed

+184
-25
lines changed

.gitattributes

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ scripts/* text eol=lf
1818
*.yaml text eol=lf
1919
*.json text eol=lf
2020
*.md text eol=lf
21+
*.sql text eol=lf
2122
*.mod text eol=lf
2223
*.sum text eol=lf
24+
*.bat text eol=crlf
2325

2426
# True binaries — no line-ending conversion
2527
*.jpg binary

.github/workflows/deploy.yml

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,12 @@ jobs:
1111
name: Test
1212
runs-on: ubuntu-latest
1313
steps:
14-
- uses: actions/checkout@v4
14+
- uses: actions/checkout@v6
1515

1616
- name: Set up Go
17-
uses: actions/setup-go@v5
17+
uses: actions/setup-go@v6
1818
with:
19-
go-version: '1.21'
20-
cache: false
19+
go-version-file: go.mod
2120

2221
- name: Download dependencies
2322
run: go mod download
@@ -32,13 +31,12 @@ jobs:
3231
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
3332

3433
steps:
35-
- uses: actions/checkout@v4
34+
- uses: actions/checkout@v6
3635

3736
- name: Set up Go
38-
uses: actions/setup-go@v5
37+
uses: actions/setup-go@v6
3938
with:
40-
go-version: '1.21'
41-
cache: false
39+
go-version-file: go.mod
4240

4341
- name: Download dependencies
4442
run: go mod download

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ Thumbs.db
2121
*.swp
2222
*.swo
2323

24+
# Database files
25+
*.db
26+
*.db-wal
27+
*.db-shm
28+
2429
# Build directories
2530
moon-env/
2631
dist/

CLAUDE.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Build & Run
6+
7+
```bash
8+
# Windows local dev (build + run, loads .env automatically):
9+
build.bat
10+
11+
# Quick restart (skip rebuild):
12+
run.bat
13+
14+
# Build only:
15+
go build -o moon.exe
16+
17+
# Linux production binary (required for Linode deploy — must be static):
18+
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o moon .
19+
```
20+
21+
Requires a `.env` file with `GOOGLE_MAPS_API_KEY`. See `.env.example`.
22+
23+
## Tests
24+
25+
```bash
26+
go test -v ./...
27+
```
28+
29+
Tests use `httptest` against handlers directly. Templates must be present in the working directory (they're parsed via `template.ParseGlob("*.html")` at init).
30+
31+
## Deployment
32+
33+
Push to `master` triggers GitHub Actions: runs tests, builds static Linux binary, SCPs to Linode, runs `deploy-moon` script. No manual deploy steps needed.
34+
35+
The deploy script (`scripts/deploy-moon`) stops the service, replaces the binary (must `rm -f` first to avoid "text file busy"), copies web assets, restarts. It has self-update logic.
36+
37+
## Architecture
38+
39+
Single-file Go server (`moon.go`) with no framework. All handlers, middleware, and `main()` live in one file.
40+
41+
**Request flow:** `main()``makeHTTPServer()``http.ServeMux` with middleware chain: `requestLogger(securityHeaders(mux))`. Static assets get an additional `cacheStaticAssets` wrapper.
42+
43+
**Routes:**
44+
- `/` — home page with Google Maps, geolocation, moon rise/set display
45+
- `/about` — static about page
46+
- `/calendar` — full-month table of sun/moon rise/set times; server-rendered with `year`/`month`/`lat`/`lon`/`zon` query params
47+
- `/gettimes` — JSON API returning `riseset.RiseSet` for given `lon`/`lat`/`zon`
48+
- `/static/` — CSS, JS, background image
49+
50+
**Templates:** Go `html/template` files at project root (`index.html`, `about.html`, `calendar.html`). Parsed once at init, with fallback to on-demand parsing. Google Maps API key is injected server-side into `index.html` (not exposed via JS endpoint).
51+
52+
**Key dependency:** `github.com/exploded/riseset` — calculates rise/set times. Pinned to a pseudo-version commit hash in `go.mod`. Update with `go get github.com/exploded/riseset@<commit>`.
53+
54+
**Frontend:** Vanilla JS + jQuery 3.7.1. `static/script.js` handles Google Maps (AdvancedMarkerElement), geolocation, timezone auto-detection, and AJAX calls to `/gettimes`. The `updateCalLink()` function keeps the calendar link in sync with current lat/lon/zon.
55+
56+
**riseset API caveat:** Always check `AlwaysAbove`/`AlwaysBelow` before displaying `Rise`/`Set` values. Rise/Set are empty strings when the moon never rises or never sets.

moon.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,14 @@ func requestLogger(next http.Handler) http.Handler {
6666
}
6767

6868
// Add security headers to all responses
69-
func securityHeaders(next http.Handler) http.Handler {
69+
func securityHeaders(isProd bool, next http.Handler) http.Handler {
7070
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
7171
w.Header().Set("X-Content-Type-Options", "nosniff")
7272
w.Header().Set("X-Frame-Options", "DENY")
73-
w.Header().Set("X-XSS-Protection", "1; mode=block")
7473
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
74+
if isProd {
75+
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
76+
}
7577
// CSP allows Google Maps with WebAssembly and all required resources
7678
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://maps.googleapis.com https://code.jquery.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://*.googleapis.com https://*.gstatic.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' data: https://maps.googleapis.com https://*.gstatic.com; worker-src blob:")
7779
next.ServeHTTP(w, r)
@@ -87,18 +89,18 @@ func cacheStaticAssets(next http.Handler) http.Handler {
8789
})
8890
}
8991

90-
func makeServerFromMux(mux *http.ServeMux) *http.Server {
92+
func makeServerFromMux(mux *http.ServeMux, isProd bool) *http.Server {
9193
// set timeouts so that a slow or malicious client doesn't
9294
// hold resources forever
9395
return &http.Server{
9496
ReadTimeout: 5 * time.Second,
95-
WriteTimeout: 5 * time.Second,
96-
IdleTimeout: 120 * time.Second,
97-
Handler: requestLogger(securityHeaders(mux)),
97+
WriteTimeout: 10 * time.Second,
98+
IdleTimeout: 60 * time.Second,
99+
Handler: requestLogger(securityHeaders(isProd, mux)),
98100
}
99101
}
100102

101-
func makeHTTPServer() *http.Server {
103+
func makeHTTPServer(isProd bool) *http.Server {
102104
mux := &http.ServeMux{}
103105
mux.HandleFunc("/", handleIndex)
104106
mux.HandleFunc("/about", about)
@@ -111,7 +113,7 @@ func makeHTTPServer() *http.Server {
111113
mux.Handle("/static/", http.StripPrefix("/static/", cacheStaticAssets(fileServer)))
112114
// 404 handler for all other routes
113115
mux.HandleFunc("/404", handle404)
114-
return makeServerFromMux(mux)
116+
return makeServerFromMux(mux, isProd)
115117
}
116118

117119
func main() {
@@ -153,7 +155,7 @@ func main() {
153155
log.Printf("Production: %v", flgProduction)
154156
log.Printf("HTTP Port: %s", httpPort)
155157

156-
httpSrv := makeHTTPServer()
158+
httpSrv := makeHTTPServer(flgProduction)
157159
httpSrv.Addr = httpPort
158160

159161
// Start server in goroutine

moon_test.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func TestGettimesMissingParams(t *testing.T) {
6363

6464
// Test security headers middleware
6565
func TestSecurityHeaders(t *testing.T) {
66-
handler := securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66+
handler := securityHeaders(false, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6767
w.WriteHeader(http.StatusOK)
6868
}))
6969

@@ -81,7 +81,6 @@ func TestSecurityHeaders(t *testing.T) {
8181
}{
8282
{"X-Content-Type-Options", "nosniff"},
8383
{"X-Frame-Options", "DENY"},
84-
{"X-XSS-Protection", "1; mode=block"},
8584
{"Referrer-Policy", "strict-origin-when-cross-origin"},
8685
}
8786

@@ -90,6 +89,36 @@ func TestSecurityHeaders(t *testing.T) {
9089
t.Errorf("security header %s = %v, want %v", tt.header, got, tt.want)
9190
}
9291
}
92+
93+
// X-XSS-Protection should NOT be set (deprecated)
94+
if got := rr.Header().Get("X-XSS-Protection"); got != "" {
95+
t.Errorf("X-XSS-Protection should not be set, got %v", got)
96+
}
97+
98+
// HSTS should NOT be set in non-prod mode
99+
if got := rr.Header().Get("Strict-Transport-Security"); got != "" {
100+
t.Errorf("HSTS should not be set in non-prod mode, got %v", got)
101+
}
102+
}
103+
104+
// Test HSTS header is set in prod mode
105+
func TestSecurityHeadersProd(t *testing.T) {
106+
handler := securityHeaders(true, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107+
w.WriteHeader(http.StatusOK)
108+
}))
109+
110+
req, err := http.NewRequest("GET", "/", nil)
111+
if err != nil {
112+
t.Fatal(err)
113+
}
114+
115+
rr := httptest.NewRecorder()
116+
handler.ServeHTTP(rr, req)
117+
118+
want := "max-age=63072000; includeSubDomains"
119+
if got := rr.Header().Get("Strict-Transport-Security"); got != want {
120+
t.Errorf("HSTS header = %v, want %v", got, want)
121+
}
93122
}
94123

95124
// Test cache headers middleware

scripts/server-setup.sh

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,77 @@ chmod 600 "$KEY_DIR/authorized_keys"
5353
chown -R "$DEPLOY_USER:$DEPLOY_USER" "$KEY_DIR"
5454

5555
# ---------------------------------------------------------------
56-
# 3. Create the server-side deploy script (runs as root via sudo)
57-
#
58-
# Reads User/Group directly from the installed service file so
59-
# this script never needs to hardcode a username.
56+
# 3. Create application directory
57+
# ---------------------------------------------------------------
58+
APP_DIR="/var/www/moon"
59+
if [ -d "$APP_DIR" ]; then
60+
echo "[ok] Application directory $APP_DIR already exists"
61+
else
62+
mkdir -p "$APP_DIR"
63+
chown www-data:www-data "$APP_DIR"
64+
echo "[ok] Created application directory $APP_DIR"
65+
fi
66+
67+
# ---------------------------------------------------------------
68+
# 4. Create .env template
69+
# ---------------------------------------------------------------
70+
ENV_FILE="$APP_DIR/.env"
71+
if [ -f "$ENV_FILE" ]; then
72+
echo "[ok] .env file already exists at $ENV_FILE (not overwriting)"
73+
else
74+
cat > "$ENV_FILE" << 'ENV_TEMPLATE'
75+
# Google Maps API Configuration
76+
GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here
77+
78+
# Production flag
79+
PROD=True
80+
81+
# Port the server listens on (default: 8484)
82+
PORT=8484
83+
84+
# Monitor portal log shipping (optional)
85+
MONITOR_URL=
86+
MONITOR_API_KEY=
87+
ENV_TEMPLATE
88+
chown www-data:www-data "$ENV_FILE"
89+
chmod 600 "$ENV_FILE"
90+
echo "[ok] Created .env template at $ENV_FILE (edit with real values)"
91+
fi
92+
93+
# ---------------------------------------------------------------
94+
# 5. Create systemd service
95+
# ---------------------------------------------------------------
96+
SERVICE_FILE="/etc/systemd/system/moon.service"
97+
cat > "$SERVICE_FILE" << 'SERVICE'
98+
[Unit]
99+
Description=Moon Rise and Set Times
100+
After=network.target
101+
102+
[Service]
103+
Type=simple
104+
User=www-data
105+
Group=www-data
106+
WorkingDirectory=/var/www/moon
107+
EnvironmentFile=/var/www/moon/.env
108+
ExecStart=/var/www/moon/moon
109+
Restart=on-failure
110+
RestartSec=5
111+
112+
# Security hardening
113+
NoNewPrivileges=true
114+
PrivateTmp=true
115+
ProtectSystem=full
116+
ProtectHome=true
117+
118+
[Install]
119+
WantedBy=multi-user.target
120+
SERVICE
121+
122+
systemctl daemon-reload
123+
echo "[ok] Created systemd service at $SERVICE_FILE"
124+
125+
# ---------------------------------------------------------------
126+
# 6. Create the server-side deploy script (runs as root via sudo)
60127
# ---------------------------------------------------------------
61128
cat > /usr/local/bin/deploy-moon << 'DEPLOY_SCRIPT'
62129
#!/bin/bash
@@ -125,7 +192,7 @@ chmod +x /usr/local/bin/deploy-moon
125192
echo "[ok] Created /usr/local/bin/deploy-moon"
126193

127194
# ---------------------------------------------------------------
128-
# 4. Configure sudoers — only allow the one deploy script
195+
# 7. Configure sudoers — only allow the one deploy script
129196
# ---------------------------------------------------------------
130197
SUDOERS_FILE="/etc/sudoers.d/moon-deploy"
131198

@@ -141,7 +208,7 @@ visudo -c -f "$SUDOERS_FILE"
141208
echo "[ok] sudoers entry created at $SUDOERS_FILE"
142209

143210
# ---------------------------------------------------------------
144-
# 5. Print next steps
211+
# 8. Print next steps
145212
# ---------------------------------------------------------------
146213
echo ""
147214
echo "=== Setup complete. Add these secrets to your GitHub repository: ==="

0 commit comments

Comments
 (0)