From 50b377ecc52054bda9c5c5a0c8d9e2116ce89e1f Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Thu, 31 Aug 2023 00:44:32 +0100 Subject: [PATCH] Initial commit --- .github/workflows/build.yml | 18 ++++++++++++++++++ .gitignore | 2 ++ LICENSE | 13 +++++++++++++ README.md | 41 +++++++++++++++++++++++++++++++++++++++++ certificate-updater.cabal | 26 ++++++++++++++++++++++++++ default.nix | 2 ++ flake.lock | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 24 ++++++++++++++++++++++++ module.nix | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ shell.nix | 4 ++++ src/Main.hs | 223 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 488 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 certificate-updater.cabal create mode 100644 default.nix create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 module.nix create mode 100644 shell.nix create mode 100644 src/Main.hs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6182ace --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,18 @@ +name: "CI" +on: + pull_request: + push: +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v3 + - uses: cachix/install-nix-action@v22 + - uses: cachix/cachix-action@v12 + with: + name: w4 + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + - run: cachix watch-exec w4 -- nix build .# diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd4112d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +dist-newstyle +result diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8b1a9d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + +Copyright (C) 2004 Sam Hocevar + +Everyone is permitted to copy and distribute verbatim or modified +copies of this license document, and changing it is allowed as long +as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/README.md b/README.md new file mode 100644 index 0000000..68763ad --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# certificate-updater + +Quickly create and update certificates from a CA in Vault, automatically recreating them prior to expiry. + +## Usage + +Simply import the Nix Flake: + +```nix +{ + inputs = { + certificate-updater.url = "github:w4/certificate-updater"; + }; + + outputs = { + nixosConfigurations.my-host = { + system = "x86_64-linux"; + modules = [ + certificate-updater.nixosModules."x86_64-linux".default + ]; + }; + }; +} +``` + +And enable the service: + +```nix +{ + services.certificate-updater = { + enable = true; + role = "fortress-vector-agent"; # vault role to authenticate against + commonName = "fortress.home"; # common name of the certificate to create + ipAddress = "10.10.0.10"; # ip address to add as a SAN + mount = "gaffken/v1/ica2/v1"; # mount point of the CA + outputDirectory = "/var/lib/vector/certs"; # directory to write certificates out to + environmentFile = config.age.secrets.cert-updater-env.path; # env file containing VAULT_TOKEN=... + group = "vector"; # group that the application should run off, this group will also own the certs + }; +} +``` diff --git a/certificate-updater.cabal b/certificate-updater.cabal new file mode 100644 index 0000000..1bbc9ba --- /dev/null +++ b/certificate-updater.cabal @@ -0,0 +1,26 @@ +cabal-version: 3.4 +name: certificate-updater +version: 0.1.0.0 +author: Jordan Doyle +build-type: Simple +license: WTFPL +license-file: LICENSE + +common warnings + ghc-options: -Wall + +executable certificate-updater + import: warnings + main-is: Main.hs + build-depends: base ^>=4.17.0.0, + optparse-generic ^>=1.4.0, + rio ^>=0.1.22.0, + aeson ^>=2.1.2.1, + time ^>=1.12.1.1, + http-conduit ^>=2.3.8.1, + http-types ^>=0.12.3, + bytestring ^>=0.11.4.0, + unordered-containers ^>=0.2.19.1, + unix ^>=2.7.2.2 + hs-source-dirs: src + default-language: Haskell2010 diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..7434aae --- /dev/null +++ b/default.nix @@ -0,0 +1,2 @@ +{ pkgs ? import { } }: +pkgs.haskellPackages.callCabal2nix "certificate-updater" ./. { } diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6f93f9c --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1693355128, + "narHash": "sha256-+ZoAny3ZxLcfMaUoLVgL9Ywb/57wP+EtsdNGuXUJrwg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a63a64b593dcf2fe05f7c5d666eb395950f36bc9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "utils": "utils" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1692799911, + "narHash": "sha256-3eihraek4qL744EvQXsK1Ha6C3CR7nnT8X2qWap4RNk=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "f9e7cf818399d17d347f847525c5a5a8032e4e44", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..6e3149e --- /dev/null +++ b/flake.nix @@ -0,0 +1,24 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, utils }: + utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + packages.default = (import ./.) { + inherit pkgs; + }; + + nixosModules.default = { config, lib, pkgs, ... }: + (import ./module.nix) { + inherit config; + inherit lib; + pkg = self.packages."${system}".default; + }; + }); +} diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..324eacf --- /dev/null +++ b/module.nix @@ -0,0 +1,74 @@ +{ config, lib, pkg, ... }: +with lib; +let + cfg = config.services.certificate-updater; +in +{ + options.services.certificate-updater = { + enable = mkEnableOption "certificate-updater"; + + role = mkOption { + description = "Vault role to create certificate for"; + type = types.str; + }; + + commonName = mkOption { + description = "Common name to create the certificate for"; + type = types.str; + }; + + ipAddress = mkOption { + description = "IP Address to include in SAN"; + type = types.str; + }; + + outputDirectory = mkOption { + description = "Directory to write certificates out to"; + type = types.path; + }; + + mount = mkOption { + description = "Mount point of the CA to create certificate from"; + type = types.str; + }; + + environmentFile = mkOption { + description = "Path to environment file containing VAULT_TOKEN"; + type = types.path; + }; + + group = mkOption { + description = "Group to write files as"; + type = types.str; + }; + + host = mkOption { + default = "http://vault.home"; + description = "URL to access Vault"; + type = types.str; + }; + }; + + config = mkIf cfg.enable { + systemd.timers.certificate-updater = { + enable = true; + wantedBy = [ "timers.target" ]; + after = [ "network-online.target" ]; + timerConfig = { + OnBootSec = "0s"; + OnCalendar = "*:0/5"; + RandomizedDelaySec = "120"; + Unit = "certificate-updater.service"; + }; + }; + + systemd.services.certificate-updater = { + serviceConfig = { + Type = "oneshot"; + EnvironmentFile = "${cfg.environmentFile}"; + ExecStart = "${pkg}/bin/certificate-updater -r \"${cfg.role}\" -c \"${cfg.commonName}\" -i \"${cfg.ipAddress}\" -o \"${cfg.outputDirectory}\" -u \"${cfg.host}\" -m \"${cfg.mount}\""; + Group = cfg.group; + }; + }; + }; +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..4d0eb8b --- /dev/null +++ b/shell.nix @@ -0,0 +1,4 @@ +{ pkgs ? import { }, ... }: +pkgs.mkShell { + inputsFrom = [ (pkgs.haskellPackages.callCabal2nix "certificate-updater" ./. { }).env ]; +} diff --git a/src/Main.hs b/src/Main.hs new file mode 100644 index 0000000..58af1a0 --- /dev/null +++ b/src/Main.hs @@ -0,0 +1,223 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeOperators #-} + +module Main where + +import Data.Aeson +import qualified Data.ByteString.Char8 as B8 +import Data.List (intercalate) +import Data.Time.Clock.POSIX +import Data.Time.Format +import Network.HTTP.Simple +import Network.HTTP.Types.Header (hAuthorization) +import Options.Generic +import RIO +import qualified RIO.ByteString.Lazy as BL +import RIO.FilePath +import System.Environment +import System.IO.Error (isDoesNotExistError) +import System.Posix + +data Args w = Args + { role :: w ::: String "Vault role to assume" <#> "r", + commonName :: w ::: String "Common name to create certificate for" <#> "c", + ipAddress :: w ::: String "IP Address to associate with the certificate" <#> "i", + outputDirectory :: w ::: FilePath "Directory to write certificate and key to" <#> "o", + url :: w ::: String "Base URL of Vault" <#> "u", + mount :: w ::: String "Mount point of the CA to create certificate from" <#> "m" + } + deriving (Generic) + +instance ParseRecord (Args Wrapped) where + parseRecord = parseRecordWithModifiers lispCaseModifiers + +data Metadata = Metadata + { expiration :: POSIXTime, + generated :: POSIXTime + } + deriving (Generic, Show) + +instance ToJSON Metadata where + toEncoding = genericToEncoding defaultOptions + +instance FromJSON Metadata + +data AppEnv = AppEnv + { appLogger :: !LogFunc, + args :: Args Unwrapped + } + +instance HasLogFunc AppEnv where + logFuncL = lens appLogger (\x y -> x {appLogger = y}) + +main :: IO () +main = do + logOptions <- logOptionsHandle stderr False + withLogFunc logOptions $ \logFunc -> do + appArgs <- unwrapRecord "certificate-updater" + runRIO AppEnv {appLogger = logFunc, args = appArgs} createCertificate + +createCertificate :: RIO AppEnv () +createCertificate = do + refreshNeeded <- certificateRequiresRefresh + when refreshNeeded $ do + vaultToken <- liftIO $ lookupEnv "VAULT_TOKEN" + let (clientForRenew, clientForGenerate) = setupVaultClient (fromMaybe "" vaultToken) + vaultRenewSelf clientForRenew + generateAndSaveCertificate clientForGenerate + +generateAndSaveCertificate :: (String -> VaultIssueCertificate -> RIO AppEnv VaultIssueCertificateResponse) -> RIO AppEnv () +generateAndSaveCertificate clientForGenerate = do + out <- vaultGenerateCertificate clientForGenerate + metadata <- buildNewMetadata (certExpiration out) + outDir <- asks (outputDirectory . args) + liftIO $ do + _ <- setFileCreationMask 0o007 + writeFile (outDir "ca-chain.crt") (intercalate "\n" (caChain out)) + writeFile (outDir "cert.crt") (certificate out) + writeFile (outDir "key.pem") (privateKey out) + BL.writeFile (outDir "metadata") (encode metadata) + logSuccessfulRegeneratedCertificate out + +buildNewMetadata :: POSIXTime -> RIO AppEnv Metadata +buildNewMetadata expr = do + gen <- liftIO getPOSIXTime + return Metadata {expiration = expr, generated = gen} + +setupVaultClient :: (ToJSON a, FromJSON b, ToJSON c, FromJSON d) => String -> (String -> a -> RIO AppEnv b, String -> c -> RIO AppEnv d) +setupVaultClient vaultToken = + let clientForRenew = vaultHttpRequest vaultToken + clientForGenerate = vaultHttpRequest vaultToken + in (clientForRenew, clientForGenerate) + +vaultHttpRequest :: (ToJSON a, FromJSON b) => String -> String -> a -> RIO AppEnv b +vaultHttpRequest token endpoint payload = do + base <- asks (url . args) + let finalUrl = base <> "/v1/" <> endpoint + logInfo $ fromString $ "Sending request to " <> finalUrl + let request = + setRequestBodyLBS (encode payload) $ + setRequestHeaders headers $ + setRequestMethod "POST" $ + parseRequestThrow_ finalUrl + response <- httpJSON request + unless (getResponseStatusCode response == 200) $ do + throwString "Got non-200 response from Vault" + return (getResponseBody response) + where + headers = [(hAuthorization, B8.pack $ "Bearer " ++ token), ("Content-Type", "application/json")] + +vaultRenewSelf :: (String -> Value -> RIO AppEnv ()) -> RIO AppEnv () +vaultRenewSelf client = do + client "auth/token/renew-self" (object []) + logInfo "Successfully renewed Vault token" + +data VaultIssueCertificate = VaultIssueCertificate + { common_name :: String, + ip_sans :: String + } + deriving (Generic, Show) + +instance ToJSON VaultIssueCertificate where + toEncoding = genericToEncoding defaultOptions + +newtype VaultIssueCertificateResponse = VaultIssueCertificateResponse {data_ :: VaultIssueCertificateResponseData} deriving (Generic, Show) + +instance FromJSON VaultIssueCertificateResponse where + parseJSON = withObject "VaultIssueCertificateResponse" $ \v -> + VaultIssueCertificateResponse <$> v .: "data" + +data VaultIssueCertificateResponseData = VaultIssueCertificateResponseData + { caChain :: [String], + certificate :: String, + privateKey :: String, + serialNumber :: String, + privateKeyType :: String, + certExpiration :: POSIXTime + } + deriving (Generic, Show) + +instance FromJSON VaultIssueCertificateResponseData where + parseJSON = withObject "VaultIssueCertificateResponseData" $ \v -> + VaultIssueCertificateResponseData + <$> v .: "ca_chain" + <*> v .: "certificate" + <*> v .: "private_key" + <*> v .: "serial_number" + <*> v .: "private_key_type" + <*> v .: "expiration" + +vaultGenerateCertificate :: (String -> VaultIssueCertificate -> RIO AppEnv VaultIssueCertificateResponse) -> RIO AppEnv VaultIssueCertificateResponseData +vaultGenerateCertificate client = do + payload <- + VaultIssueCertificate + <$> asks (commonName . args) + <*> asks (ipAddress . args) + roleArg <- asks (role . args) + mountPoint <- asks (mount . args) + client (mountPoint <> "/issue/" <> roleArg) payload <&> data_ + +certificateRequiresRefresh :: RIO AppEnv Bool +certificateRequiresRefresh = do + path <- asks (outputDirectory . args) + result <- fetchCurrentMetadata path + currentTime <- liftIO getPOSIXTime + case result of + Just metadata + | hasPassedSafeRefreshInterval currentTime metadata -> logPassedTtlMessage currentTime metadata >> return True + | otherwise -> logNotYetReadyMessage currentTime metadata >> return False + Nothing -> do + logInfo "Metadata does not exist, assuming this is our first run, continuing" + return True + +fetchCurrentMetadata :: FilePath -> RIO AppEnv (Maybe Metadata) +fetchCurrentMetadata path = do + result <- try $ liftIO $ BL.readFile (path "metadata") + case result of + Left e + | isDoesNotExistError e -> return Nothing + | otherwise -> throwIO e + Right content -> case decode content of + Just metadata -> return (Just metadata) + Nothing -> throwString "Failed to decode metadata" + +hasPassedSafeRefreshInterval :: POSIXTime -> Metadata -> Bool +hasPassedSafeRefreshInterval currentTime (Metadata expr gen) = + let expires = expr - currentTime + totalTtl = expr - gen + in expr < gen || (totalTtl - expires) / totalTtl >= 0.75 + +logPassedTtlMessage :: POSIXTime -> Metadata -> RIO AppEnv () +logPassedTtlMessage currentTime (Metadata expr _) = do + logInfo $ + display $ + "More than 3/4s through certificate TTL (" + <> tshow (round (expr - currentTime) :: Integer) + <> " seconds remaining), continuing" + +logNotYetReadyMessage :: POSIXTime -> Metadata -> RIO AppEnv () +logNotYetReadyMessage currentTime (Metadata expr gen) = do + logWarn $ + display $ + "Not yet reached threshold for regeneration, " + <> tshow (round (expr - currentTime) :: Integer) + <> " seconds left of TTL " + <> tshow (round (expr - gen) :: Integer) + <> " seconds. " + <> tshow (round ((expr - currentTime) - ((expr - gen) * 0.25)) :: Integer) + <> " seconds until threshold is reached" + +logSuccessfulRegeneratedCertificate :: VaultIssueCertificateResponseData -> RIO AppEnv () +logSuccessfulRegeneratedCertificate d = do + logInfo $ + fromString $ + "Successfully regenerated " + <> privateKeyType d + <> " certificate, new serial number is " + <> serialNumber d + <> " expiring at " + <> formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S %Z" (posixSecondsToUTCTime (certExpiration d)) -- libgit2 1.7.2