c5b17dbc8d
Self-contained Go application, no database server. Pages, users and configuration live as JSON/HTML files under wiki_data/, which ships with placeholder start and imprint pages only — everything institution-specific is configured at runtime via /settings. Features: bilingual pages (DE/EN) up to four levels deep with sidebar navigation and full-text search, a WYSIWYG editor with per-page uploads, per-page access control (external/internal) and drafts, version history with restore, local or LDAP login with locally managed roles, bcrypt password hashing, CSRF protection and login rate limiting, and TLS with hot certificate reload. The editor (Quill) and the webfonts are vendored under static/vendor/, so the application makes no third-party requests at runtime.
309 lines
8.8 KiB
Go
309 lines
8.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/flosch/pongo2/v6"
|
|
)
|
|
|
|
var (
|
|
// Global servers for dynamic restarts
|
|
httpServer *http.Server
|
|
httpsServer *http.Server
|
|
serverMutex sync.Mutex
|
|
mainRouter http.Handler
|
|
)
|
|
|
|
func initPaths() {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
log.Fatalf("Error getting working directory: %v", err)
|
|
}
|
|
dataDir = filepath.Join(cwd, "wiki_data")
|
|
pagesDir = filepath.Join(dataDir, "pages")
|
|
imagesDir = filepath.Join(dataDir, "images")
|
|
filesDir = filepath.Join(dataDir, "files")
|
|
|
|
_ = os.MkdirAll(pagesDir, 0755)
|
|
|
|
templateSet = pongo2.NewSet("html", pongo2.MustNewLocalFileSystemLoader("templates"))
|
|
}
|
|
|
|
func decryptPrivateKey(keyPath string, passphrase string) ([]byte, error) {
|
|
if passphrase == "" {
|
|
return os.ReadFile(keyPath)
|
|
}
|
|
|
|
// We use openssl to decrypt the key file to keep the binary portable without external Go libraries.
|
|
cmd := exec.Command("openssl", "pkey", "-in", keyPath, "-passin", "pass:"+passphrase)
|
|
var out bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%v (stderr: %s)", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
// fileStamp identifies a version of a file on disk without reading it.
|
|
type fileStamp struct {
|
|
modTime time.Time
|
|
size int64
|
|
}
|
|
|
|
func stampOf(path string) (fileStamp, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return fileStamp{}, err
|
|
}
|
|
return fileStamp{modTime: info.ModTime(), size: info.Size()}, nil
|
|
}
|
|
|
|
// certReloader keeps the served TLS certificate in sync with the files on disk,
|
|
// so a renewed certificate takes effect without restarting the application —
|
|
// just drop the new files in place under the same paths. It is wired into
|
|
// tls.Config.GetCertificate, which Go calls once per handshake; the files are
|
|
// therefore only re-read (and, with a passphrase set, only decrypted through
|
|
// the openssl subprocess in decryptPrivateKey) when their modification time or
|
|
// size actually changed.
|
|
type certReloader struct {
|
|
certPath string
|
|
keyPath string
|
|
passphrase string
|
|
|
|
mu sync.Mutex
|
|
cert *tls.Certificate
|
|
certStamp fileStamp
|
|
keyStamp fileStamp
|
|
}
|
|
|
|
// newCertReloader loads the key pair once, so that startServers() can tell
|
|
// whether HTTPS can be enabled at all.
|
|
func newCertReloader(certPath, keyPath, passphrase string) (*certReloader, error) {
|
|
cr := &certReloader{certPath: certPath, keyPath: keyPath, passphrase: passphrase}
|
|
|
|
cr.mu.Lock()
|
|
defer cr.mu.Unlock()
|
|
|
|
certStamp, keyStamp, err := cr.stamps()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := cr.reload(certStamp, keyStamp); err != nil {
|
|
return nil, err
|
|
}
|
|
return cr, nil
|
|
}
|
|
|
|
// stamps reads both file stamps. Callers must hold cr.mu.
|
|
func (cr *certReloader) stamps() (fileStamp, fileStamp, error) {
|
|
certStamp, err := stampOf(cr.certPath)
|
|
if err != nil {
|
|
return fileStamp{}, fileStamp{}, fmt.Errorf("cannot stat cert file %s: %v", cr.certPath, err)
|
|
}
|
|
keyStamp, err := stampOf(cr.keyPath)
|
|
if err != nil {
|
|
return fileStamp{}, fileStamp{}, fmt.Errorf("cannot stat key file %s: %v", cr.keyPath, err)
|
|
}
|
|
return certStamp, keyStamp, nil
|
|
}
|
|
|
|
// reload re-reads the key pair from disk and replaces the cached certificate.
|
|
// Callers must hold cr.mu and must have stat'ed the files *before* reading them,
|
|
// so that a file changing mid-reload is picked up again on the next handshake
|
|
// rather than being missed. The stamps are recorded even when the reload fails,
|
|
// so a half-written or broken file is not retried on every single handshake —
|
|
// the next attempt happens once the files change again.
|
|
func (cr *certReloader) reload(certStamp, keyStamp fileStamp) error {
|
|
cr.certStamp = certStamp
|
|
cr.keyStamp = keyStamp
|
|
|
|
certBytes, err := os.ReadFile(cr.certPath)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot read cert file %s: %v", cr.certPath, err)
|
|
}
|
|
keyBytes, err := decryptPrivateKey(cr.keyPath, cr.passphrase)
|
|
if err != nil {
|
|
return fmt.Errorf("decryption failed for %s: %v", cr.keyPath, err)
|
|
}
|
|
cert, err := tls.X509KeyPair(certBytes, keyBytes)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse key pair: %v", err)
|
|
}
|
|
|
|
cr.cert = &cert
|
|
return nil
|
|
}
|
|
|
|
// GetCertificate is the tls.Config hook. A failed reload is logged and the
|
|
// previously loaded certificate keeps being served, so replacing the files with
|
|
// something unusable degrades to "old certificate" instead of "no HTTPS".
|
|
func (cr *certReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
cr.mu.Lock()
|
|
defer cr.mu.Unlock()
|
|
|
|
certStamp, keyStamp, err := cr.stamps()
|
|
if err != nil {
|
|
log.Printf("TLS certificate reload skipped, continuing with the previously loaded certificate: %v", err)
|
|
} else if certStamp != cr.certStamp || keyStamp != cr.keyStamp {
|
|
if err := cr.reload(certStamp, keyStamp); err != nil {
|
|
log.Printf("TLS certificate reload failed, continuing with the previously loaded certificate: %v", err)
|
|
} else {
|
|
log.Printf("TLS certificate reloaded from %s", cr.certPath)
|
|
}
|
|
}
|
|
|
|
if cr.cert == nil {
|
|
return nil, fmt.Errorf("no TLS certificate available")
|
|
}
|
|
return cr.cert, nil
|
|
}
|
|
|
|
func startServers() {
|
|
serverMutex.Lock()
|
|
defer serverMutex.Unlock()
|
|
|
|
config := loadConfig()
|
|
hasTLS := false
|
|
var tlsCert tls.Certificate
|
|
var certSource *certReloader
|
|
|
|
if strings.TrimSpace(config.TLSCertPath) != "" && strings.TrimSpace(config.TLSKeyPath) != "" {
|
|
reloader, err := newCertReloader(config.TLSCertPath, config.TLSKeyPath, config.TLSKeyPassphrase)
|
|
if err != nil {
|
|
log.Printf("TLS/HTTPS Load Certificate Error: %v", err)
|
|
} else {
|
|
certSource = reloader
|
|
hasTLS = true
|
|
}
|
|
} else if strings.TrimSpace(config.TLSCert) != "" && strings.TrimSpace(config.TLSKey) != "" {
|
|
cert, err := tls.X509KeyPair([]byte(config.TLSCert), []byte(config.TLSKey))
|
|
if err != nil {
|
|
log.Printf("TLS/HTTPS Certificate Error (legacy fallback): %v", err)
|
|
} else {
|
|
tlsCert = cert
|
|
hasTLS = true
|
|
}
|
|
}
|
|
|
|
httpPort := strings.TrimSpace(config.HTTPPort)
|
|
if httpPort == "" {
|
|
httpPort = "80"
|
|
}
|
|
|
|
httpsPort := strings.TrimSpace(config.HTTPSPort)
|
|
if httpsPort == "" {
|
|
httpsPort = "443"
|
|
}
|
|
|
|
if hasTLS {
|
|
// Start HTTPS server on port httpsPort in a goroutine. With cert/key
|
|
// paths configured the certificate is served through certReloader, so
|
|
// swapping the files on disk is picked up automatically; the deprecated
|
|
// inline TLSCert/TLSKey fields have nothing to watch and stay static.
|
|
tlsConfig := &tls.Config{}
|
|
if certSource != nil {
|
|
tlsConfig.GetCertificate = certSource.GetCertificate
|
|
} else {
|
|
tlsConfig.Certificates = []tls.Certificate{tlsCert}
|
|
}
|
|
httpsServer = &http.Server{
|
|
Addr: "0.0.0.0:" + httpsPort,
|
|
Handler: mainRouter,
|
|
TLSConfig: tlsConfig,
|
|
}
|
|
|
|
go func() {
|
|
log.Printf("Starting Go HTTPS web server on https://localhost:%s", httpsPort)
|
|
if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
|
|
log.Printf("HTTPS Server failed: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Start HTTP redirection server on httpPort (redirect to HTTPS)
|
|
httpRedirectHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
host := req.Host
|
|
if strings.Contains(host, ":") {
|
|
hostPart, _, err := net.SplitHostPort(host)
|
|
if err == nil {
|
|
host = hostPart
|
|
}
|
|
}
|
|
if httpsPort != "443" {
|
|
host = host + ":" + httpsPort
|
|
}
|
|
target := "https://" + host + req.URL.RequestURI()
|
|
http.Redirect(w, req, target, http.StatusFound)
|
|
})
|
|
|
|
httpServer = &http.Server{
|
|
Addr: "0.0.0.0:" + httpPort,
|
|
Handler: httpRedirectHandler,
|
|
}
|
|
|
|
go func() {
|
|
log.Printf("Starting Go HTTP redirection server on http://localhost:%s (redirecting to HTTPS)", httpPort)
|
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Printf("HTTP Server failed: %v", err)
|
|
}
|
|
}()
|
|
} else {
|
|
httpServer = &http.Server{
|
|
Addr: "0.0.0.0:" + httpPort,
|
|
Handler: mainRouter,
|
|
}
|
|
|
|
go func() {
|
|
log.Printf("Starting Go web server on http://localhost:%s", httpPort)
|
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Printf("HTTP Server failed: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
func restartServers() {
|
|
go func() {
|
|
// Wait a moment for response to finish sending to client
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
serverMutex.Lock()
|
|
log.Println("Restarting web servers to apply new settings...")
|
|
|
|
// Shutdown HTTP server
|
|
if httpServer != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
_ = httpServer.Shutdown(ctx)
|
|
cancel()
|
|
httpServer = nil
|
|
}
|
|
|
|
// Shutdown HTTPS server
|
|
if httpsServer != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
_ = httpsServer.Shutdown(ctx)
|
|
cancel()
|
|
httpsServer = nil
|
|
}
|
|
serverMutex.Unlock()
|
|
|
|
// Start servers again
|
|
startServers()
|
|
}()
|
|
}
|