🏡 index : ~doyle/certificate-updater.git

author Jordan Doyle <jordan@doyle.la> 2023-08-30 23:44:32.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2023-08-31 0:53:02.0 +00:00:00
commit
50b377ecc52054bda9c5c5a0c8d9e2116ce89e1f [patch]
tree
9855045586cf592ea01a9bfe8fb05dd52ba5ef7c
download
50b377ecc52054bda9c5c5a0c8d9e2116ce89e1f.tar.gz

Initial commit



Diff

 .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(+)

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 <sam@hocevar.net>

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 <nixpkgs> { } }:
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 <nixpkgs> { }, ... }:
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))