diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..d59eb34 --- /dev/null +++ b/config/config.go @@ -0,0 +1,159 @@ +package config + +import ( + "encoding/json" + "io/ioutil" +) + +func setIfNotEmpty(a *string, b string) { + if b != "" { + *a = b + } +} + +// Server contains the configuration information required to start a +// redoctober server. +type Server struct { + // Addr contains the host:port that the server should listen + // on. + Addr string `json:"address"` + + // CAPath contains the path to the TLS CA for client + // authentication. This is an optional field. + CAPath string `json:"ca_path,omitempty"` + + // KeyPaths and CertPaths contains a list of paths to TLS key + // pairs that should be used to secure connections to the + // server. + KeyPaths []string `json:"private_keys"` + CertPaths []string `json:"certificates"` + + // Systemd indicates whether systemd socket activation should + // be used instead of a normal port listener. + Systemd bool `json:"use_systemd,omitempty"` +} + +// Merge copies over non-empty string values from other into the +// current Server config. +func (s *Server) Merge(other *Server) { + setIfNotEmpty(&s.Addr, other.Addr) + setIfNotEmpty(&s.CAPath, other.CAPath) + + if len(other.KeyPaths) != 0 { + s.KeyPaths = other.KeyPaths + } + + if len(other.CertPaths) != 0 { + s.CertPaths = other.CertPaths + } + + if other.Systemd { + s.Systemd = true + } +} + +// UI contains the configuration information for the WWW API. +type UI struct { + // Root contains the base URL for the UI. + Root string `json:"root"` + + // Static is an optional path for overriding the built in HTML + // UI. + Static string `json:"static"` +} + +// Merge copies over non-empty string values from other into the +// current UI config. +func (ui *UI) Merge(other *UI) { + setIfNotEmpty(&ui.Root, other.Root) + setIfNotEmpty(&ui.Static, other.Static) +} + +// HipChat contains the settings for Hipchat integration. +type HipChat struct { + Host string `json:"host"` + Room string `json:"room"` + APIKey string `json:"api_key"` +} + +// Merge copies over non-empty settings from other into the current +// HipChat config. +func (hc *HipChat) Merge(other *HipChat) { + setIfNotEmpty(&hc.Host, other.Host) + setIfNotEmpty(&hc.Room, other.Room) + setIfNotEmpty(&hc.APIKey, other.APIKey) +} + +// Metrics contains the configuration for the Prometheus metrics +// collector. +type Metrics struct { + Host string `json:"host"` + Port string `json:"port"` +} + +// Merge copies over non-empty settings from other into the current +// Metrics config. +func (m *Metrics) Merge(other *Metrics) { + setIfNotEmpty(&m.Host, other.Host) + setIfNotEmpty(&m.Port, other.Port) +} + +// Config contains all the configuration options for a redoctober +// instance. +type Config struct { + Server *Server `json:"server"` + UI *UI `json:"ui"` + HipChat *HipChat `json:"hipchat"` + Metrics *Metrics `json:"metrics"` +} + +// Merge copies over the non-empty settings from other into the +// current Config. +func (c *Config) Merge(other *Config) { + c.Server.Merge(other.Server) + c.UI.Merge(other.UI) + c.HipChat.Merge(other.HipChat) + c.Metrics.Merge(other.Metrics) +} + +// Valid ensures that the config has enough data to start a Red +// October process. +func (c *Config) Valid() bool { + // The RedOctober API relies on TLS for security. + if len(c.Server.CertPaths) == 0 || len(c.Server.KeyPaths) == 0 { + return false + } + + // The server needs some address to listen on. + if c.Server.Addr == "" && !c.Server.Systemd { + return false + } + + return true +} + +// New returns a new, empty config. +func New() *Config { + return &Config{ + Server: &Server{}, + UI: &UI{}, + HipChat: &HipChat{}, + Metrics: &Metrics{}, + } +} + +// Load reads a JSON-encoded config file from disk. +func Load(path string) (*Config, error) { + cfg := New() + in, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + + err = json.Unmarshal(in, cfg) + if err != nil { + return nil, err + } + + return cfg, nil +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..df497ba --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,237 @@ +package config + +import "testing" + +func (s *Server) equal(other *Server) bool { + if s.Addr != other.Addr { + return false + } + + if s.CAPath != other.CAPath { + return false + } + + if len(s.KeyPaths) != len(other.KeyPaths) { + return false + } + + if len(s.CertPaths) != len(other.KeyPaths) { + return false + } + + for i := range s.KeyPaths { + if s.KeyPaths[i] != other.KeyPaths[i] { + return false + } + } + + for i := range s.CertPaths { + if s.CertPaths[i] != other.CertPaths[i] { + return false + } + } + + if s.Systemd != other.Systemd { + return false + } + + return true +} + +func (ui *UI) equal(other *UI) bool { + if ui.Root != other.Root { + return false + } + + if ui.Static != other.Static { + return false + } + + return true +} + +func (hc *HipChat) equal(other *HipChat) bool { + if hc.Host != other.Host || hc.Room != other.Room || hc.APIKey != other.APIKey { + return false + } + + return true +} + +func (m *Metrics) equal(other *Metrics) bool { + return m.Host == other.Host && m.Port == other.Port +} + +func (c *Config) equal(other *Config) bool { + if !c.Server.equal(other.Server) { + return false + } + + if !c.UI.equal(other.UI) { + return false + } + + if !c.HipChat.equal(other.HipChat) { + return false + } + + if !c.Metrics.equal(other.Metrics) { + return false + } + + return true +} + +// TestEmptyEqual makes sure two empty configurations are equal. +func TestEmptyEqual(t *testing.T) { + a := New() + b := New() + + if !a.equal(b) { + t.Fatal("empty configurations should be equivalent") + } +} + +// TestMergeEmpty verifies the behaviour where merging a config into +// an empty config results in the empty config becoming the same +// config as the config being merged. +func TestMergeEmpty(t *testing.T) { + empty := New() + testConfig := &Config{ + Server: &Server{ + Addr: "localhost:8080", + CAPath: "", + KeyPaths: []string{"testdata/server.key"}, + CertPaths: []string{"testdata/server.pem"}, + Systemd: true, + }, + UI: &UI{ + Root: "https://ro.example.net", + }, + Metrics: &Metrics{ + Host: "127.0.0.1", + Port: "8081", + }, + HipChat: &HipChat{ + Host: "hipchat.example.net", + Room: "redoctober", + APIKey: "i don't this key will work", + }, + } + + if empty.equal(testConfig) { + + } + + empty.Merge(testConfig) + if !empty.equal(testConfig) { + t.Fatal("merging should result in equivalent configs") + } +} + +// TestMergeOverride verifies that merges will combine two configs. +func TestMergeOverride(t *testing.T) { + config := New() + config.Server = &Server{ + Addr: "localhost:443", + CAPath: "", + KeyPaths: []string{"testdata/server.key"}, + CertPaths: []string{"testdata/server.pem"}, + } + + merge := New() + merge.Server = &Server{ + Addr: "localhost:8000", + } + + expected := New() + expected.Server = &Server{ + Addr: "localhost:8000", + CAPath: "", + KeyPaths: []string{"testdata/server.key"}, + CertPaths: []string{"testdata/server.pem"}, + } + + if config.equal(merge) { + t.Fatal("configurations shouldn't match") + } + + if config.equal(expected) { + t.Fatal("configurations shouldn't match") + } + + config.Merge(merge) + if !config.equal(expected) { + t.Fatal("configurations don't match") + } +} + +// TestLoadFile validates loading a configuration from disk. +func TestLoadFile(t *testing.T) { + goodConfig := "testdata/config.json" + badConfig := "testdata/bad_config.json" + expected := New() + expected.Server = &Server{ + Addr: "localhost:8080", + KeyPaths: []string{"testdata/server.key"}, + CertPaths: []string{"testdata/server.pem"}, + } + + _, err := Load("testdata/enoent.json") + if err == nil { + t.Fatal("attempt to load non-existent file should fail") + } + + _, err = Load(badConfig) + if err == nil { + t.Fatal("attempt to load malformed JSON should fail") + } + + cfg, err := Load(goodConfig) + if err != nil { + t.Fatalf("failed to load config: %s", err) + } + + if !cfg.equal(expected) { + t.Fatal("loaded config is invalid") + } +} + +// TestValid validates the Validate function. +func TestValid(t *testing.T) { + config := New() + + if config.Valid() { + t.Fatal("empty config shouldn't be valid") + } + + // Certs and no keys is an invalid config. + config.Server.CertPaths = []string{"testdata/server.pem"} + if config.Valid() { + t.Fatal("config shouldn't be valid") + } + + // Keys and no certs is an invalid config. + config.Server.CertPaths = nil + config.Server.KeyPaths = []string{"testdata/server.key"} + if config.Valid() { + t.Fatal("config shouldn't be valid") + } + + // Key pairs but no address information is an invalid config. + config.Server.CertPaths = []string{"testdata/server.pem"} + if config.Valid() { + t.Fatal("config shouldn't be valid") + } + + config.Server.Addr = "localhost:8080" + if !config.Valid() { + t.Fatal("config should be valid") + } + + config.Server.Addr = "" + config.Server.Systemd = true + if !config.Valid() { + t.Fatal("config should be valid") + } +} diff --git a/config/testdata/bad_config.json b/config/testdata/bad_config.json new file mode 100644 index 0000000..98c10bf --- /dev/null +++ b/config/testdata/bad_config.json @@ -0,0 +1,8 @@ +{ + "server": { + "address": "localhost:8080", + "private_keys": ["testdata/server.key"], + "certificates": ["testdata/server.pem"], + }, +} + diff --git a/config/testdata/config.json b/config/testdata/config.json new file mode 100644 index 0000000..d9a5c0b --- /dev/null +++ b/config/testdata/config.json @@ -0,0 +1,12 @@ +{ + "server": { + "address": "localhost:8080", + "private_keys": [ + "testdata/server.key" + ], + "certificates": [ + "testdata/server.pem" + ] + } +} + diff --git a/redoctober.go b/redoctober.go index 8b9dcb1..01bb944 100644 --- a/redoctober.go +++ b/redoctober.go @@ -20,6 +20,7 @@ import ( "strings" "time" + "github.com/cloudflare/redoctober/config" "github.com/cloudflare/redoctober/core" "github.com/coreos/go-systemd/activation" "github.com/prometheus/client_golang/prometheus" @@ -209,7 +210,7 @@ func (this *indexHandler) handle(w http.ResponseWriter, r *http.Request) { // be started, a log.Fatal call is made. func initPrometheus() { srv := &http.Server{ - Addr: net.JoinHostPort(metricsHost, metricsPort), + Addr: net.JoinHostPort(cfg.Metrics.Host, cfg.Metrics.Port), Handler: prometheus.Handler(), } @@ -230,22 +231,19 @@ redoctober -vaultpath diskrecord.json -addr localhost:8080 -certs cert1.pem,cert ` var ( - addr string - metricsHost string - metricsPort string - caPath string - certsPath string - hcHost string - hcKey string - hcRoom string - keysPath string - roHost string - staticPath string - useSystemdSocket bool - vaultPath string + cfg, cli *config.Config + confFile string + vaultPath string ) func init() { + // cli contains the configuration set by the command line + // options, and cfg is the actual Red October config. + cli = config.New() + cfg = config.New() + + var certsPath, keysPath string + flag.Usage = func() { fmt.Fprint(os.Stderr, "main usage dump\n") fmt.Fprint(os.Stderr, usage) @@ -253,46 +251,56 @@ func init() { os.Exit(2) } - flag.StringVar(&addr, "addr", "localhost:8080", "Server and port separated by :") - flag.StringVar(&caPath, "ca", "", "Path of TLS CA for client authentication (optional)") + flag.StringVar(&confFile, "f", "", "path to config file") + flag.StringVar(&cli.Server.Addr, "addr", "localhost:8080", "Server and port separated by :") + flag.StringVar(&cli.Server.CAPath, "ca", "", "Path of TLS CA for client authentication (optional)") flag.StringVar(&certsPath, "certs", "", "Path(s) of TLS certificate in PEM format, comma-separated") - flag.StringVar(&hcHost, "hchost", "", "Hipchat Url Base (ex: hipchat.com)") - flag.StringVar(&hcKey, "hckey", "", "Hipchat API Key") - flag.StringVar(&hcRoom, "hcroom", "", "Hipchat Room Id") + flag.StringVar(&cli.HipChat.Host, "hchost", "", "Hipchat Url Base (ex: hipchat.com)") + flag.StringVar(&cli.HipChat.APIKey, "hckey", "", "Hipchat API Key") + flag.StringVar(&cli.HipChat.Room, "hcroom", "", "Hipchat Room Id") flag.StringVar(&keysPath, "keys", "", "Path(s) of TLS private key in PEM format, comma-separated, must me in the same order as the certs") - flag.StringVar(&metricsHost, "metrics-host", "localhost", "The `host` the metrics endpoint should listen on.") - flag.StringVar(&metricsPort, "metrics-port", "8081", "The `port` the metrics endpoint should listen on.") - flag.StringVar(&roHost, "rohost", "", "RedOctober Url Base (ex: localhost:8080)") - flag.StringVar(&staticPath, "static", "", "Path to override built-in index.html") - flag.BoolVar(&useSystemdSocket, "systemdfds", false, "Use systemd socket activation to listen on a file. Useful for binding privileged sockets.") + flag.StringVar(&cli.Metrics.Host, "metrics-host", "localhost", "The `host` the metrics endpoint should listen on.") + flag.StringVar(&cli.Metrics.Port, "metrics-port", "8081", "The `port` the metrics endpoint should listen on.") + flag.StringVar(&cli.UI.Root, "rohost", "", "RedOctober Url Base (ex: localhost:8080)") + flag.StringVar(&cli.UI.Static, "static", "", "Path to override built-in index.html") + flag.BoolVar(&cli.Server.Systemd, "systemdfds", false, "Use systemd socket activation to listen on a file. Useful for binding privileged sockets.") flag.StringVar(&vaultPath, "vaultpath", "diskrecord.json", "Path to the the disk vault") flag.Parse() + + cli.Server.CertPaths = strings.Split(certsPath, ",") + cli.Server.KeyPaths = strings.Split(keysPath, ",") } //go:generate go run generate.go func main() { - if vaultPath == "" || certsPath == "" || keysPath == "" || - (addr == "" && useSystemdSocket == false) { + var err error + if confFile != "" { + cfg, err = config.Load(confFile) + if err != nil { + log.Fatal(err) + } + } + cfg.Merge(cli) + + if vaultPath == "" || !cfg.Valid() { fmt.Fprint(os.Stderr, usage) flag.PrintDefaults() os.Exit(2) } - certPaths := strings.Split(certsPath, ",") - keyPaths := strings.Split(keysPath, ",") - - if err := core.Init(vaultPath, hcKey, hcRoom, hcHost, roHost); err != nil { + if err := core.Init(vaultPath, cfg.HipChat.APIKey, cfg.HipChat.Room, cfg.HipChat.Room, cfg.UI.Root); err != nil { log.Fatal(err) } initPrometheus() - s, l, err := NewServer(staticPath, addr, caPath, certPaths, keyPaths, useSystemdSocket) + s, l, err := NewServer(cfg.UI.Static, cfg.Server.Addr, cfg.Server.CAPath, + cfg.Server.CertPaths, cfg.Server.KeyPaths, cfg.Server.Systemd) if err != nil { log.Fatalf("Error starting redoctober server: %s\n", err) } - log.Printf("http.serve start: addr=%s", addr) + log.Printf("http.serve start: addr=%s", cfg.Server.Addr) log.Fatal(s.Serve(l)) }