Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions .github/workflows/docker-build-push.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
name: Build and Push Docker Image to Docker Hub
on:
pull_request:
push:
branches: [ "main" ]
tags:
Expand All @@ -12,11 +13,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3


- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
with:
driver: docker-container

- name: Log in to Docker Hub
if: github.event_name == 'push'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
Expand All @@ -26,6 +33,9 @@ jobs:
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
platforms: linux/amd64,linux/arm64
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
# Pull requests must produce the required "build" check without
# publishing. Multi-arch builds cannot be loaded into the local
# Docker engine, so they are cached only until a main/tag push.
outputs: ${{ github.event_name == 'push' && 'type=registry' || 'type=cacheonly' }}
66 changes: 37 additions & 29 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,46 +1,54 @@
FROM ubuntu:20.04

ENV NVM_DIR /usr/local/nvm
ENV NVM_DIR=/usr/local/nvm

# Install all dependencies, NVM, GitHub CLI, and other tools in a single RUN command
# Install dependencies, AWS CLI, kubectl, Helm, tfenv, NVM, and GitHub CLI.
# Node itself is installed at runtime by script.sh when NODE_VERSION is set.
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
git \
jq \
unzip \
curl \
wget \
tar \
openssl \
python3 \
python3-pip \
&& curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \
git \
jq \
unzip \
curl \
wget \
tar \
openssl \
python3 \
python3-pip \
&& arch=$(dpkg --print-architecture) \
&& case "$arch" in \
amd64) aws_cli_url="https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip"; kubectl_arch="amd64" ;; \
arm64) aws_cli_url="https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip"; kubectl_arch="arm64" ;; \
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
esac \
&& curl -fsSL "$aws_cli_url" -o awscliv2.zip \
&& unzip awscliv2.zip \
&& ./aws/install \
&& curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \
&& curl -fsSL -o kubectl "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/${kubectl_arch}/kubectl" \
&& install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl \
&& curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 \
&& chmod 700 get_helm.sh \
&& ./get_helm.sh \
&& git clone https://github.com/tfutils/tfenv.git ~/.tfenv \
&& git clone https://github.com/tfutils/tfenv.git /root/.tfenv \
&& pip3 install urllib3==1.26.7 print-env \
&& mkdir -p $NVM_DIR \
&& curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash \
&& . $NVM_DIR/nvm.sh \
# Install GitHub CLI
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt update \
&& apt install gh \
# Clean up
&& mkdir -p "$NVM_DIR" \
&& curl -fsSL -o /tmp/nvm-install.sh https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh \
&& bash /tmp/nvm-install.sh \
&& rm /tmp/nvm-install.sh \
&& curl -fsSL -o /usr/share/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y gh \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /awscliv2.zip
&& rm -rf /var/lib/apt/lists/* awscliv2.zip aws kubectl get_helm.sh

# Set environment path for tfenv and NVM
ENV PATH="/root/.tfenv/bin:$NVM_DIR/versions/node/$(nvm version)/bin:$PATH"
# tfenv is a real binary path. nvm is a shell function loaded by script.sh,
# so a node version directory cannot be added here.
ENV PATH="/root/.tfenv/bin:$PATH"

# Create and set the working directory
WORKDIR /work

# Copy the script into the container
COPY ./script.sh /
RUN chmod u+x /script.sh
COPY ./mfa.sh /usr/local/bin/mfa.sh
COPY ./aws-role-credentials /usr/local/bin/aws-role-credentials
RUN chmod u+x /script.sh /usr/local/bin/mfa.sh /usr/local/bin/aws-role-credentials \
&& printf '\nmfa() { source /usr/local/bin/mfa.sh "$@"; }\n' >> /root/.bashrc
18 changes: 18 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,21 @@ Notes:
```bash
dev
```

## AWS role

If the Portunus project has `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_ROLE_TO_ASSUME`, the container assumes that role before the shell opens and prints the role and session expiry. The IAM user keys are not put in the environment or the default AWS profile. Commands use the assumed role, and if that role cannot be assumed the command fails instead of running as the IAM user. The AWS CLI assumes the role again after the session expires.

`dev` with no project does not load Portunus, so that container does not assume a role. Keys without `AWS_ROLE_TO_ASSUME` still configure the default profile as the IAM user.

## AWS MFA

Inside the container, after AWS credentials are configured:

```bash
mfa 123456
```

`123456` is the code from your authenticator app. That command sources `/usr/local/bin/mfa.sh`, writes a session token to `~/.aws/credentials`, and exports `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` in the current shell.

`source ./mfa.sh` does not work here. `dev` mounts the project on `/work`, so a script copied into the project is hidden or left behind in that repository. `mfa` stays in the image, outside that mount.
57 changes: 57 additions & 0 deletions aws-role-credentials
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# credential_process for the default AWS profile.
# Prints assumed-role credentials, or exits non-zero. It never prints the IAM user keys.
set -euo pipefail

key_file="${HOME}/.aws/role-source.json"
if [ ! -f "$key_file" ]; then
echo "Missing ${key_file}" >&2
exit 1
fi

role_arn=$(jq -er '.RoleArn // empty' "$key_file")
access_key_id=$(jq -er '.AccessKeyId // empty' "$key_file")
secret_access_key=$(jq -er '.SecretAccessKey // empty' "$key_file")
session_token=$(jq -r '.SessionToken // empty' "$key_file")
if [ -z "$role_arn" ] || [ -z "$access_key_id" ] || [ -z "$secret_access_key" ]; then
echo "Role source file is incomplete" >&2
exit 1
fi

# The user keys are passed only to this AssumeRole call. Clearing the config
# and profile variables stops this aws process from calling itself.
sts_json=$(
unset AWS_PROFILE AWS_DEFAULT_PROFILE AWS_CONFIG_FILE AWS_SHARED_CREDENTIALS_FILE \
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI AWS_CONTAINER_CREDENTIALS_FULL_URI \
AWS_WEB_IDENTITY_TOKEN_FILE AWS_ROLE_ARN
export AWS_ACCESS_KEY_ID="$access_key_id"
export AWS_SECRET_ACCESS_KEY="$secret_access_key"
if [ -n "$session_token" ]; then
export AWS_SESSION_TOKEN="$session_token"
else
unset AWS_SESSION_TOKEN
fi
aws sts assume-role \
--role-arn "$role_arn" \
--role-session-name docker-dev-env \
--output json
)

expiry=$(jq -er '.Credentials.Expiration // empty' <<<"$sts_json")
access_key_out=$(jq -er '.Credentials.AccessKeyId // empty' <<<"$sts_json")
secret_out=$(jq -er '.Credentials.SecretAccessKey // empty' <<<"$sts_json")
token_out=$(jq -er '.Credentials.SessionToken // empty' <<<"$sts_json")
if [ -z "$expiry" ] || [ -z "$access_key_out" ] || [ -z "$secret_out" ] || [ -z "$token_out" ]; then
echo "AssumeRole response was incomplete" >&2
exit 1
fi

printf '%s\n' "$expiry" > "${HOME}/.aws/role-expiration"
chmod 600 "${HOME}/.aws/role-expiration"

jq -nc \
--arg AccessKeyId "$access_key_out" \
--arg SecretAccessKey "$secret_out" \
--arg SessionToken "$token_out" \
--arg Expiration "$expiry" \
'{Version: 1, AccessKeyId: $AccessKeyId, SecretAccessKey: $SecretAccessKey, SessionToken: $SessionToken, Expiration: $Expiration}'
182 changes: 182 additions & 0 deletions mfa.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# Temporary AWS credentials from an MFA code.
#
# Source this file so the new credentials stay in the current shell.
# The container shell provides `mfa` for that:
# mfa 123456
# source /usr/local/bin/mfa.sh 123456
#
# Running this file as a program cannot update the shell you are already in.

if [ -z "${1:-}" ]; then
echo "Usage: mfa <MFA-TOKEN>" >&2
return 1 2>/dev/null || exit 1
fi

session_duration=129600 # 36 hours, the STS maximum for GetSessionToken
mfa_code=$1
aws_dir="${HOME}/.aws"
aws_creds_file="${aws_dir}/credentials"
orig_creds_file="${aws_dir}/origcreds"
tmp_creds_file="${aws_dir}/tempcreds"
role_source_file="${aws_dir}/role-source.json"
role_source_orig_file="${aws_dir}/role-source-orig.json"

# A configured role must stay the only identity used for commands. MFA refreshes
# the keys that call AssumeRole and does not export the IAM user into this shell.
if [ -f "$role_source_file" ]; then
mkdir -p "$aws_dir"
if [ ! -f "$role_source_orig_file" ]; then
cp "$role_source_file" "$role_source_orig_file"
chmod 600 "$role_source_orig_file"
fi

role_arn=$(jq -r '.RoleArn // empty' "$role_source_orig_file")
base_access_key_id=$(jq -r '.AccessKeyId // empty' "$role_source_orig_file")
base_secret_access_key=$(jq -r '.SecretAccessKey // empty' "$role_source_orig_file")
base_session_token=$(jq -r '.SessionToken // empty' "$role_source_orig_file")
if [ -z "$role_arn" ] || [ -z "$base_access_key_id" ] || [ -z "$base_secret_access_key" ]; then
echo "Role source file is incomplete." >&2
return 1 2>/dev/null || exit 1
fi

run_as_user() {
unset AWS_PROFILE AWS_DEFAULT_PROFILE AWS_CONFIG_FILE AWS_SHARED_CREDENTIALS_FILE
export AWS_ACCESS_KEY_ID="$base_access_key_id"
export AWS_SECRET_ACCESS_KEY="$base_secret_access_key"
if [ -n "$base_session_token" ]; then
export AWS_SESSION_TOKEN="$base_session_token"
else
unset AWS_SESSION_TOKEN
fi
aws "$@"
}

mfa_device_code=$(run_as_user iam list-mfa-devices | jq -r '.MFADevices[0].SerialNumber // empty')
if [ -z "$mfa_device_code" ]; then
unset -f run_as_user
echo "Failed to retrieve an MFA device. Check that the long-lived IAM user keys are valid." >&2
return 1 2>/dev/null || exit 1
fi

echo "aws sts get-session-token --duration-seconds ${session_duration} --serial-number ${mfa_device_code} --token-code ${mfa_code}"
if ! (
run_as_user sts get-session-token \
--duration-seconds "$session_duration" \
--serial-number "$mfa_device_code" \
--token-code "$mfa_code" > "$tmp_creds_file"
); then
unset -f run_as_user
echo "Request failed" >&2
return 1 2>/dev/null || exit 1
fi
unset -f run_as_user

access_key_id=$(jq -r '.Credentials.AccessKeyId // empty' "$tmp_creds_file")
secret_access_key=$(jq -r '.Credentials.SecretAccessKey // empty' "$tmp_creds_file")
session_token=$(jq -r '.Credentials.SessionToken // empty' "$tmp_creds_file")
expiry=$(jq -r '.Credentials.Expiration // empty' "$tmp_creds_file")
rm -f "$tmp_creds_file"
if [ -z "$access_key_id" ] || [ -z "$secret_access_key" ] || [ -z "$session_token" ]; then
echo "Request failed" >&2
return 1 2>/dev/null || exit 1
fi

jq -n \
--arg RoleArn "$role_arn" \
--arg AccessKeyId "$access_key_id" \
--arg SecretAccessKey "$secret_access_key" \
--arg SessionToken "$session_token" \
'{RoleArn:$RoleArn, AccessKeyId:$AccessKeyId, SecretAccessKey:$SecretAccessKey, SessionToken:$SessionToken}' \
> "$role_source_file"
chmod 600 "$role_source_file"
rm -f "$aws_creds_file"
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN \
AWS_PROFILE AWS_DEFAULT_PROFILE AWS_SHARED_CREDENTIALS_FILE AWS_CONFIG_FILE

caller_arn=$(aws sts get-caller-identity --query Arn --output text) || {
echo "Failed to assume role ${role_arn} after MFA." >&2
return 1 2>/dev/null || exit 1
}
role_name=${role_arn##*/}
case "$caller_arn" in
arn:aws:sts::*:assumed-role/${role_name}/*) ;;
*)
echo "Refusing to use ${caller_arn}. Commands must run as assumed role ${role_arn}." >&2
return 1 2>/dev/null || exit 1
;;
esac

if expiry_local=$(date -d "$expiry" 2>/dev/null); then
echo "MFA session refreshed. Role ${role_arn} remains the identity used for commands. MFA expiry at: ${expiry_local}"
else
echo "MFA session refreshed. Role ${role_arn} remains the identity used for commands. MFA expiry at: ${expiry}"
fi
echo "Caller: ${caller_arn}"
return 0 2>/dev/null || exit 0
fi

mkdir -p "$aws_dir"

if [ ! -f "$aws_creds_file" ]; then
echo "AWS credentials not found at ${aws_creds_file}." >&2
echo "Long-lived keys have to be configured before requesting an MFA session." >&2
return 1 2>/dev/null || exit 1
fi

if [ ! -f "$orig_creds_file" ]; then
echo "Backing up current credentials to ${orig_creds_file}"
cp "$aws_creds_file" "$orig_creds_file"
chmod 600 "$orig_creds_file"
fi

# Always call STS with the long-lived keys, not a previous session token.
cp "$orig_creds_file" "$aws_creds_file"
chmod 600 "$aws_creds_file"

mfa_device_code=$(aws iam list-mfa-devices | jq -r '.MFADevices[0].SerialNumber // empty')
if [ -z "$mfa_device_code" ]; then
echo "Failed to retrieve an MFA device. Check that the AWS CLI is using the long-lived credentials." >&2
return 1 2>/dev/null || exit 1
fi

echo "aws sts get-session-token --duration-seconds ${session_duration} --serial-number ${mfa_device_code} --token-code ${mfa_code}"
if ! aws sts get-session-token \
--duration-seconds "$session_duration" \
--serial-number "$mfa_device_code" \
--token-code "$mfa_code" > "$tmp_creds_file"; then
echo "Request failed" >&2
return 1 2>/dev/null || exit 1
fi

access_key_id=$(jq -r '.Credentials.AccessKeyId // empty' "$tmp_creds_file")
secret_access_key=$(jq -r '.Credentials.SecretAccessKey // empty' "$tmp_creds_file")
session_token=$(jq -r '.Credentials.SessionToken // empty' "$tmp_creds_file")
expiry=$(jq -r '.Credentials.Expiration // empty' "$tmp_creds_file")

if [ -z "$access_key_id" ] || [ -z "$secret_access_key" ] || [ -z "$session_token" ]; then
echo "Request failed" >&2
return 1 2>/dev/null || exit 1
fi

cat > "$aws_creds_file" << EOF
[default]
aws_access_key_id = ${access_key_id}
aws_secret_access_key = ${secret_access_key}
aws_session_token = ${session_token}
EOF
chmod 600 "$aws_creds_file"
rm -f "$tmp_creds_file"

# Environment variables override the credentials file, including keys that
# Portunus already exported, so these have to be set in this shell.
export AWS_ACCESS_KEY_ID="$access_key_id"
export AWS_SECRET_ACCESS_KEY="$secret_access_key"
export AWS_SESSION_TOKEN="$session_token"

if expiry_local=$(date -d "$expiry" 2>/dev/null); then
echo "All set. Expiry at: ${expiry_local}"
else
echo "All set. Expiry at: ${expiry}"
fi
echo "Session credentials are exported and written to ${aws_creds_file}."
Loading
Loading