From be6198dbedfd66aa4aea79264b287b5c78c645c9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Emmanuel=20Beno=C3=AEt?= <tseeker@nocternity.net>
Date: Sat, 4 Dec 2021 18:31:19 +0100
Subject: [PATCH] Client mode

  * Refactored so that all server code is in a single file
  * Added missing CLI option to send reload/quit commands to the server
  * Implemented client mode
---
 client.go              |  84 +++++++++++++++++++++++++++++++
 main.go                | 111 ++++++++++++-----------------------------
 socket.go => server.go |  93 +++++++++++++++++++++++++++++-----
 3 files changed, 196 insertions(+), 92 deletions(-)
 create mode 100644 client.go
 rename socket.go => server.go (56%)

diff --git a/client.go b/client.go
new file mode 100644
index 0000000..705ad56
--- /dev/null
+++ b/client.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+	"net"
+
+	"github.com/sirupsen/logrus"
+)
+
+// Client runtime data
+type TClient struct {
+	config tConfiguration
+}
+
+// Initialize the client's state.
+func InitClient(config tConfiguration) TClient {
+	return TClient{
+		config: config,
+	}
+}
+
+// Connect to the UNIX socket. Terminate the program with an error if connection
+// fails.
+func (c *TClient) getConnection() net.Conn {
+	conn, err := net.Dial("unix", c.config.Socket.Path)
+	if err != nil {
+		log.WithFields(logrus.Fields{
+			"error": err,
+			"path":  c.config.Socket.Path,
+		}).Fatal("Could not connect to the UNIX socket")
+	}
+	return conn
+}
+
+// Send a string to the UNIX socket. Terminate the program with an error if
+// some form of IO error occurs.
+func (c *TClient) send(conn net.Conn, data string) {
+	_, err := conn.Write([]byte(data))
+	if err != nil {
+		log.WithFields(logrus.Fields{
+			"error": err,
+			"path":  c.config.Socket.Path,
+			"data":  data,
+		}).Fatal("Could not write to the UNIX socket")
+	}
+}
+
+// Send a command to the server then disconnect.
+func (c *TClient) SendCommand(command string) {
+	conn := c.getConnection()
+	defer conn.Close()
+	c.send(conn, command)
+}
+
+// Request an update by sending the selector and force flag to the server, then
+// wait for the server to respond. Returns true if the server responded that the
+// updates were executed without problem.
+func (c *TClient) RequestUpdate(selector string, force bool) bool {
+	command := "U"
+	if force {
+		command += "!"
+	} else {
+		command += " "
+	}
+	command += selector
+
+	conn := c.getConnection()
+	defer conn.Close()
+	c.send(conn, command)
+
+	buf := make([]byte, 2)
+	nr, err := conn.Read(buf)
+	if err != nil {
+		log.WithFields(logrus.Fields{
+			"error": err,
+			"path":  c.config.Socket.Path,
+		}).Fatal("Could not read server response from the UNIX socket")
+	}
+	if nr != 1 {
+		log.WithFields(logrus.Fields{
+			"path": c.config.Socket.Path,
+		}).Fatal("Invalid response from server")
+	}
+	return buf[0] == 49
+}
diff --git a/main.go b/main.go
index 0b34242..6d7867a 100644
--- a/main.go
+++ b/main.go
@@ -1,7 +1,6 @@
 package main
 
 import (
-	"net"
 	"os"
 
 	"github.com/karrick/golf"
@@ -16,6 +15,11 @@ type (
 		// then quits), client (connects to the server and requests an
 		// update) or server (runs the server in the foreground).
 		runMode string
+		// When running in client mode, if this is set to a string, it
+		// will be interpreted as a command to send to the server.
+		// Supported commands are 'Q' (quit) and 'R' (reload
+		// configuration)
+		command string
 		// The selector to use when running the updates. Only meaningful
 		// if running in client or standalone mode.
 		selector string
@@ -33,16 +37,6 @@ type (
 		// Send logs to syslog.
 		logSyslog bool
 	}
-
-	// The state of the main server
-	tServerState struct {
-		// The path to the configuration file
-		cfgFile string
-		// The configuration
-		config tConfiguration
-		// The UNIX socket listener
-		listener net.Listener
-	}
 )
 
 // Parse command line options.
@@ -52,6 +46,10 @@ func parseCommandLine() tCliFlags {
 
 	golf.StringVarP(&flags.cfgFile, 'c', "config", "/etc/fetch-certificates.yml",
 		"Path to the configuration file.")
+	golf.StringVarP(&flags.command, 'C', "command", "",
+		"Send a command to the server instead of requesting an "+
+			"update. Only meaningful in client mode. Command may be "+
+			"Q (quit) or R (reload configuration).")
 	golf.BoolVarP(&flags.force, 'f', "force", false,
 		"Force update of selected certificates. Only meaningful in "+
 			"client or standalone mode.")
@@ -81,65 +79,6 @@ func parseCommandLine() tCliFlags {
 	return flags
 }
 
-// Initialize server state
-func initServer(cfgFile string) tServerState {
-	ss := tServerState{
-		cfgFile: cfgFile,
-	}
-	cfg, err := LoadConfiguration(ss.cfgFile)
-	if err != nil {
-		log.WithField("error", err).Fatal("Failed to load initial configuration.")
-	}
-	ss.config = cfg
-	listener, err := initSocket(cfg.Socket)
-	if err != nil {
-		log.WithField("error", err).Fatal("Failed to initialize socket.")
-	}
-	ss.listener = listener
-	return ss
-}
-
-// Destroy the server
-func (state *tServerState) destroy() {
-	state.listener.Close()
-}
-
-// Server main loop. Processes commands received from connections. Certificate
-// update requests are processed directly, but Quit/Reload commands are
-// propagated back to this loop and handled here.
-func (state *tServerState) mainLoop() {
-	for {
-		cmd := socketServer(&state.config, state.listener)
-		if cmd == CMD_QUIT {
-			break
-		} else if cmd != CMD_RELOAD {
-			continue
-		}
-
-		new_cfg, err := LoadConfiguration(state.cfgFile)
-		if err != nil {
-			log.WithField("error", err).Error("Failed to load updated configuration.")
-			continue
-		}
-
-		replace_ok := true
-		if new_cfg.Socket.Path != state.config.Socket.Path {
-			new_listener, err := initSocket(new_cfg.Socket)
-			if err != nil {
-				log.WithField("error", err).Error("Failed to initialize new server socket.")
-				replace_ok = false
-			} else {
-				state.listener.Close()
-				state.listener = new_listener
-			}
-		}
-		if replace_ok {
-			state.config = new_cfg
-			log.Info("Configuration reloaded")
-		}
-	}
-}
-
 func main() {
 	flags := parseCommandLine()
 	err := configureLogging(flags)
@@ -147,26 +86,40 @@ func main() {
 		log.WithField("error", err).Fatal("Failed to configure logging.")
 	}
 
-	if flags.runMode == "server" {
-		server := initServer(flags.cfgFile)
-		defer server.destroy()
-		server.mainLoop()
-		return
-	}
-
 	cfg, err := LoadConfiguration(flags.cfgFile)
 	if err != nil {
 		log.WithField("error", err).Fatal("Failed to load initial configuration.")
 	}
-	if flags.runMode == "standalone" {
+
+	if flags.runMode == "server" {
+		server := InitServer(flags.cfgFile, cfg)
+		defer server.Destroy()
+		server.MainLoop()
+
+	} else if flags.runMode == "standalone" {
 		result := executeUpdate(&cfg, flags.selector, flags.force)
 		if result {
 			log.Debug("Update successful")
 		} else {
 			log.Fatal("Update failed")
 		}
+
 	} else if flags.runMode == "client" {
-		panic("CLIENT MODE NOT IMPLEMENTED") // FIXME
+		client := InitClient(cfg)
+		if flags.command == "Q" || flags.command == "R" {
+			client.SendCommand(flags.command)
+		} else if flags.command != "" {
+			log.WithField("command", flags.command).Fatal(
+				"Unknown server command.")
+		} else {
+			result := client.RequestUpdate(flags.selector, flags.force)
+			if result {
+				log.Debug("Update successful")
+			} else {
+				log.Fatal("Update failed")
+			}
+		}
+
 	} else {
 		log.WithField("mode", flags.runMode).Fatal("Unknown execution mode.")
 	}
diff --git a/socket.go b/server.go
similarity index 56%
rename from socket.go
rename to server.go
index 5a497b9..bec4d69 100644
--- a/socket.go
+++ b/server.go
@@ -12,20 +12,32 @@ import (
 	"github.com/sirupsen/logrus"
 )
 
-type TCommandType int
+type tCommandType int
 
 const (
-	CMD_IGNORE TCommandType = iota
+	CMD_IGNORE tCommandType = iota
 	CMD_QUIT
 	CMD_RELOAD
 	CMD_UPDATE
 )
 
-type TCommand struct {
-	CommandType TCommandType
-	Force       bool
-	Selector    string
-}
+type (
+	tCommand struct {
+		CommandType tCommandType
+		Force       bool
+		Selector    string
+	}
+
+	// The state of the main server
+	TServerState struct {
+		// The path to the configuration file
+		cfgFile string
+		// The configuration
+		config tConfiguration
+		// The UNIX socket listener
+		listener net.Listener
+	}
+)
 
 func configureSocket(cfg tSocketConfig) error {
 	if cfg.Group != "" {
@@ -67,7 +79,7 @@ func initSocket(cfg tSocketConfig) (net.Listener, error) {
 	return listener, nil
 }
 
-func socketServer(cfg *tConfiguration, listener net.Listener) TCommandType {
+func socketServer(cfg *tConfiguration, listener net.Listener) tCommandType {
 	for {
 		fd, err := listener.Accept()
 		if err != nil {
@@ -80,7 +92,7 @@ func socketServer(cfg *tConfiguration, listener net.Listener) TCommandType {
 	}
 }
 
-func executeFromSocket(cfg *tConfiguration, conn net.Conn) TCommandType {
+func executeFromSocket(cfg *tConfiguration, conn net.Conn) tCommandType {
 	defer conn.Close()
 	log.Debug("Received connection")
 
@@ -114,7 +126,7 @@ func executeFromSocket(cfg *tConfiguration, conn net.Conn) TCommandType {
 	return command.CommandType
 }
 
-func parseCommand(n int, buf []byte) *TCommand {
+func parseCommand(n int, buf []byte) *tCommand {
 	if n == 512 {
 		log.Warn("Too much data received")
 		return nil
@@ -125,12 +137,12 @@ func parseCommand(n int, buf []byte) *TCommand {
 	}
 	if n == 1 {
 		if buf[0] == 'Q' {
-			return &TCommand{CommandType: CMD_QUIT}
+			return &tCommand{CommandType: CMD_QUIT}
 		} else if buf[0] == 'R' {
-			return &TCommand{CommandType: CMD_RELOAD}
+			return &tCommand{CommandType: CMD_RELOAD}
 		}
 	} else if n > 2 && buf[0] == 'U' {
-		res := &TCommand{CommandType: CMD_UPDATE}
+		res := &tCommand{CommandType: CMD_UPDATE}
 		if buf[1] == '!' {
 			res.Force = true
 		}
@@ -142,3 +154,58 @@ func parseCommand(n int, buf []byte) *TCommand {
 	log.Warn("Invalid command received")
 	return nil
 }
+
+// Initialize server state
+func InitServer(cfgFile string, config tConfiguration) TServerState {
+	ss := TServerState{
+		cfgFile: cfgFile,
+		config:  config,
+	}
+	listener, err := initSocket(ss.config.Socket)
+	if err != nil {
+		log.WithField("error", err).Fatal("Failed to initialize socket.")
+	}
+	ss.listener = listener
+	return ss
+}
+
+// Destroy the server
+func (state *TServerState) Destroy() {
+	state.listener.Close()
+}
+
+// Server main loop. Processes commands received from connections. Certificate
+// update requests are processed directly, but Quit/Reload commands are
+// propagated back to this loop and handled here.
+func (state *TServerState) MainLoop() {
+	for {
+		cmd := socketServer(&state.config, state.listener)
+		if cmd == CMD_QUIT {
+			break
+		} else if cmd != CMD_RELOAD {
+			continue
+		}
+
+		new_cfg, err := LoadConfiguration(state.cfgFile)
+		if err != nil {
+			log.WithField("error", err).Error("Failed to load updated configuration.")
+			continue
+		}
+
+		replace_ok := true
+		if new_cfg.Socket.Path != state.config.Socket.Path {
+			new_listener, err := initSocket(new_cfg.Socket)
+			if err != nil {
+				log.WithField("error", err).Error("Failed to initialize new server socket.")
+				replace_ok = false
+			} else {
+				state.listener.Close()
+				state.listener = new_listener
+			}
+		}
+		if replace_ok {
+			state.config = new_cfg
+			log.Info("Configuration reloaded")
+		}
+	}
+}