Skip to content

Commit e69136d

Browse files
authored
Merge pull request #2 from Fokir/feat/nginx-http2-tls12
feat: HTTP/2 + optional TLS 1.2-only to mitigate new RKN blocking
2 parents 0fd9535 + 7fb6a5c commit e69136d

15 files changed

Lines changed: 449 additions & 11 deletions

File tree

cmd/server/main.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/sokol/system-control/internal/network_nodes"
2121
"github.com/sokol/system-control/internal/nftables"
2222
"github.com/sokol/system-control/internal/nginx"
23+
"github.com/sokol/system-control/internal/settings"
2324
"github.com/sokol/system-control/internal/traffic"
2425
"github.com/sokol/system-control/internal/wireguard"
2526
)
@@ -56,6 +57,7 @@ func main() {
5657
natRepo := nftables.NewRepository(db)
5758
nodesRepo := network_nodes.NewRepository(db)
5859
nginxRepo := nginx.NewRepository(db)
60+
settingsRepo := settings.NewRepository(db)
5961
auditRepo := audit.NewRepository(db)
6062
trafficRepo := traffic.NewRepository(db)
6163
wgRepo := wireguard.NewRepository(db)
@@ -66,7 +68,9 @@ func main() {
6668
natSvc := nftables.NewService(natRepo, natEngine)
6769
nodesMonitor := network_nodes.NewMonitor(nodesRepo, 30*time.Second)
6870
nodesSvc := network_nodes.NewService(nodesRepo, nodesMonitor)
69-
nginxSvc := nginx.NewService(nginxRepo, cfg.NginxSitesDir, cfg.Port)
71+
settingsSvc := settings.NewService(settingsRepo)
72+
nginxSvc := nginx.NewService(nginxRepo, cfg.NginxSitesDir, cfg.Port, settingsSvc)
73+
settingsSvc.SetChangeListener(nginxSvc)
7074
auditSvc := audit.NewService(auditRepo)
7175
trafficSvc := traffic.NewService(trafficRepo, nodesRepo)
7276
wgEngine := wireguard.NewEngine()
@@ -114,6 +118,7 @@ func main() {
114118
nodesHandler := network_nodes.NewHandler(nodesSvc)
115119
nginxHandler := nginx.NewHandler(nginxSvc)
116120
nginxAuthHandler := nginx.NewAuthHandler(nginxRepo)
121+
settingsHandler := settings.NewHandler(settingsSvc)
117122
auditHandler := audit.NewHandler(auditSvc)
118123
trafficHandler := traffic.NewHandler(trafficSvc)
119124
wgHandler := wireguard.NewHandler(wgSvc)
@@ -140,6 +145,7 @@ func main() {
140145
r.Mount("/nat-groups", natHandler.Routes())
141146
r.Mount("/network-nodes", nodesHandler.Routes())
142147
r.Mount("/nginx-domains", nginxHandler.Routes())
148+
r.Mount("/settings", settingsHandler.Routes())
143149
r.Mount("/audit-logs", auditHandler.Routes())
144150
r.Mount("/traffic", trafficHandler.Routes())
145151
r.Mount("/wireguard", wgHandler.Routes())
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Global key/value settings store
2+
CREATE TABLE IF NOT EXISTS settings (
3+
key TEXT PRIMARY KEY,
4+
value TEXT NOT NULL,
5+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
6+
);

internal/nginx/service.go

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,20 @@ import (
1515
"github.com/sokol/system-control/internal/pkg/validate"
1616
)
1717

18+
// SettingsProvider supplies global settings the nginx config depends on.
19+
type SettingsProvider interface {
20+
ForceTLS12() bool
21+
}
22+
1823
type Service struct {
1924
repo *Repository
2025
sitesDir string
2126
backendPort int
27+
settings SettingsProvider
2228
}
2329

24-
func NewService(repo *Repository, sitesDir string, backendPort int) *Service {
25-
return &Service{repo: repo, sitesDir: sitesDir, backendPort: backendPort}
30+
func NewService(repo *Repository, sitesDir string, backendPort int, settings SettingsProvider) *Service {
31+
return &Service{repo: repo, sitesDir: sitesDir, backendPort: backendPort, settings: settings}
2632
}
2733

2834
func (s *Service) GetAll() ([]Domain, error) {
@@ -384,18 +390,23 @@ func (s *Service) configPath(domain string) string {
384390
return filepath.Join(s.sitesDir, fmt.Sprintf("sc_%s.conf", domain))
385391
}
386392

387-
func (s *Service) writeAndReload(domain *Domain) error {
393+
// writeConfig renders and writes the nginx config for a domain and validates
394+
// it with `nginx -t`, rolling back on test failure (restoring the previous
395+
// config if one existed, otherwise removing the brand-new file). It does NOT
396+
// reload nginx — callers reload once they have written all configs.
397+
func (s *Service) writeConfig(domain *Domain) error {
388398
if runtime.GOOS != "linux" {
389399
slog.Warn("nginx config write skipped on non-Linux", "domain", domain.Domain)
390400
return nil
391401
}
392402

393-
content, err := renderConfig(domain, s.backendPort)
403+
content, err := renderConfig(domain, s.backendPort, s.settings.ForceTLS12())
394404
if err != nil {
395405
return fmt.Errorf("render config: %w", err)
396406
}
397407

398408
path := s.configPath(domain.Domain)
409+
prev, prevErr := os.ReadFile(path) // prevErr != nil => no prior config existed
399410
if err := os.WriteFile(path, content, 0644); err != nil {
400411
return fmt.Errorf("write config file: %w", err)
401412
}
@@ -404,15 +415,74 @@ func (s *Service) writeAndReload(domain *Domain) error {
404415
cmd := exec.Command("nginx", "-t")
405416
output, err := cmd.CombinedOutput()
406417
if err != nil {
407-
// Rollback: remove bad config
408-
os.Remove(path)
418+
// Roll back: restore the previous working config if one existed,
419+
// otherwise remove the brand-new (bad) file. Prevents a failed
420+
// rebuild from taking an already-online domain offline.
421+
if prevErr == nil {
422+
if wErr := os.WriteFile(path, prev, 0644); wErr != nil {
423+
slog.Error("failed to restore previous nginx config after failed test", "error", wErr, "domain", domain.Domain)
424+
}
425+
} else {
426+
os.Remove(path)
427+
}
409428
return fmt.Errorf("nginx config test failed: %s: %w", string(output), err)
410429
}
411430

431+
return nil
432+
}
433+
434+
func (s *Service) writeAndReload(domain *Domain) error {
435+
if err := s.writeConfig(domain); err != nil {
436+
return err
437+
}
412438
s.reloadNginx()
413439
return nil
414440
}
415441

442+
// rebuildTargets returns the domains whose nginx config depends on global
443+
// SSL settings: enabled domains that have SSL. Non-SSL domains are skipped
444+
// because ForceTLS12 / http2 only affect the SSL server block.
445+
func rebuildTargets(domains []Domain) []*Domain {
446+
var targets []*Domain
447+
for i := range domains {
448+
d := &domains[i]
449+
if d.Enabled && d.SSLEnabled {
450+
targets = append(targets, d)
451+
}
452+
}
453+
return targets
454+
}
455+
456+
// RebuildAll re-renders and reloads nginx configs for all SSL-enabled domains.
457+
// Used when a global setting (e.g. force TLS 1.2) changes. It reloads nginx once
458+
// after writing all configs rather than per domain.
459+
func (s *Service) RebuildAll() error {
460+
if runtime.GOOS != "linux" {
461+
return nil
462+
}
463+
domains, err := s.repo.GetAll()
464+
if err != nil {
465+
return fmt.Errorf("get domains: %w", err)
466+
}
467+
written := 0
468+
for _, d := range rebuildTargets(domains) {
469+
if err := s.writeConfig(d); err != nil {
470+
slog.Error("failed to rebuild nginx config", "error", err, "domain", d.Domain)
471+
continue
472+
}
473+
written++
474+
}
475+
if written > 0 {
476+
s.reloadNginx()
477+
}
478+
return nil
479+
}
480+
481+
// OnSettingsChanged implements settings.ChangeListener: rebuild all configs.
482+
func (s *Service) OnSettingsChanged() error {
483+
return s.RebuildAll()
484+
}
485+
416486
func (s *Service) removeConfig(domain string) {
417487
os.Remove(s.configPath(domain))
418488
}

internal/nginx/service_test.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import (
77
"github.com/sokol/system-control/internal/database"
88
)
99

10+
type stubSettings struct{ forceTLS12 bool }
11+
12+
func (s stubSettings) ForceTLS12() bool { return s.forceTLS12 }
13+
1014
func setupNginxTest(t *testing.T) *Service {
1115
t.Helper()
1216
dbPath := filepath.Join(t.TempDir(), "test.db")
@@ -18,7 +22,7 @@ func setupNginxTest(t *testing.T) *Service {
1822

1923
sitesDir := t.TempDir()
2024
repo := NewRepository(db)
21-
return NewService(repo, sitesDir, 8080)
25+
return NewService(repo, sitesDir, 8080, stubSettings{})
2226
}
2327

2428
// mustCreate creates a domain, ignoring nginx config errors (expected in test env without root).
@@ -31,6 +35,22 @@ func mustCreate(t *testing.T, svc *Service, req CreateDomainRequest) *Domain {
3135
return domain
3236
}
3337

38+
func TestRebuildTargets(t *testing.T) {
39+
domains := []Domain{
40+
{Domain: "enabled-ssl.example.com", Enabled: true, SSLEnabled: true},
41+
{Domain: "enabled-nossl.example.com", Enabled: true, SSLEnabled: false},
42+
{Domain: "disabled-ssl.example.com", Enabled: false, SSLEnabled: true},
43+
}
44+
45+
targets := rebuildTargets(domains)
46+
if len(targets) != 1 {
47+
t.Fatalf("expected 1 target, got %d", len(targets))
48+
}
49+
if targets[0].Domain != "enabled-ssl.example.com" {
50+
t.Errorf("expected 'enabled-ssl.example.com', got %q", targets[0].Domain)
51+
}
52+
}
53+
3454
func TestCreateDomain_Success(t *testing.T) {
3555
svc := setupNginxTest(t)
3656

internal/nginx/template.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,21 @@ server {
7474
}
7575
7676
server {
77-
listen 443 ssl;
77+
listen 443 ssl http2;
7878
server_name {{.Domain}};
7979
8080
ssl_certificate /etc/letsencrypt/live/{{.Domain}}/fullchain.pem;
8181
ssl_certificate_key /etc/letsencrypt/live/{{.Domain}}/privkey.pem;
82+
{{- if .ForceTLS12}}
83+
ssl_protocols TLSv1.2;
84+
ssl_prefer_server_ciphers off;
85+
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
86+
ssl_session_cache shared:SSL:10m;
87+
ssl_session_timeout 1d;
88+
ssl_session_tickets off;
89+
{{- else}}
8290
include /etc/letsencrypt/options-ssl-nginx.conf;
91+
{{- end}}
8392
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
8493
{{template "locations" .}}
8594
}
@@ -106,9 +115,10 @@ type configData struct {
106115
HasAuth bool
107116
BackendPort int
108117
SSLEnabled bool
118+
ForceTLS12 bool
109119
}
110120

111-
func renderConfig(d *Domain, backendPort int) ([]byte, error) {
121+
func renderConfig(d *Domain, backendPort int, forceTLS12 bool) ([]byte, error) {
112122
scheme := d.UpstreamScheme
113123
if scheme == "" {
114124
scheme = "http"
@@ -122,6 +132,7 @@ func renderConfig(d *Domain, backendPort int) ([]byte, error) {
122132
HasAuth: d.AuthEnabled,
123133
BackendPort: backendPort,
124134
SSLEnabled: d.SSLEnabled,
135+
ForceTLS12: forceTLS12,
125136
}
126137
var buf bytes.Buffer
127138
if err := confTmpl.Execute(&buf, data); err != nil {

internal/nginx/template_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package nginx
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func sslDomain() *Domain {
9+
return &Domain{
10+
Domain: "example.com",
11+
UpstreamIP: "10.0.0.1",
12+
UpstreamPort: 8080,
13+
UpstreamScheme: "http",
14+
SSLEnabled: true,
15+
}
16+
}
17+
18+
func TestRenderConfig_HTTP2AlwaysEnabled(t *testing.T) {
19+
out, err := renderConfig(sslDomain(), 8080, false)
20+
if err != nil {
21+
t.Fatalf("renderConfig: %v", err)
22+
}
23+
if !strings.Contains(string(out), "listen 443 ssl http2;") {
24+
t.Errorf("expected 'listen 443 ssl http2;' in SSL config, got:\n%s", out)
25+
}
26+
}
27+
28+
func TestRenderConfig_ForceTLS12(t *testing.T) {
29+
out, err := renderConfig(sslDomain(), 8080, true)
30+
if err != nil {
31+
t.Fatalf("renderConfig: %v", err)
32+
}
33+
s := string(out)
34+
if !strings.Contains(s, "ssl_protocols TLSv1.2;") {
35+
t.Errorf("expected 'ssl_protocols TLSv1.2;' when ForceTLS12, got:\n%s", s)
36+
}
37+
if strings.Contains(s, "options-ssl-nginx.conf") {
38+
t.Errorf("expected certbot include ABSENT when ForceTLS12, got:\n%s", s)
39+
}
40+
}
41+
42+
func TestRenderConfig_DefaultUsesCertbotInclude(t *testing.T) {
43+
out, err := renderConfig(sslDomain(), 8080, false)
44+
if err != nil {
45+
t.Fatalf("renderConfig: %v", err)
46+
}
47+
if !strings.Contains(string(out), "options-ssl-nginx.conf") {
48+
t.Errorf("expected certbot include when not ForceTLS12, got:\n%s", out)
49+
}
50+
}

internal/settings/handler.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package settings
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/go-chi/chi/v5"
7+
"github.com/sokol/system-control/internal/pkg/httputil"
8+
)
9+
10+
type Handler struct {
11+
svc *Service
12+
}
13+
14+
func NewHandler(svc *Service) *Handler {
15+
return &Handler{svc: svc}
16+
}
17+
18+
func (h *Handler) Routes() chi.Router {
19+
r := chi.NewRouter()
20+
r.Get("/", h.get)
21+
r.Put("/", h.update)
22+
return r
23+
}
24+
25+
type settingsDTO struct {
26+
ForceTLS12 bool `json:"forceTls12"`
27+
}
28+
29+
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
30+
httputil.JSON(w, http.StatusOK, settingsDTO{ForceTLS12: h.svc.ForceTLS12()})
31+
}
32+
33+
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
34+
var req settingsDTO
35+
if err := httputil.Decode(r, &req); err != nil {
36+
httputil.Error(w, http.StatusBadRequest, "invalid request body")
37+
return
38+
}
39+
if err := h.svc.SetForceTLS12(req.ForceTLS12); err != nil {
40+
httputil.Error(w, http.StatusInternalServerError, err.Error())
41+
return
42+
}
43+
httputil.JSON(w, http.StatusOK, settingsDTO{ForceTLS12: req.ForceTLS12})
44+
}

internal/settings/repository.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package settings
2+
3+
import "database/sql"
4+
5+
type Repository struct {
6+
db *sql.DB
7+
}
8+
9+
func NewRepository(db *sql.DB) *Repository {
10+
return &Repository{db: db}
11+
}
12+
13+
// Get returns the value for key, or empty string if the key does not exist.
14+
func (r *Repository) Get(key string) (string, error) {
15+
var value string
16+
err := r.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
17+
if err == sql.ErrNoRows {
18+
return "", nil
19+
}
20+
if err != nil {
21+
return "", err
22+
}
23+
return value, nil
24+
}
25+
26+
// Set inserts or updates the value for key.
27+
func (r *Repository) Set(key, value string) error {
28+
_, err := r.db.Exec(
29+
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
30+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
31+
key, value,
32+
)
33+
return err
34+
}

0 commit comments

Comments
 (0)