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(+)
@@ -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 .#
@@ -0,0 +1,2 @@
dist-newstyle
result
@@ -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.
@@ -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
};
}
```
@@ -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
@@ -0,0 +1,2 @@
{ pkgs ? import <nixpkgs> { } }:
pkgs.haskellPackages.callCabal2nix "certificate-updater" ./. { }
@@ -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
}
@@ -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;
};
});
}
@@ -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;
};
};
};
}
@@ -0,0 +1,4 @@
{ pkgs ? import <nixpkgs> { }, ... }:
pkgs.mkShell {
inputsFrom = [ (pkgs.haskellPackages.callCabal2nix "certificate-updater" ./. { }).env ];
}
@@ -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))