Compare commits

...

19 commits

Author SHA1 Message Date
Felix Niederwanger 42176fac4e
Add unit test for the message body and headers
Adds unit tests for passing of the message body and the message headers.
2024-04-05 09:35:09 +02:00
Felix Niederwanger a3266382fd
Pass headers and body to subcommands
Pass the http headers and the http body to commands executed by weblug.
2024-04-05 09:35:03 +02:00
Felix Niederwanger 5f297ff006 Merge pull request 'Finalize tls' (#26) from tls into main
Reviewed-on: https://codeberg.org/grisu48/weblug/pulls/26
2024-03-23 17:55:26 +00:00
Felix Niederwanger f1de88e478
Add key generation section 2024-03-23 18:54:45 +01:00
Felix Niederwanger 355381e584
Add note for reverse proxies
Add note saying weblug should not be run on the open internet without a
reverse proxy.
2024-03-23 18:51:12 +01:00
Felix Niederwanger d486f8176d
Update section
Update the tls section to reflect that we're supporting tls now.
2024-03-20 20:14:34 +01:00
Felix Niederwanger eee10c16ad
Remove self-signed TODO
We're not going to support self-signed certificates from within weblug.
Remove the TODO section.
2024-03-20 20:12:41 +01:00
Felix Niederwanger 405cf8dfed Merge pull request 'Add TLS test' (#25) from testing into main
Reviewed-on: https://codeberg.org/grisu48/weblug/pulls/25
2024-02-26 14:57:43 +00:00
Felix Niederwanger ef0bc17e67
Add TLS test
Adds a unit test for the TLS webserver.
2024-02-26 13:50:30 +01:00
Felix Niederwanger d9d8b73b50 Merge pull request 'Remove leftovers' (#24) from testing into main
Reviewed-on: https://codeberg.org/grisu48/weblug/pulls/24
2024-02-26 09:41:25 +00:00
Felix Niederwanger 6cf62dc77c
Remove leftovers
Remove leftover config fields. Those are not used and should not have
been introduces in the previous commits.
2024-02-26 08:12:13 +01:00
Felix Niederwanger f6ebd78c43 Merge pull request 'Introduce tls support' (#22) from tls into main
Reviewed-on: https://codeberg.org/grisu48/weblug/pulls/22
2024-02-26 07:09:36 +00:00
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
Felix Niederwanger f87a4d7006 Merge pull request 'Add vet' (#21) from vet into main
Reviewed-on: https://codeberg.org/grisu48/weblug/pulls/21
2024-02-08 17:24:27 +00:00
Felix Niederwanger 748438eb3b
Add vet
Adds rule for running go vet.
2024-02-08 18:23:24 +01:00
Felix Niederwanger d5f79eae01 Merge pull request 'Add default page' (#20) from default into main
Reviewed-on: https://codeberg.org/grisu48/weblug/pulls/20
2023-11-25 22:13:14 +00:00
Felix Niederwanger 9713b0d2c1
Add default page
The default page handler now also accepts / as URL path for the default
page.
2023-11-25 23:12:51 +01:00
10 changed files with 777 additions and 49 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

@ -10,4 +10,5 @@ static: cmd/weblug/*.go
CGO_ENABLED=0 go build -ldflags="-w -s" -o weblug $^
test: weblug
go test ./...
sudo bash -c "cd test && ./blackbox.sh"

View file

@ -16,20 +16,26 @@ Webooks are defined via a yaml file. See [weblug.yml](weblug.yml) for an example
### Caveats
1. `weblug` does not support https encryption
1. `weblug` should not face the open internet
weblug is expected to run behind a http reverse proxy (e.g. `apache` or `nginx`) which handles transport encryption. The program it self does not support https, nor are there any plans to implement this in the near future.
weblug is expected to run behind a http reverse proxy (e.g. `apache` or `nginx`).
While the program itself does support tls, to avoid a whole class of security issues, `weblug` should never run on the open internet without a http reverse proxy.
CAVE: Don't expose secrets and credentials by running this without any transport encryption!
2. Do not run this without reverse proxy
`weblug` relies on the standart go http implementation. To avoid a whole class of security issues, `weblug` should never run on the open internet without a http reverse proxy.
3. `weblug` runs as root, when using custom UID/GIDs
2. `weblug` runs as root, when using custom UID/GIDs
In it's current implementation, `weblug` requires to remain running as root without dropping privileges when using custom UID/GIDs.
### TLS
`weblug` now supports tls. To generate self-signed keys
```
openssl genrsa -out weblug.key 2048
openssl req -new -x509 -sha256 -key weblug.key -out weblug1.pem -days 365
```
Then configure the `weblug.yml` file accordingly. You can use multiple keys/certificates.
## Build
make # Build weblug

View file

@ -29,4 +29,13 @@ tasks:
preconditions:
- test -f weblug
cmds:
- go test ./...
- sudo bash -c "cd test && ./blackbox.sh"
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,41 @@
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 header
MaxBodySize int64 `yaml:"maxbodysize"` // 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"`
}
type TLSKeypairs struct {
Keyfile string `yaml:"keyfile"`
Certificate string `yaml:"certificate"`
}
func (cf *Config) SetDefaults() {
@ -63,3 +80,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.3" {
return tls.VersionTLS13, nil
} else if version == "1.2" {
return tls.VersionTLS12, nil
} else if version == "1.1" {
return tls.VersionTLS11, nil
} else if version == "1.0" {
return tls.VersionTLS10, nil
} else {
return 0, fmt.Errorf("invalid tls version string")
}
}

View file

@ -1,6 +1,7 @@
package main
import (
"bytes"
"fmt"
"net"
"os/exec"
@ -12,6 +13,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
@ -23,6 +25,8 @@ type Hook struct {
BlockedAddresses []string `yaml:"blocked"` // Addresses that are explicitly blocked
HttpBasicAuth BasicAuth `yaml:"basic_auth"` // Optional requires http basic auth
Env map[string]string `yaml:"env"` // Optional environment variables
maxBodySize int64 // Maximum allowed body size for this hook
}
type BasicAuth struct {
@ -78,8 +82,14 @@ func cmdSplit(command string) []string {
return ret
}
// 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 {
// Run executes the given command and return it's return code.
// Will pass the given input string to the command
// It also respects the given concurrency number and will block until resources are free
func (hook *Hook) Run(buffer []byte) ([]byte, error) {
if hook.Command == "" {
return make([]byte, 0), nil
}
split := cmdSplit(hook.Command)
args := make([]string, 0)
if len(split) > 1 {
@ -97,12 +107,8 @@ func (hook *Hook) Run() error {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
}
}
if hook.Output {
buf, ret := cmd.Output()
fmt.Println(string(buf))
return ret
}
return cmd.Run()
cmd.Stdin = bytes.NewReader(buffer)
return cmd.Output()
}
func isAddressInList(addr string, addrList []string) (bool, error) {

View file

@ -4,17 +4,20 @@
package main
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
var cf Config
type Handler func(http.ResponseWriter, *http.Request)
func usage() {
@ -56,6 +59,20 @@ func sanityCheckHooks(hooks []Hook) error {
return nil
}
// Extract the hostname from an URL
func hostname(url string) string {
hostname := url
i := strings.Index(hostname, "://")
if i > 0 {
hostname = hostname[i+3:]
}
i = strings.Index(hostname, ":")
if i > 0 {
hostname = hostname[:i]
}
return hostname
}
func main() {
cf.SetDefaults()
if len(os.Args) < 2 {
@ -100,38 +117,122 @@ func main() {
}
}
// Create default handlers
http.HandleFunc("/health", createHealthHandler())
http.HandleFunc("/health.json", createHealthHandler())
http.HandleFunc("/index", createDefaultHandler())
http.HandleFunc("/index.htm", createDefaultHandler())
http.HandleFunc("/index.html", createDefaultHandler())
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.MaxVersion)
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 {
return nil, fmt.Errorf("no keypairs provided")
} 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 {
// Register default paths
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
}
// allow hooks to have individual maxBodySize arguments.
if cf.Settings.MaxBodySize > 0 && hook.maxBodySize == 0 {
hook.maxBodySize = cf.Settings.MaxBodySize
}
mux.HandleFunc(hook.Route, createHandler(hook))
}
return nil
}
// create a http handler function from the given hook
@ -139,6 +240,25 @@ 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
// Extract the queried hostname
queriedHost := hostname(r.Host)
for _, host := range hook.Hosts {
if host == queriedHost {
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 {
@ -185,13 +305,37 @@ func createHandler(hook Hook) Handler {
return
}
// Input buffer used to pass the headers to the process
var buffer bytes.Buffer
for k, v := range r.Header {
buffer.WriteString(fmt.Sprintf("%s:%s\n", k, strings.Join(v, ",")))
}
buffer.WriteString("\n")
// Receive body only if configured. By default the body is ignored.
if hook.maxBodySize > 0 {
body := make([]byte, hook.maxBodySize)
n, err := r.Body.Read(body)
if err != nil && err != io.EOF {
log.Printf("receive body failed: %s", err)
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(500)
fmt.Fprintf(w, "{\"status\":\"fail\",\"reason\":\"receive failure\"}")
return
}
buffer.Write(body[:n])
}
if hook.Background { // Execute command in background
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(200)
fmt.Fprintf(w, "{\"status\":\"ok\"}")
go func() {
defer hook.Unlock()
if err := hook.Run(); err != nil {
buffer, err := hook.Run(buffer.Bytes())
if hook.Output {
fmt.Println(string(buffer))
}
if err != nil {
log.Printf("Hook \"%s\" failed: %s", hook.Name, err)
} else {
log.Printf("Hook \"%s\" completed", hook.Name)
@ -199,7 +343,11 @@ func createHandler(hook Hook) Handler {
}()
} else {
defer hook.Unlock()
if err := hook.Run(); err != nil {
buffer, err := hook.Run(buffer.Bytes())
if hook.Output {
fmt.Println(string(buffer))
}
if err != nil {
log.Printf("ERR: \"%s\" exec failure: %s", hook.Name, err)
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(500)
@ -223,8 +371,15 @@ func createHealthHandler() Handler {
func createDefaultHandler() Handler {
return func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintf(w, "weblug - webhook receiver program\nSee https://codeberg.org/grisu48/weblug\n")
if r.URL.Path == "/" || r.URL.Path == "/index.txt" {
w.WriteHeader(200)
fmt.Fprintf(w, "weblug - webhook receiver program\nhttps://codeberg.org/grisu48/weblug\n")
} else if r.URL.Path == "/index.htm" || r.URL.Path == "/index.html" {
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 {
respondNotFound(w, r)
}
}
}
@ -234,3 +389,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
}

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

@ -0,0 +1,455 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"net/http"
"os"
"strings"
"testing"
"time"
)
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)
defer func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatalf("error while server shutdown: %s", err)
return
}
}()
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)
}
// Tests the TLS functions of the webserver
func TestTLSWebserver(t *testing.T) {
var cf Config
const TESTPORT = 2089
// Test keypairs. testkey1 belongs to the "localhost" host and testkey2 belongs to the "localhost" and "example.com" hosts
keypairs := make([]TLSKeypairs, 0)
keypairs = append(keypairs, TLSKeypairs{Keyfile: "testkey1.pem", Certificate: "testcert1.pem"})
keypairs = append(keypairs, TLSKeypairs{Keyfile: "testkey2.pem", Certificate: "testcert2.pem"})
// Generate test certificates
for i, keypair := range keypairs {
if fileExists(keypair.Keyfile) {
t.Fatalf("test key '%s' already exists", keypair.Keyfile)
return
}
if fileExists(keypair.Certificate) {
t.Fatalf("test certificate '%s' already exists", keypair.Certificate)
return
}
hostnames := []string{"localhost"}
if i == 1 {
hostnames = append(hostnames, "example.com")
}
if err := generateKeypair(keypair.Keyfile, keypair.Certificate, hostnames); err != nil {
t.Fatalf("keypair generation failed: %s\n", err)
return
}
defer func(keypair TLSKeypairs) {
os.Remove(keypair.Keyfile)
os.Remove(keypair.Certificate)
}(keypair)
}
cf.Settings.BindAddress = fmt.Sprintf("localhost:%d", TESTPORT)
cf.Settings.TLS.Enabled = true
cf.Settings.TLS.MinVersion = "1.3"
cf.Settings.TLS.MaxVersion = "1.3"
cf.Settings.TLS.Keypairs = keypairs
cf.Hooks = make([]Hook, 0)
cf.Hooks = append(cf.Hooks, Hook{Route: "/test1", Name: "test1", Command: "", Hosts: []string{"localhost"}})
cf.Hooks = append(cf.Hooks, Hook{Route: "/test2", Name: "test2", Command: "", Hosts: []string{"localhost", "example.com"}})
// Setup TLS webserver
listener, err := CreateTLSListener(cf)
if err != nil {
t.Fatalf("error creating tls 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)
defer func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatalf("error while server shutdown: %s", err)
return
}
}()
// Default page without https should return a 400 error
resp, err := http.Get(fmt.Sprintf("http://%s/", cf.Settings.BindAddress))
if err != nil {
t.Fatalf("%s", err)
return
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("GET / returns status code %d for default page (400 expected)", resp.StatusCode)
}
// Check default page with tls certificates
certs := make([]tls.Certificate, 0)
rootCAs, _ := x509.SystemCertPool()
for i, keypair := range keypairs {
x509cert, err := readCertificate(keypair.Certificate)
if err != nil {
t.Fatalf("error loading certificate %d: %s", i, err)
return
}
raw := make([][]byte, 0)
raw = append(raw, x509cert.Raw)
certs = append(certs, tls.Certificate{Certificate: raw})
rootCAs.AddCert(x509cert)
}
dialer := &net.Dialer{}
transport := &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: certs,
RootCAs: rootCAs,
},
// Mock connections to example.com -> 127.0.0.1
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
if strings.Contains(addr, "example.com") {
addr = strings.ReplaceAll(addr, "example.com", "127.0.0.1")
}
return dialer.DialContext(ctx, network, addr)
},
}
client := http.Client{Transport: transport, Timeout: 15 * time.Second}
assertStatusCode := func(url string, statusCode int) {
resp, err = client.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)
}
}
fetchBody := func(url string) (string, error) {
resp, err = client.Get(url)
if err != nil {
return "", err
}
body, err := io.ReadAll(resp.Body)
return string(body), err
}
// Check default page and test hooks
assertStatusCode(fmt.Sprintf("https://%s/", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("https://%s/test1", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("https://%s/test2", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("https://%s/test404", cf.Settings.BindAddress), http.StatusNotFound)
// Check if connection via TLS 1.2 is not accepted (we're enforcing TLS >= 1.3)
transport.TLSClientConfig.MinVersion = tls.VersionTLS12
transport.TLSClientConfig.MaxVersion = tls.VersionTLS12
resp, err = client.Get(fmt.Sprintf("https://%s/", cf.Settings.BindAddress))
if err == nil {
t.Fatal("tls 1.2 connection possible where it should be unsupported", err)
return
} else {
// TODO: Matching by string might be flanky.
if !strings.Contains(err.Error(), "tls: protocol version not supported") {
t.Fatalf("%s", err)
return
}
}
transport.TLSClientConfig.MaxVersion = tls.VersionTLS13
// Check if example.com resolves (second certificate)
assertStatusCode(fmt.Sprintf("https://example.com:%d/", TESTPORT), http.StatusOK)
// Only /test2 should be reachable via example.com
assertStatusCode(fmt.Sprintf("https://example.com:%d/test1", TESTPORT), http.StatusNotFound)
assertStatusCode(fmt.Sprintf("https://example.com:%d/test2", TESTPORT), http.StatusOK)
// Assert, that the host 404 page is the same as the 404 page for a route that doesn't exist.
// This check is needed, because we pretend a path to not exist, if `hosts` is configured and
// we don't want to give attackers the possibility to distinguish between the two 404 errors
if body1, err := fetchBody(fmt.Sprintf("https://%s/test404", cf.Settings.BindAddress)); err != nil {
t.Fatalf("%s", err)
return
} else if body2, err := fetchBody(fmt.Sprintf("https://example.com:%d/test1", TESTPORT)); err != nil {
t.Fatalf("%s", err)
return
} else {
if body1 != body2 {
t.Fatal("404 bodies differ between default 404 page and host-not-matched route", err)
return
}
}
}
// Tests the run hook commands
func TestRunHook(t *testing.T) {
testText := "hello Test"
hook := Hook{Name: "hook", Command: "cat"}
buffer, err := hook.Run([]byte(testText))
if err != nil {
t.Fatalf("running test hook failed: %s", err)
}
ret := string(buffer)
if ret != testText {
t.Error("returned string mismatch")
}
}
// Tests passing the request header and body
func TestHeaderAndBody(t *testing.T) {
// Create temp file
tempFile, err := os.CreateTemp("", "test_header_body_*")
if err != nil {
panic(err)
}
defer func() {
os.Remove(tempFile.Name())
}()
// Create test webserver with receive hook, that passes all headers and the body to the temp file
var cf Config
bodyIncluded := "this is the request body\nit is awesome"
bodyIgnored := "this part of the body should be ignored\nIt is hopefully not present"
bodyText := fmt.Sprintf("%s\n%s", bodyIncluded, bodyIgnored)
cf.Settings.BindAddress = "127.0.0.1:2088"
cf.Settings.MaxBodySize = int64(len(bodyIncluded))
cf.Hooks = make([]Hook, 0)
cf.Hooks = append(cf.Hooks, Hook{Name: "hook", Command: fmt.Sprintf("tee %s", tempFile.Name()), Route: "/header_and_body"})
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)
defer func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatalf("error while server shutdown: %s", err)
return
}
}()
// Create http request with custom headers and a message body
client := &http.Client{}
req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/header_and_body", cf.Settings.BindAddress), nil)
if err != nil {
panic(err)
}
headers := make(map[string]string, 0)
headers["Header1"] = "value1"
headers["Header2"] = "value2"
headers["Header3"] = "value3"
headers["Content-Type"] = "this is the content type"
for k, v := range headers {
req.Header.Set(k, v)
}
req.Body = io.NopCloser(bytes.NewReader([]byte(bodyText)))
res, err := client.Do(req)
if err != nil {
t.Fatalf("http request error: %s", err)
}
if res.StatusCode != http.StatusOK {
t.Fatalf("http request failed: %d != %d", res.StatusCode, http.StatusOK)
}
// Assert that the headers and the body is in the test file
buf, err := os.ReadFile(tempFile.Name())
if err != nil {
panic(err)
}
contents := string(buf)
assertHeader := func(key string, value string) {
if !strings.Contains(contents, key) {
t.Fatalf("Header %s is not present", key)
}
if !strings.Contains(contents, fmt.Sprintf("%s:%s\n", key, value)) {
t.Fatalf("Header %s has not the right value", key)
}
}
for k, v := range headers {
assertHeader(k, v)
}
// Assert the message body got passed as well
if !strings.Contains(contents, bodyIncluded) {
t.Fatal("Message body was not passed")
}
// Assert the messaeg body got cropped
if strings.Contains(contents, bodyIgnored) {
t.Fatal("Cut-off after max-body size didn't happen")
}
}
func generateKeypair(keyfile string, certfile string, hostnames []string) error {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
// Write key to file
var buffer []byte = x509.MarshalPKCS1PrivateKey(key)
block := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: buffer,
}
file, err := os.Create(keyfile)
if err != nil {
return err
}
defer file.Close()
if err := file.Chmod(os.FileMode(0400)); err != nil {
return err
}
if err = pem.Encode(file, block); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
// Generate certificate
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * 10 * time.Hour)
//Create certificate templet
template := x509.Certificate{
SerialNumber: big.NewInt(0),
Subject: pkix.Name{CommonName: hostnames[0]},
SignatureAlgorithm: x509.SHA256WithRSA,
DNSNames: hostnames,
NotBefore: notBefore,
NotAfter: notAfter,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement | x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
}
//Create certificate using templet
cert, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
if err != nil {
return err
}
block = &pem.Block{
Type: "CERTIFICATE",
Bytes: cert,
}
file, err = os.Create(certfile)
if err != nil {
return err
}
defer file.Close()
if err := file.Chmod(os.FileMode(0644)); err != nil {
return err
}
if err = pem.Encode(file, block); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
return nil
}
func readCertificate(certfile string) (*x509.Certificate, error) {
buffer, err := os.ReadFile(certfile)
if err != nil {
return nil, err
}
p, _ := pem.Decode(buffer)
if p == nil {
return nil, fmt.Errorf("invalid pem file")
}
cert, err := x509.ParseCertificate(p.Bytes)
return cert, err
}
func fileExists(filename string) bool {
st, err := os.Stat(filename)
if err != nil {
return false
}
return !st.IsDir()
}

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,32 @@ 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
maxbodysize: 0 # maximum size of the body before cropping. Setting to 0 will ignore the http request body. Default: 0
# Enable TLS
# Note that it is not recommended to run weblug on the open internet without using a reverse proxy
tls:
enabled: false
# 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