287 lines
9.9 KiB
Python
287 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import csv
|
|
import json
|
|
import logging
|
|
import requests
|
|
from base64 import b64decode
|
|
from copy import deepcopy
|
|
from flask import Flask, request, jsonify
|
|
from pathlib import Path
|
|
from subprocess import check_call, check_output, CalledProcessError, TimeoutExpired
|
|
from yaml import safe_load
|
|
app = Flask(__name__)
|
|
|
|
|
|
def kubectl(*args):
|
|
'''Run a kubectl cli command with a config file.
|
|
|
|
Returns stdout and throws an error if the command fails.
|
|
'''
|
|
# Try to use our service account kubeconfig; fall back to root if needed
|
|
kubectl_cmd = Path('/snap/bin/kubectl')
|
|
if not kubectl_cmd.is_file():
|
|
# Fall back to anywhere on the path if the snap isn't available
|
|
kubectl_cmd = 'kubectl'
|
|
kubeconfig = '/root/.kube/config'
|
|
command = [str(kubectl_cmd), '--kubeconfig={}'.format(kubeconfig)] + list(args)
|
|
return check_output(command, timeout=10)
|
|
|
|
|
|
def log_secret(text, obj, hide=True):
|
|
'''Log information about a TokenReview object.
|
|
|
|
The message will always be logged at the 'debug' level and will be in the
|
|
form "text: obj". By default, secrets will be hidden. Set 'hide=False' to
|
|
have the secret printed in the output unobfuscated.
|
|
'''
|
|
log_obj = obj
|
|
if obj and hide:
|
|
log_obj = deepcopy(obj)
|
|
try:
|
|
log_obj['spec']['token'] = '********'
|
|
except (KeyError, TypeError):
|
|
# No secret here, carry on
|
|
pass
|
|
app.logger.debug('{}: {}'.format(text, log_obj))
|
|
|
|
|
|
def check_token(token_review):
|
|
'''Populate user info if token is found in auth-related files.'''
|
|
app.logger.info('Checking token')
|
|
token_to_check = token_review['spec']['token']
|
|
|
|
# If we have an admin token, short-circuit all other checks. This prevents us
|
|
# from leaking our admin token to other authn services.
|
|
admin_kubeconfig = Path('/root/.kube/config')
|
|
if admin_kubeconfig.exists():
|
|
with admin_kubeconfig.open('r') as f:
|
|
data = safe_load(f)
|
|
try:
|
|
admin_token = data['users'][0]['user']['token']
|
|
except (KeyError, ValueError):
|
|
# No admin kubeconfig; this is weird since we should always have an
|
|
# admin kubeconfig, but we shouldn't fail here in case there's
|
|
# something in known_tokens that should be validated.
|
|
pass
|
|
else:
|
|
if token_to_check == admin_token:
|
|
# We have a valid admin
|
|
token_review['status'] = {
|
|
'authenticated': True,
|
|
'user': {
|
|
'username': 'admin',
|
|
'uid': 'admin',
|
|
'groups': ['system:masters']
|
|
}
|
|
}
|
|
return True
|
|
|
|
# No admin? We're probably in an upgrade. Check an existing known_tokens.csv.
|
|
csv_fields = ['token', 'username', 'user', 'groups']
|
|
known_tokens = Path('/root/cdk/known_tokens.csv')
|
|
try:
|
|
with known_tokens.open('r') as f:
|
|
data_by_token = {r['token']: r for r in csv.DictReader(f, csv_fields)}
|
|
except FileNotFoundError:
|
|
data_by_token = {}
|
|
|
|
if token_to_check in data_by_token:
|
|
record = data_by_token[token_to_check]
|
|
# groups are optional; default to an empty string if we don't have any
|
|
groups = record.get('groups', '').split(',')
|
|
token_review['status'] = {
|
|
'authenticated': True,
|
|
'user': {
|
|
'username': record['username'],
|
|
'uid': record['user'],
|
|
'groups': groups,
|
|
}
|
|
}
|
|
return True
|
|
return False
|
|
|
|
|
|
def check_secrets(token_review):
|
|
'''Populate user info if token is found in k8s secrets.'''
|
|
# Only check secrets if kube-apiserver is up
|
|
try:
|
|
output = check_call(['systemctl', 'is-active', 'snap.kube-apiserver.daemon'])
|
|
except CalledProcessError:
|
|
app.logger.info('Skipping secret check: kube-apiserver is not ready')
|
|
return False
|
|
else:
|
|
app.logger.info('Checking secret')
|
|
|
|
token_to_check = token_review['spec']['token']
|
|
try:
|
|
output = kubectl(
|
|
'get', 'secrets', '-n', 'kube-system', '-o', 'json').decode('UTF-8')
|
|
except (CalledProcessError, TimeoutExpired) as e:
|
|
app.logger.info('Unable to load secrets: {}.'.format(e))
|
|
return False
|
|
|
|
secrets = json.loads(output)
|
|
if 'items' in secrets:
|
|
for secret in secrets['items']:
|
|
try:
|
|
data_b64 = secret['data']
|
|
password_b64 = data_b64['password'].encode('UTF-8')
|
|
username_b64 = data_b64['username'].encode('UTF-8')
|
|
except (KeyError, TypeError):
|
|
# CK secrets will have populated 'data', but not all secrets do
|
|
continue
|
|
|
|
password = b64decode(password_b64).decode('UTF-8')
|
|
if token_to_check == password:
|
|
groups_b64 = data_b64['groups'].encode('UTF-8') \
|
|
if 'groups' in data_b64 else b''
|
|
|
|
# NB: CK creates k8s secrets with the 'password' field set as
|
|
# uid::token. Split the decoded password so we can send a 'uid' back.
|
|
# If there is no delimiter, set uid == username.
|
|
# TODO: make the delimeter less magical so it doesn't get out of
|
|
# sync with the function that creates secrets in k8s-master.py.
|
|
username = uid = b64decode(username_b64).decode('UTF-8')
|
|
pw_delim = '::'
|
|
if pw_delim in password:
|
|
uid = password.rsplit(pw_delim, 1)[0]
|
|
groups = b64decode(groups_b64).decode('UTF-8').split(',')
|
|
token_review['status'] = {
|
|
'authenticated': True,
|
|
'user': {
|
|
'username': username,
|
|
'uid': uid,
|
|
'groups': groups,
|
|
}
|
|
}
|
|
return True
|
|
return False
|
|
|
|
|
|
def check_aws_iam(token_review):
|
|
'''Check the request with an AWS IAM authn server.'''
|
|
app.logger.info('Checking AWS IAM')
|
|
|
|
# URL comes from /root/cdk/aws-iam-webhook.yaml
|
|
url = '{{ aws_iam_endpoint }}'
|
|
app.logger.debug('Forwarding to: {}'.format(url))
|
|
|
|
return forward_request(token_review, url)
|
|
|
|
|
|
def check_keystone(token_review):
|
|
'''Check the request with a Keystone authn server.'''
|
|
app.logger.info('Checking Keystone')
|
|
|
|
# URL comes from /root/cdk/keystone/webhook.yaml
|
|
url = '{{ keystone_endpoint }}'
|
|
app.logger.debug('Forwarding to: {}'.format(url))
|
|
|
|
return forward_request(token_review, url)
|
|
|
|
|
|
def check_custom(token_review):
|
|
'''Check the request with a user-specified authn server.'''
|
|
app.logger.info('Checking Custom Endpoint')
|
|
|
|
# User will set the URL in k8s-master config
|
|
url = '{{ custom_authn_endpoint }}'
|
|
app.logger.debug('Forwarding to: {}'.format(url))
|
|
|
|
return forward_request(token_review, url)
|
|
|
|
|
|
def forward_request(json_req, url):
|
|
'''Forward a JSON TokenReview request to a url.
|
|
|
|
Returns True if the request is authenticated; False if the response is
|
|
either invalid or authn has been denied.
|
|
'''
|
|
timeout = 10
|
|
try:
|
|
try:
|
|
r = requests.post(url, json=json_req, timeout=timeout)
|
|
except requests.exceptions.SSLError:
|
|
app.logger.debug('SSLError with server; skipping cert validation')
|
|
r = requests.post(url, json=json_req, verify=False, timeout=timeout)
|
|
except Exception as e:
|
|
app.logger.debug('Failed to contact server: {}'.format(e))
|
|
return False
|
|
|
|
# Check if the response is valid
|
|
try:
|
|
resp = json.loads(r.text)
|
|
'authenticated' in resp['status']
|
|
except (KeyError, TypeError, ValueError):
|
|
log_secret(text='Invalid response from server', obj=r.text)
|
|
return False
|
|
|
|
# NB: When a forwarded request is authenticated, set the 'status' field to
|
|
# whatever the external server sends us. This ensures any status fields that
|
|
# the server wants to send makes it back to the kube apiserver.
|
|
if resp['status']['authenticated']:
|
|
json_req['status'] = resp['status']
|
|
return True
|
|
return False
|
|
|
|
|
|
@app.route('/{{ api_ver }}', methods=['POST'])
|
|
def webhook():
|
|
'''Listen on /$api_version for POST requests.
|
|
|
|
For a POSTed TokenReview object, check every known authentication mechanism
|
|
for a user with a matching token.
|
|
|
|
The /$api_version is expected to be the api version of the authentication.k8s.io
|
|
TokenReview that the k8s-apiserver will be sending.
|
|
|
|
Returns:
|
|
TokenReview object with 'authenticated: True' and user attributes if a
|
|
token is found; otherwise, a TokenReview object with 'authenticated: False'
|
|
'''
|
|
# Log to gunicorn
|
|
glogger = logging.getLogger('gunicorn.error')
|
|
app.logger.handlers = glogger.handlers
|
|
app.logger.setLevel(glogger.level)
|
|
|
|
req = request.json
|
|
try:
|
|
valid = True if (req['kind'] == 'TokenReview' and
|
|
req['spec']['token']) else False
|
|
except (KeyError, TypeError):
|
|
valid = False
|
|
|
|
if valid:
|
|
log_secret(text='REQ', obj=req)
|
|
else:
|
|
log_secret(text='Invalid request', obj=req)
|
|
return '' # flask needs to return something that isn't None
|
|
|
|
# Make the request unauthenticated by deafult
|
|
req['status'] = {'authenticated': False}
|
|
|
|
if (
|
|
check_token(req)
|
|
or check_secrets(req)
|
|
{%- if aws_iam_endpoint %}
|
|
or check_aws_iam(req)
|
|
{%- endif %}
|
|
{%- if keystone_endpoint %}
|
|
or check_keystone(req)
|
|
{%- endif %}
|
|
{%- if custom_authn_endpoint %}
|
|
or check_custom(req)
|
|
{%- endif %}
|
|
):
|
|
# Successful checks will set auth and user data in the 'req' dict
|
|
log_secret(text='ACK', obj=req)
|
|
else:
|
|
log_secret(text='NAK', obj=req)
|
|
|
|
return jsonify(req)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
app.run()
|