Skip to content

Instantly share code, notes, and snippets.

@yuvipanda
Created January 7, 2020 23:30
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save yuvipanda/64756b1bdc34faf1f20175ae5ea0e98e to your computer and use it in GitHub Desktop.
Save yuvipanda/64756b1bdc34faf1f20175ae5ea0e98e to your computer and use it in GitHub Desktop.
Simple automatic HTTPS with certbot on kubernetes
#!/usr/bin/env python3
"""
Helper script to run certbot inside a kubernetes cluster.
certbot expects contents of /etc/letsencrypt to be persistent
across runs, so it knows when to renew certificates & when to leave
them alone. This is a problem in Kubernetes, since we try to avoid
having persistent disks unless we must.
This script saves / restores the contents of /etc/letsencrypt into
a Kubernetes secret object, thus letting certbot operate unchanged
without needing any persistent storage.
This script runs as a sidecar to an nginx container that has webroot
set to /usr/shared/nginx/html, and is shared with this container. This
lets us use the webroot challenge with certbot.
"""
import sys
import subprocess
import argparse
import time
import tarfile
import io
import base64
import logging
from kubernetes import client, config
def compress_dir(path):
"""
Compress directory at 'path' to a tar.gz & return it.
Paths stored in the tarball are relative to the base directory -
so /etc/letsencrypt/account/ is stored as account/
"""
compressed_stream = io.BytesIO()
with tarfile.open(fileobj=compressed_stream, mode='w:gz') as tf:
tf.add(path, arcname='.')
return compressed_stream.getvalue()
def update_secret(namespace, secret_name, key, value):
"""
Update a secret object's key with the value
"""
try:
config.load_kube_config()
except:
config.load_incluster_config()
v1 = client.CoreV1Api()
try:
secret = v1.read_namespaced_secret(namespace=namespace, name=secret_name)
except client.rest.ApiException as e:
if e.status == 404:
secret = client.V1Secret(
metadata=client.V1ObjectMeta(name=secret_name),
data={}
)
resp = v1.create_namespaced_secret(namespace=namespace, body=secret)
logging.info(f"Created secret {secret_name} since it does not exist")
else:
raise
# Value should be base64'd string
new_value = base64.standard_b64encode(value).decode()
if new_value != secret.data.get(key):
secret.data[key] = base64.standard_b64encode(value).decode()
v1.patch_namespaced_secret(namespace=namespace, name=secret_name, body=secret)
logging.info(f"Updated secret {secret_name} with new value for key {key}")
def get_secret_value(namespace, secret_name, key):
try:
config.load_kube_config()
except:
config.load_incluster_config()
v1 = client.CoreV1Api()
try:
secret = v1.read_namespaced_secret(namespace=namespace, name=secret_name)
except client.rest.ApiException as e:
if e.status == 404:
# Secret doesn't exist
return None
raise
return base64.standard_b64decode(secret.data[key])
def setup_logging():
"""
Set up root logger to log to stderr
"""
logging.basicConfig(format="%(asctime)s %(message)s", level=logging.INFO, stream=sys.stderr)
def main():
argparser = argparse.ArgumentParser()
argparser.add_argument(
'email',
help='Contact email to pass to letsencrypt'
)
argparser.add_argument(
'domains',
help='List of domains to get certificates for',
nargs='+'
)
argparser.add_argument(
'--namespace',
help='Namespace to operate in'
)
argparser.add_argument(
'--test-cert',
help='Get test certificates from the staging server',
action='store_true'
)
args = argparser.parse_args()
setup_logging()
if not args.namespace:
try:
with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace") as f:
args.namespace = f.read().strip()
except FileNotFoundError:
print("Can not determina namespace, must be explicitly set with --namespace", file=sys.stderr)
sys.exit(1)
current_dir = get_secret_value(args.namespace, 'secret', 'letsencrypt.tar.gz')
if current_dir:
with tarfile.open(fileobj=io.BytesIO(current_dir), mode='r:gz') as tf:
tf.extractall('/etc/letsencrypt')
certbot_args = [
'certbot',
'certonly', '--webroot', '-n', '--agree-tos',
'-m', args.email,
'-w', '/usr/share/nginx/html'
] + [f'-d={d}' for d in args.domains]
if args.test_cert:
certbot_args.append('--test-cert')
logging.info("Using Let's Encrypt Staging server")
while True:
logging.info(f"Calling certbot: {' '.join(certbot_args)}")
subprocess.check_call(certbot_args)
letsencrypt_dir = compress_dir('/etc/letsencrypt')
update_secret(args.namespace, 'secret', 'letsencrypt.tar.gz', letsencrypt_dir)
time.sleep(30)
if __name__ == '__main__':
main()
FROM python:3.7-buster
RUN pip install --no-cache certbot kubernetes
COPY autocert.py /usr/local/bin/autocert.py
apiVersion: apps/v1 # for versions before 1.9.0 use apps/v1beta2
kind: Deployment
metadata:
name: nginx-deployment
spec:
selector:
matchLabels:
app: nginx
replicas: 1 # tells deployment to run 2 pods matching the template
template:
metadata:
labels:
app: nginx
spec:
volumes:
- name: webroot
emptyDir: {}
- name: certificates
emptyDir: {}
initContainers:
- name: volume-mount-hack-why-god-still
image: busybox
command:
- /bin/sh
- -c
- chmod 0755 /usr/share/nginx/html /etc/letsencrypt
volumeMounts:
- name: webroot
mountPath: /usr/share/nginx/html
- name: certificates
mountPath: /etc/letsencrypt
containers:
- name: nginx
image: nginx:1.7.9
ports:
- containerPort: 80
volumeMounts:
- name: webroot
mountPath: /usr/share/nginx/html
- name: certificates
mountPath: /etc/letsencrypt
- name: certbot
image: autocert
imagePullPolicy: Never
command: ["/usr/local/bin/autocert.py"]
env:
# We need this to get logs immediately
- name: PYTHONUNBUFFERED
value: "True"
args:
- --test-cert
- yuvipanda@gmail.com
- 67af5ec6.ngrok.io
volumeMounts:
- name: webroot
mountPath: /usr/share/nginx/html
- name: certificates
mountPath: /etc/letsencrypt
---
apiVersion: v1
kind: Service
metadata:
name: nginx
labels:
app: nginx
spec:
type: LoadBalancer
ports:
- port: 80
protocol: TCP
selector:
app: nginx
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: default
name: autotls
rules:
- apiGroups: [""] # "" indicates the core API group
resources: ["secrets"]
verbs: ["get", "patch", "list", "create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: autotls
namespace: default
subjects:
- kind: ServiceAccount
name: default # Name is case sensitive
apiGroup:
roleRef:
kind: Role
name: autotls
apiGroup: rbac.authorization.k8s.io
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment