Compare commits

...

3 commits

Author SHA1 Message Date
Felix Niederwanger 888c99e93f
Add testing
Extract webserver functions to allow testing and introduce first unit
tests for the webserver.
2024-02-26 08:01:55 +01:00
Felix Niederwanger 2df56b96c9
Update manpage
Include TLS example in the manpage.
2024-02-26 08:01:46 +01:00
Felix Niederwanger 6887e73b19
Introduce tls support
Introduce TLS support for weblug. TLS can be enabled in the config file.
Multiple certificates are supported.
2024-02-26 08:01:42 +01:00
8 changed files with 298 additions and 28 deletions

4
.gitignore vendored
View file

@ -25,3 +25,7 @@ vendor/
# Go workspace file
go.work
# Possible key and cert files
*.key
*.pem
*.cert

View file

@ -33,3 +33,8 @@ tasks:
vet:
cmds:
- go vet ./...
certs:
cmds:
- openssl genrsa -out weblug.key 2048
- openssl req -new -x509 -sha256 -key weblug.key -subj "/C=XX/ST=None/L=Nirwana/O=Adeptus Mechanicus/OU=Cult Mechanicus/CN=example1.local" -addext "subjectAltName = DNS:example1.local" -out weblug1.pem -days 365
- openssl req -new -x509 -sha256 -key weblug.key -subj "/C=XX/ST=None/L=Nirwana/O=Adeptus Mechanicus/OU=Cult Mechanicus/CN=example1.local" -addext "subjectAltName = DNS:example1.local" -out weblug2.pem -days 365

View file

@ -1,24 +1,42 @@
package main
import (
"crypto/tls"
"fmt"
"io/ioutil"
"gopkg.in/yaml.v2"
)
var cf Config
type Config struct {
Settings ConfigSettings `yaml:"settings"`
Hooks []Hook `yaml:"hooks"`
}
type ConfigSettings struct {
BindAddress string `yaml:"bind"` // Bind address for the webserver
UID int `yaml:"uid"` // Custom user ID or 0, if not being used
GID int `yaml:"gid"` // Custom group ID or 0, if not being used
ReadTimeout int `yaml:"readtimeout"` // Timeout for reading the whole request
WriteTimeout int `yaml:"writetimeout"` // Timeout for writing the whole response
MaxHeaderBytes int `yaml:"maxheadersize"` // Maximum size of the receive body
BindAddress string `yaml:"bind"` // Bind address for the webserver
UID int `yaml:"uid"` // Custom user ID or 0, if not being used
GID int `yaml:"gid"` // Custom group ID or 0, if not being used
ReadTimeout int `yaml:"readtimeout"` // Timeout for reading the whole request
WriteTimeout int `yaml:"writetimeout"` // Timeout for writing the whole response
MaxHeaderBytes int `yaml:"maxheadersize"` // Maximum size of the receive body
TLS TLSSettings `yaml:"tls"`
}
type TLSSettings struct {
Enabled bool `yaml:"enabled"`
MinVersion string `yaml:"minversion"`
MaxVersion string `yaml:"maxversion"`
Keypairs []TLSKeypairs `yaml:"keypairs"`
Keyfile string `yaml:"keyfile"`
Certificates []string `yaml:"certificates"`
}
type TLSKeypairs struct {
Keyfile string `yaml:"keyfile"`
Certificate string `yaml:"certificate"`
}
func (cf *Config) SetDefaults() {
@ -63,3 +81,19 @@ func (cf *Config) LoadYAML(filename string) error {
}
return cf.Check()
}
func ParseTLSVersion(version string) (uint16, error) {
if version == "" {
return tls.VersionTLS12, nil
} else if version == "1.0" {
return tls.VersionTLS10, nil
} else if version == "1.1" {
return tls.VersionTLS11, nil
} else if version == "1.2" {
return tls.VersionTLS12, nil
} else if version == "1.3" {
return tls.VersionTLS13, nil
} else {
return 0, fmt.Errorf("invalid tls version string")
}
}

View file

@ -12,6 +12,7 @@ import (
type Hook struct {
Name string `yaml:"name"` // name of the hook
Route string `yaml:"route"` // http route
Hosts []string `yaml:"hosts"` // allowed remote hosts
Command string `yaml:"command"` // Actual command to execute
Background bool `yaml:"background"` // Run in background
Concurrency int `yaml:"concurrency"` // Number of allowed concurrent runs
@ -80,6 +81,10 @@ func cmdSplit(command string) []string {
// Run executes the given command and return it's return code. It also respects the given concurrency number and will block until resources are free
func (hook *Hook) Run() error {
if hook.Command == "" {
return nil
}
split := cmdSplit(hook.Command)
args := make([]string, 0)
if len(split) > 1 {

View file

@ -4,8 +4,10 @@
package main
import (
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
@ -13,8 +15,6 @@ import (
"time"
)
var cf Config
type Handler func(http.ResponseWriter, *http.Request)
func usage() {
@ -100,36 +100,118 @@ func main() {
}
}
// Create default handlers
http.HandleFunc("/", createDefaultHandler())
http.HandleFunc("/health", createHealthHandler())
http.HandleFunc("/health.json", createHealthHandler())
http.HandleFunc("/robots.txt", createRobotsHandler())
// Register hooks
server := CreateWebserver(cf)
mux := http.NewServeMux()
server.Handler = mux
if err := RegisterHandlers(cf, mux); err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
os.Exit(1)
}
for i, hook := range cf.Hooks {
if hook.Route == "" {
fmt.Fprintf(os.Stderr, "Invalid hook %s: No route defined\n", hook.Name)
}
if hook.Concurrency < 1 {
hook.Concurrency = 1
}
log.Printf("Webhook %d: '%s' [%s] \"%s\"\n", i, hook.Name, hook.Route, hook.Command)
http.HandleFunc(hook.Route, createHandler(hook))
}
awaitTerminationSignal()
log.Printf("Launching webserver on %s", cf.Settings.BindAddress)
var listener net.Listener
var err error
if cf.Settings.TLS.Enabled {
log.Printf("Launching tls webserver on %s", cf.Settings.BindAddress)
listener, err = CreateTLSListener(cf)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
os.Exit(1)
}
} else {
log.Printf("Launching webserver on %s", cf.Settings.BindAddress)
listener, err = CreateListener(cf)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
os.Exit(1)
}
}
server.Serve(listener)
// read guard, should never ever ever be called.
// If we end up here, the only safe thing we can do is terminate the program
panic("unexpected end of main loop")
}
func CreateListener(cf Config) (net.Listener, error) {
return net.Listen("tcp", cf.Settings.BindAddress)
}
func CreateTLSListener(cf Config) (net.Listener, error) {
var err error
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
if cf.Settings.TLS.MinVersion != "" {
tlsConfig.MinVersion, err = ParseTLSVersion(cf.Settings.TLS.MinVersion)
if err != nil {
return nil, fmt.Errorf("invalid tls min version")
}
}
if cf.Settings.TLS.MaxVersion != "" {
tlsConfig.MaxVersion, err = ParseTLSVersion(cf.Settings.TLS.MinVersion)
if err != nil {
return nil, fmt.Errorf("invalid tls max version")
}
}
if tlsConfig.MinVersion == tls.VersionTLS10 || tlsConfig.MinVersion == tls.VersionTLS11 {
fmt.Fprintf(os.Stderr, "warning: using of a deprecated TLS version (< 1.2) is not recommended\n")
}
// Create self-signed certificate, when no keyfile and no certificates are present
if len(cf.Settings.TLS.Keypairs) == 0 {
// TODO
return nil, fmt.Errorf("creating self-signed certificates is not yet supported")
} else {
// Load key/certificates keypairs
tlsConfig.Certificates = make([]tls.Certificate, len(cf.Settings.TLS.Keypairs))
for i, keypair := range cf.Settings.TLS.Keypairs {
tlsConfig.Certificates[i], err = tls.LoadX509KeyPair(keypair.Certificate, keypair.Keyfile)
if err != nil {
return nil, fmt.Errorf("invalid tls keypair '%s,%s' - %s", keypair.Certificate, keypair.Keyfile, err)
}
}
if len(tlsConfig.Certificates) == 1 {
log.Printf("Loaded 1 tls certificate")
} else {
log.Printf("Loaded %d tls certificates", len(tlsConfig.Certificates))
}
}
return tls.Listen("tcp", cf.Settings.BindAddress, tlsConfig)
}
func CreateWebserver(cf Config) *http.Server {
server := &http.Server{
Addr: cf.Settings.BindAddress,
ReadTimeout: time.Duration(cf.Settings.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(cf.Settings.WriteTimeout) * time.Second,
MaxHeaderBytes: cf.Settings.MaxHeaderBytes,
}
err := server.ListenAndServe()
log.Fatal(err)
os.Exit(1)
return server
}
func RegisterHandlers(cf Config, mux *http.ServeMux) error {
mux.HandleFunc("/", createDefaultHandler())
mux.HandleFunc("/health", createHealthHandler())
mux.HandleFunc("/health.json", createHealthHandler())
mux.HandleFunc("/robots.txt", createRobotsHandler())
// Register hooks
for _, hook := range cf.Hooks {
if hook.Route == "" {
return fmt.Errorf("no route defined in hook %s", hook.Name)
}
if hook.Concurrency < 1 {
hook.Concurrency = 1
}
mux.HandleFunc(hook.Route, createHandler(hook))
}
return nil
}
// create a http handler function from the given hook
@ -137,6 +219,23 @@ func createHandler(hook Hook) Handler {
return func(w http.ResponseWriter, r *http.Request) {
log.Printf("GET %s %s", r.RemoteAddr, hook.Name)
// Check if this hook is remote-host limited
if len(hook.Hosts) > 0 {
allowed := false
for _, host := range hook.Hosts {
if r.RemoteAddr == host {
allowed = true
break
}
}
if !allowed {
// Hook doesn't exist for this host.
respondNotFound(w, r)
return
}
}
// Check if adresses are allowed or blocked
allowed, err := hook.IsAddressAllowed(r.RemoteAddr)
if err != nil {
@ -228,8 +327,7 @@ func createDefaultHandler() Handler {
w.WriteHeader(200)
fmt.Fprintf(w, "<!DOCTYPE html><html><head><title>weblug</title></head>\n<body><p><a href=\"https://codeberg.org/grisu48/weblug\">weblug</a> - webhook receiver program</p>\n</body></html>")
} else {
w.WriteHeader(404)
fmt.Fprintf(w, "not found\n")
respondNotFound(w, r)
}
}
}
@ -240,3 +338,9 @@ func createRobotsHandler() Handler {
fmt.Fprintf(w, "User-agent: *\nDisallow: /")
}
}
func respondNotFound(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(404)
_, err := fmt.Fprintf(w, "not found\n")
return err
}

67
cmd/weblug/weblug_test.go Normal file
View file

@ -0,0 +1,67 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"testing"
)
func TestMain(m *testing.M) {
// Run tests
ret := m.Run()
os.Exit(ret)
}
// Test the general webserver functionalities
func TestWebserver(t *testing.T) {
var cf Config
cf.Settings.BindAddress = "127.0.0.1:2088"
cf.Hooks = make([]Hook, 0)
cf.Hooks = append(cf.Hooks, Hook{Route: "/test1", Name: "test1", Command: ""})
cf.Hooks = append(cf.Hooks, Hook{Route: "/test2", Name: "test2", Command: ""})
listener, err := CreateListener(cf)
if err != nil {
t.Fatalf("error creating listener: %s", err)
return
}
server := CreateWebserver(cf)
mux := http.NewServeMux()
server.Handler = mux
if err := RegisterHandlers(cf, mux); err != nil {
t.Fatalf("error registering handlers: %s", err)
return
}
go server.Serve(listener)
assertStatusCode := func(url string, statusCode int) {
resp, err := http.Get(url)
if err != nil {
t.Fatalf("%s", err)
return
}
if resp.StatusCode != statusCode {
t.Fatalf("GET / returns status code %d != %d", resp.StatusCode, statusCode)
}
}
// Check default sites
assertStatusCode(fmt.Sprintf("http://%s/", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("http://%s/health", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("http://%s/health.json", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("http://%s/robots.txt", cf.Settings.BindAddress), http.StatusOK)
// Check for a 404 page
assertStatusCode(fmt.Sprintf("http://%s/404", cf.Settings.BindAddress), http.StatusNotFound)
assertStatusCode(fmt.Sprintf("http://%s/test3", cf.Settings.BindAddress), http.StatusNotFound)
// Test registered hooks
assertStatusCode(fmt.Sprintf("http://%s/test1", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("http://%s/test2", cf.Settings.BindAddress), http.StatusOK)
if err := server.Shutdown(context.Background()); err != nil {
t.Fatalf("error while server shutdown: %s", err)
return
}
}

View file

@ -50,6 +50,29 @@ See the following example configuration file:
.br
.B " gid: 0 # run under specified group id
.br
.B " # Enable TLS here here
.br
.B " tls:
.br
.B " enabled: true
.br
.B " # Minimum and maximum required TLS version. By default TLS1.2 is the minimum
.br
.B " minversion: '1.2'
.br
.B " maxversion: ''
.br
.B " keypairs:
.br
.B " - keyfile: 'weblug.key'
.br
.B " certificate: 'weblug1.pem'
.br
.B " - keyfile: 'weblug.key'
.br
.B " certificate: 'weblug2.pem'
.br
.br
.B "# hook definitions. A hook needs to define the HTTP endpoint ("route") and the command
.br
.B "# See the following examples for more possible options.
@ -60,6 +83,16 @@ See the following example configuration file:
.br
.B " route: "/webhooks/1"
.br
.B " # if hosts is present, then limit the incoming requests to the given remote host(s)
.br
.B " # Currently multiplexing the same route to different hosts does not work
.br
.B " hosts:
.br
.B " - example1.local
.br
.B " - example2.local
.br
.B " command: "sleep 5"
.br
.B " background: True # Terminate http request immediately

View file

@ -11,12 +11,30 @@ settings:
readtimeout: 10 # if set, maximum number of seconds to receive the full request
writetimeout: 10 # if set, maximum number of seconds to send the full response
maxheadersize: 4096 # maximum header size
# Enable TLS here here
tls:
enabled: true
# Minimum and maximum required TLS version. By default TLS1.2 is the minimum
minversion: '1.2'
maxversion: ''
keypairs:
- keyfile: 'weblug.key'
certificate: 'weblug1.pem'
- keyfile: 'weblug.key'
certificate: 'weblug2.pem'
# hook definitions. A hook needs to define the HTTP endpoint ("route") and the command
# See the following examples for more possible options.
hooks:
- name: 'hook one'
route: "/webhooks/1"
# if hosts is present, then limit the incoming requests to the given remote host(s)
# Currently multiplexing the same route to different hosts does not work
hosts:
- example1.local
- example2.local
command: "sleep 5"
background: True # Terminate http request immediately
concurrency: 2 # At most 2 parallel processes are allowed