Configuration file reading

* An example configuration file has been added.
  * The data structure that corresponds to the configuration has been
    defined, and functions to load it have been added.
  * Logging using logrus and command line arguments that configure
    logging and set the configuration file's path have been added.
  * Opening the UNIX socket has been implemented.
This commit is contained in:
Emmanuel BENOîT 2021-10-30 17:07:06 +02:00
parent 18ce1d6738
commit 610cbf28f8
7 changed files with 407 additions and 1 deletions

2
.gitignore vendored
View file

@ -1,2 +1,4 @@
bin
go.sum
fetchcert
fetch-certificates.yml

104
config.go Normal file
View file

@ -0,0 +1,104 @@
package main
import (
"fmt"
"io/ioutil"
"os"
"gopkg.in/yaml.v2"
)
type (
/* *
* CONFIGURATION DATA *
* */
// UNIX socket configuration. This includes the full path to the socket
// as well as the group name and mode.
tSocketConfig struct {
Path string `yaml:"path"`
Group string `yaml:"group"`
Mode os.FileMode `yaml:"mode"`
}
// LDAP connection configuration, used for servers and as a way to specify
// defaults.
tLdapConnectionConfig struct {
Port uint16 `yaml:"port"`
TLS string `yaml:"tls"`
TLSNoVerify bool `yaml:"tls_skip_verify"`
CaChain string `yaml:"ca_chain"`
BindUser string `yaml:"bind_user"`
BindPassword string `yaml:"bind_password"`
}
// LDAP server configuration. This defines how to connect to a
// single, specific LDAP server.
tLdapServerConfig struct {
Host string `yaml:"host"`
tLdapConnectionConfig
}
// LDAP attributes and base DN configuration
tLdapStructureConfig struct {
BaseDN string `yaml:"base_dn"`
EndEntityCertificate string `yaml:"end_entity"`
CACertificate string `yaml:"ca_certificate"`
CAChaining string `yaml:"ca_chaining"`
}
// LDAP configuration: LDAP structure, connection defaults and server
// connections.
tLdapConfig struct {
Structure tLdapStructureConfig `yaml:"structure"`
Defaults tLdapConnectionConfig `yaml:"defaults"`
Servers []tLdapServerConfig `yaml:"servers"`
}
// Certificate file configuration.
tCertificateFileConfig struct {
Path string `yaml:"path"`
Mode os.FileMode `yaml:"mode"`
Owner string `yaml:"owner"`
Group string `yaml:"group"`
PrependFiles []string `yaml:"prepend_files"`
Certificate string `yaml:"certificate"`
CACertificates []string `yaml:"ca"`
CAChainOf string `yaml:"ca_chain_of"`
Reverse bool `yaml:"reverse"`
AppendFiles []string `yaml:"append_files"`
AfterUpdate []string `yaml:"after_update"`
}
// Main configuration.
tConfiguration struct {
Socket tSocketConfig `yaml:"socket"`
LdapConfig tLdapConfig `yaml:"ldap"`
Certificates []tCertificateFileConfig `yaml:"certificates"`
}
)
func defaultConfiguration() tConfiguration {
cfg := tConfiguration{}
cfg.Socket.Mode = 0640
cfg.LdapConfig.Defaults.Port = 389
cfg.LdapConfig.Defaults.TLS = "no"
return cfg
}
// Load and check the configuration file
func loadConfiguration(file string) (tConfiguration, error) {
cfg := defaultConfiguration()
cfgData, err := ioutil.ReadFile(file)
if err != nil {
return cfg, fmt.Errorf("Could not load configuration: %w", err)
}
err = yaml.Unmarshal(cfgData, &cfg)
if err != nil {
return cfg, fmt.Errorf("Could not parse configuration: %w", err)
}
return cfg, nil
}

View file

@ -0,0 +1,95 @@
# fetchcert configuration example / documentation
# ===============================================
# The UNIX socket the main program listens on.
socket:
# The path to the UNIX socket.
path: /tmp/socket
# A group name to set as the socket's owner. No group change will occur if
# this entry is not set.
group: users
# The socket's access mode. Defaults to 0640.
mode: 0640
# Configuration for the LDAP servers and structure.
ldap:
structure:
# Base DN that will be appended to certificate DNs
base_dn: ou=certificates,dc=example,dc=org
# Name of the attribute that will contain an end entity certificate
# in the LDAP objects.
end_entity: userCertificate
# Name of the attribute that will contain a CA certificate in the LDAP
# objects.
ca_certificate: caCertificate
# Attribute that will contain the DN of the next certificate in the chain.
ca_chaining: seeAlso
# These are the defaults for the LDAP server connections. May be completely
# omitted.
defaults:
# Port number - usually 389 for clear/starttls or 636 for TLS. Defaults to
# 389.
port: 636
# TLS mode. This must be either "yes" for the non-standard, pure TLS mode,
# "starttls" for TLS over a clear connection, or "no" to use a clear
# connection. Defaults to "no".
tls: yes
# Skip server certificate check. Defaults to false.
tls_skip_verify: false
# CA certificate chain. Can be omitted if the systems' trusted CAs must be
# used, or if no TLS is being used.
ca_chain: /path/to/ca/chain.pem
# LDAP user (as a DN) and password to bind with. Both fields may be
# omitted if anonymous binds are to be used.
bind_user: cn=fetchcert,ou=automation,dc=example,dc=org
bind_password: drowssap
# Configurations for each LDAP server. Each entry must incluse a "host"
# field which defines the host name for the server ; it may also redefine
# any of the defaults above.
servers:
- host: ldap1.example.org
- host: ldap2.example.org
# Certificates that must be updated
certificates:
# Path to the file to generate
- path: /etc/ssl/private/cert1.pem
# Access mode, owner and group for the file. May be omitted.
mode: 0640
owner: root
group: somegroup
# A list of files to prepend. Can be used to e.g. copy the private key
# into this file.
prepend_files:
- /some/file.pem
# DN of the certificate itself. If a base DN is defined in the LDAP
# section, it will be appended to this value. Can be omitted if either
# the ca or ca_chain_of fields below are in use.
certificate: cn=www.example.org,ou=webservers
# A list of DNs of CA certificates. The base DN from the LDAP section will
# be appended to each entry if defined. If this list is empty and the
# ca_chain_of field below is undefined as well, the certificate field
# above must be defined.
ca: ['cn=root,ou=ca','cn=interm,ou=ca']
# Alternatively, CA chaining using the LDAP attribute defined above can
# be used by specifying the DN of a certificate here. The certificate
# matching the DN will be ignored, it will only be used as the start of
# the chain. Using this mechanism is incompatible with usage of the ca
# field above.
ca_chain_of: cn=www.example.org,ou=webservers
# Reverse order. If this is false, the main certificate will be written
# first, followed by the first intermediary certificate, and so on until
# the root CA certificate is found. If this is true, the first certificate
# in the file will be the root CA certificate.
reverse: false
# A list of files to append to the output.
append_files:
- /some/other/file.pem
# A list of commands that will be executed when the file is replaced.
# If one of the commands fails, execution will stop.
after_update:
- apache2ctl graceful

12
go.mod
View file

@ -1,3 +1,15 @@
module nocternity.net/go/fetchcert
go 1.17
require (
github.com/gemnasium/logrus-graylog-hook/v3 v3.0.3
github.com/karrick/golf v1.4.0
github.com/sirupsen/logrus v1.8.1
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/pkg/errors v0.8.1 // indirect
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 // indirect
)

73
logging.go Normal file
View file

@ -0,0 +1,73 @@
package main
import (
"io/ioutil"
"log/syslog"
"os"
lrh_gl "github.com/gemnasium/logrus-graylog-hook/v3"
"github.com/sirupsen/logrus"
lrh_sl "github.com/sirupsen/logrus/hooks/syslog"
lrh_wr "github.com/sirupsen/logrus/hooks/writer"
)
var (
// The logging context.
log *logrus.Entry
)
// Configure the log level
func toLogLevel(cliLevel string) logrus.Level {
if cliLevel == "" {
return logrus.InfoLevel
}
lvl, err := logrus.ParseLevel(cliLevel)
if err == nil {
return lvl
}
log.WithField("level", cliLevel).Warning("Invalid log level on command line")
return logrus.InfoLevel
}
// Add a file writer hook to the logging library.
func configureLogFile(path string) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err == nil {
log.Logger.AddHook(&lrh_wr.Hook{
Writer: file,
LogLevels: logrus.AllLevels,
})
} else {
log.WithFields(logrus.Fields{
"error": err,
"file": path,
}).Error("Could not open log file")
}
}
// Configure the logging library based on the various command line flags.
func configureLogging(flags cliFlags) error {
log = logrus.NewEntry(logrus.New())
log.Logger.SetFormatter(&logrus.TextFormatter{
DisableColors: true,
FullTimestamp: true,
})
log.Logger.SetLevel(toLogLevel(flags.logLevel))
if flags.logFile != "" {
configureLogFile(flags.logFile)
}
if flags.logGraylog != "" {
log.Logger.AddHook(lrh_gl.NewGraylogHook(flags.logGraylog, nil))
}
if flags.logSyslog {
hook, err := lrh_sl.NewSyslogHook("", "", syslog.LOG_DEBUG, "fetchcert")
if err != nil {
return err
}
log.Logger.AddHook(hook)
}
if flags.quiet {
log.Logger.SetOutput(ioutil.Discard)
}
return nil
}

73
main.go Normal file
View file

@ -0,0 +1,73 @@
package main
import (
"os"
"github.com/karrick/golf"
)
type (
// This structure contains all values that may be set from the command line.
cliFlags struct {
// The path to the configuration file.
cfgFile string
// Quiet mode. Will disable logging to stderr.
quiet bool
// The log level.
logLevel string
// A file to write logs into.
logFile string
// Graylog server to send logs to (using GELF/UDP). Format is <hostname>:<port>.
logGraylog string
// Send logs to syslog.
logSyslog bool
}
)
// Parse command line options.
func parseCommandLine() cliFlags {
var help bool
flags := cliFlags{}
golf.StringVarP(&flags.cfgFile, 'c', "config", "/etc/fetch-certificates.yml", "Path to the configuration file.")
golf.StringVarP(&flags.logFile, 'f', "log-file", "", "Path to the log file.")
golf.StringVarP(&flags.logGraylog, 'g', "log-graylog", "", "Log to Graylog server (format: <host>:<port>).")
golf.BoolVarP(&help, 'h', "help", false, "Display command line help and exit.")
golf.StringVarP(&flags.logLevel, 'L', "log-level", "info", "Log level to use.")
golf.BoolVarP(&flags.quiet, 'q', "quiet", false, "Quiet mode; prevents logging to stderr.")
golf.BoolVarP(&flags.logSyslog, 's', "syslog", false, "Log to local syslog.")
golf.Parse()
if help {
golf.Usage()
os.Exit(0)
}
return flags
}
func main() {
// This utility will load its configuration then start listening on
// a UNIX socket. It will be handle messages that can :
// - stop the program,
// - update the configuration,
// - check a single entry for replacement,
// - check all entries for replacement.
// Both check commands include a flag that will force replacement.
flags := parseCommandLine()
err := configureLogging(flags)
if err != nil {
log.WithField("error", err).Fatal("Failed to configure logging.")
}
cfg, err := loadConfiguration(flags.cfgFile)
if err != nil {
log.WithField("error", err).Fatal("Failed to load initial configuration.")
}
listener, err := initSocket(cfg.Socket)
if err != nil {
log.WithField("error", err).Fatal("Failed to initialize socket.")
}
listener.Close()
}

47
socket.go Normal file
View file

@ -0,0 +1,47 @@
package main
import (
"fmt"
"net"
"os"
"os/user"
"strconv"
)
func configureSocket(cfg tSocketConfig) error {
if cfg.Group != "" {
group, err := user.LookupGroup(cfg.Group)
if err != nil {
return fmt.Errorf("Group %s not found: %w", cfg.Group, err)
}
gid, err := strconv.Atoi(group.Gid)
if err != nil {
return fmt.Errorf("Group %s has non-numeric GID %s", cfg.Group, group.Gid)
}
err = os.Chown(cfg.Path, -1, gid)
if err != nil {
return fmt.Errorf("Cannot change group on UNIX socket: %w", err)
}
}
err := os.Chmod(cfg.Path, cfg.Mode)
if err != nil {
return fmt.Errorf("Cannot set UNIX socket access mode: %w", err)
}
return nil
}
func initSocket(cfg tSocketConfig) (net.Listener, error) {
listener, err := net.Listen("unix", cfg.Path)
if err != nil {
return nil, fmt.Errorf("Cannot listen on UNIX socket at %s: %w", cfg.Path, err)
}
err = configureSocket(cfg)
if err != nil {
listener.Close()
return nil, err
}
log.WithField("path", cfg.Path).Info("UNIX socket created")
return listener, nil
}