diff --git a/.config/.pre-commit-config.yaml b/.config/.pre-commit-config.yaml new file mode 100644 index 0000000..b6dfba6 --- /dev/null +++ b/.config/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: fix-byte-order-marker + - id: check-added-large-files + args: + - "--maxkb=20" + - id: check-case-conflict + - id: check-yaml + - id: check-json + - id: end-of-file-fixer + - id: trailing-whitespace + - id: mixed-line-ending + - id: check-merge-conflict + - id: check-shebang-scripts-are-executable + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.6.0 + hooks: + - id: prettier diff --git a/.config/cspell.json b/.config/cspell.json new file mode 100644 index 0000000..b21dd4d --- /dev/null +++ b/.config/cspell.json @@ -0,0 +1,54 @@ +{ + "version": "0.2", + "language": "en", + "words": [ + "aesgcm", + "ciphertext", + "cpus", + "dockerhub", + "Hapag", + "Infracost", + "oidc", + "pytest", + "Repology", + "tflint", + "tfsec", + "virtualenv" + ], + "ignoreWords": [ + "AKIA", + "amannn", + "aquasecurity", + "automerge", + "automerged", + "boto", + "buildcache", + "buildx", + "codeowners", + "codeql", + "conventionalcommits", + "datasource", + "dorny", + "hadolint", + "hlag", + "hmarr", + "ibiqlik", + "kayman", + "ludeeus", + "markdownlint", + "maxsplit", + "mktemp", + "ossrh", + "pascalfrenz", + "rhysd", + "ruleset", + "sarif", + "shellcheck", + "shuf", + "shunsuke", + "signoff", + "stty", + "venv", + "vuln" + ] +} diff --git a/.config/markdownlint.yml b/.config/markdownlint.yml new file mode 100644 index 0000000..5ed51f8 --- /dev/null +++ b/.config/markdownlint.yml @@ -0,0 +1,12 @@ +--- +# Default state for all rules +default: true + +# MD013/line-length - Line length +MD013: + # Number of characters + line_length: 132 + # Number of characters for headings + heading_line_length: 132 + # Number of characters for code blocks + code_block_line_length: 132 diff --git a/.config/yamllint.yml b/.config/yamllint.yml new file mode 100644 index 0000000..25e3962 --- /dev/null +++ b/.config/yamllint.yml @@ -0,0 +1,8 @@ +--- +extends: default + +rules: + line-length: + max: 132 + comments: + min-spaces-from-content: 1 # Renovate uses 1 space only diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3c570f1 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# maintainers of the project +* @kayman-mk @PascalFrenz \ No newline at end of file diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..70d4b2b --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +see CODEOWNERS. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..9ea8088 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Contribution guide + +We appreciate your thought to contribute to open source. :heart: We want to make contributing as easy as possible. You are welcome to: + +- Report a bug +- Discuss the current state of the code +- Submit a fix +- Propose new features + +We use [Github Flow](https://guides.github.com/introduction/flow/index.html), so all code changes happen through pull +requests. We actively welcome your pull requests: + +1. Fork the repo and create your branch from `main`. +2. If you've added code, check one of the examples. +3. Make sure your code lints. +4. Raise a pull request. + +## Documentation + +We use [pre-commit](https://pre-commit.com/) for some default checks which are fast and find the most common errors. + +## License + +By contributing, you agree that your contributions will be licensed under the license available at +[LICENSE](blob/main/LICENSE). \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..420e196 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Found a bug? Report it! +title: '' +labels: new, bug +assignees: '' +--- + + + +# Describe the bug + +A clear and concise description of what the bug is. + +# To Reproduce + +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +# Expected behavior + +A clear and concise description of what you expected to happen. + +# Additional context + +Add any other context about the \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..437c9db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,39 @@ +--- +name: Feature Request +about: Propose a new feature +title: '' +labels: new, enhancement +assignees: '' +--- + +# Describe the solution you'd like + + + +# Describe alternatives you've considered + + + +# Suggest a solution + + + +# Additional context + + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..123ebd0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ +# Description + +What is the overall goal of your PR? Which problem does it solve? Please also include relevant motivation and context. +List any dependencies that are required for this change. + +Fixes #(issue number) + +# Migrations required + +yes: please describe the migration +no: please delete the whole paragraph + +# Verification + +This paragraph is usually not needed if automated tests are in place for the change. + +Please describe the test cases you used to verify your code. Did you check the change in your environment? + +# Checklist + +- [ ] My code follows the style guidelines of the project +- [ ] I have performed a self-review of my own code +- [ ] I have made corresponding changes to the documentation diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 0000000..422c856 --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,5 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + + "extends": ["github>Hapag-Lloyd/Renovate-Global-Configuration"] +} diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..af04577 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,11 @@ +--- +name: Lint files + +# yamllint disable-line rule:truthy +on: + pull_request: + +jobs: + default: + uses: Hapag-Lloyd/Repository-Templates/.github/workflows/default_linter_callable.yml@13628b3a168ca0096a52ecf93fa1a92295580a9f + secrets: inherit diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..35a7dbc --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,18 @@ +--- +name: "Pull Request" + +# yamllint disable-line rule:truthy +on: + pull_request_target: + types: + - opened + - edited + - synchronize + branches-ignore: + - "release-please--branches--*" + +jobs: + default: + # yamllint disable-line rule:line-length + uses: Hapag-Lloyd/Repository-Templates/.github/workflows/default_pull_request_callable.yml@13628b3a168ca0096a52ecf93fa1a92295580a9f + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..02384af --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,13 @@ +--- +name: Create a release + +# yamllint disable-line rule:truthy +on: + push: + branches: + - main + +jobs: + default: + uses: Hapag-Lloyd/Repository-Templates/.github/workflows/default_release_callable.yml@13628b3a168ca0096a52ecf93fa1a92295580a9f + secrets: inherit diff --git a/.github/workflows/release_dry_run.yml b/.github/workflows/release_dry_run.yml new file mode 100644 index 0000000..c627ccf --- /dev/null +++ b/.github/workflows/release_dry_run.yml @@ -0,0 +1,14 @@ +--- +name: Try a release + +# yamllint disable-line rule:truthy +on: + push: + branches: + - release-dry-run + +jobs: + default: + # yamllint disable-line rule:line-length + uses: Hapag-Lloyd/Repository-Templates/.github/workflows/default_release_dry_run_callable.yml@13628b3a168ca0096a52ecf93fa1a92295580a9f + secrets: inherit diff --git a/.github/workflows/renovate_auto_approve.yml b/.github/workflows/renovate_auto_approve.yml new file mode 100644 index 0000000..7c872ac --- /dev/null +++ b/.github/workflows/renovate_auto_approve.yml @@ -0,0 +1,11 @@ +--- +name: Approve all Renovate PRs automatically + +# yamllint disable-line rule:truthy +on: pull_request_target + +jobs: + default: + # yamllint disable-line rule:line-length + uses: Hapag-Lloyd/Repository-Templates/.github/workflows/default_renovate_auto_approve_callable.yml@13628b3a168ca0096a52ecf93fa1a92295580a9f + secrets: inherit diff --git a/.github/workflows/slash_ops_command_help.yml b/.github/workflows/slash_ops_command_help.yml new file mode 100644 index 0000000..9a17b33 --- /dev/null +++ b/.github/workflows/slash_ops_command_help.yml @@ -0,0 +1,14 @@ +--- +name: Execute ChatOps command + +# yamllint disable-line rule:truthy +on: + repository_dispatch: + types: + - help-command + +jobs: + default: + # yamllint disable-line rule:line-length + uses: Hapag-Lloyd/Repository-Templates/.github/workflows/default_slash_ops_command_help_callable.yml@13628b3a168ca0096a52ecf93fa1a92295580a9f + secrets: inherit diff --git a/.github/workflows/slash_ops_comment_dispatch.yml b/.github/workflows/slash_ops_comment_dispatch.yml new file mode 100644 index 0000000..a033ad4 --- /dev/null +++ b/.github/workflows/slash_ops_comment_dispatch.yml @@ -0,0 +1,14 @@ +--- +name: PR commented + +# yamllint disable-line rule:truthy +on: + issue_comment: + types: + - created + +jobs: + default: + # yamllint disable-line rule:line-length + uses: Hapag-Lloyd/Repository-Templates/.github/workflows/default_slash_ops_comment_dispatch_callable.yml@13628b3a168ca0096a52ecf93fa1a92295580a9f + secrets: inherit diff --git a/.github/workflows/spelling.yml b/.github/workflows/spelling.yml new file mode 100644 index 0000000..976a1d8 --- /dev/null +++ b/.github/workflows/spelling.yml @@ -0,0 +1,11 @@ +--- +name: "Check spelling" + +# yamllint disable-line rule:truthy +on: + pull_request: + +jobs: + default: + uses: Hapag-Lloyd/Repository-Templates/.github/workflows/default_spelling_callable.yml@13628b3a168ca0096a52ecf93fa1a92295580a9f + secrets: inherit diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..fda577d --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,12 @@ +--- +name: "Close stale issues and PRs" + +# yamllint disable-line rule:truthy +on: + schedule: + - cron: "25 2 * * *" + +jobs: + default: + uses: Hapag-Lloyd/Repository-Templates/.github/workflows/default_stale_callable.yml@13628b3a168ca0096a52ecf93fa1a92295580a9f + secrets: inherit diff --git a/.github/workflows/welcome_message.yml b/.github/workflows/welcome_message.yml new file mode 100644 index 0000000..1f65d95 --- /dev/null +++ b/.github/workflows/welcome_message.yml @@ -0,0 +1,14 @@ +--- +name: PR opened + +# yamllint disable-line rule:truthy +on: + pull_request_target: + types: + - opened + +jobs: + default: + # yamllint disable-line rule:line-length + uses: Hapag-Lloyd/Repository-Templates/.github/workflows/default_welcome_message_callable.yml@13628b3a168ca0096a52ecf93fa1a92295580a9f + secrets: inherit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45e9d2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# IntelliJ +.idea/ + +# Python +venv/ diff --git a/README.md b/README.md index e286234..e1a78dd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,52 @@ # rackspace-aws-login -Shell/Python script to fetch credentials for AWS accounts from Rackspace, storing them in AWS profiles. + +Shell and Python scripts to fetch credentials for AWS accounts from Rackspace, storing them in AWS profiles. + +Main features: + +- fetch AWS credentials from Rackspace +- cache the Rackspace login cookies (encrypted) to speed up subsequent logins +- fetch new credentials only if old ones are expired or not present +- use AWS profiles via `aws configure` +- flexible account setup on user level + +## Installation + +The minimum requirements are: Python, virtualenv and the AWS CLI. The script expects a `venv` in the same directory. Execute +`install_requirements.sh` to install all other dependencies. + +## Usage + +Set up your AWS accounts in `~/.config/aws_accounts.json`. The account name is used as the AWS profile name. + +```json +{ + "aws_accounts": [ + { + "number": "123456789012", + "name": "QA" + } + ] +} +``` + +Execute the login script with the account name as argument or without to list all available accounts. + +```bash +# place this in your .bash_profile +source ./aws_login.sh + +# log into a AWS account 123456789012 +aws_login 123456789012 + +# AWS_PROFILE is now set +export | grep AWS_ + +# list all S3 buckets in the account +aws s3 ls +``` + +## Contributing + +Please read [CONTRIBUTING.md](.github/CONTRIBUTING.md) and [code of conduct](.github/CODE_OF_CONDUCT.md) for details on our, and +the process for submitting pull requests. diff --git a/aws_accounts.json b/aws_accounts.json new file mode 100644 index 0000000..b74c78f --- /dev/null +++ b/aws_accounts.json @@ -0,0 +1,8 @@ +{ + "aws_accounts": [ + { + "number": "123456789012", + "name": "Dummy" + } + ] +} diff --git a/aws_login.sh b/aws_login.sh new file mode 100644 index 0000000..f17be9a --- /dev/null +++ b/aws_login.sh @@ -0,0 +1,73 @@ +# shellcheck shell=bash + +# +# Determines the AWS credentials for a specific account and exports them to the environment. +# In case a Rackspace login is needed, enter the credentials in the browser window that opens. The cookies +# from Rackspace are stored in a temporary file (encrypted with your Rackspace password) and used +# to avoid the login screen in the future. +# +# usage: source aws_login.sh +# aws_login [aws_account_id] +# +function aws_login() { + local script_dir + script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + + # $1: optional, AWS account ID to connect to + local aws_account_id="${1:-}" + + if [ "$(uname)" == "Darwin" ]; then + # external file + # shellcheck source=/dev/null + source "$script_dir/venv/bin/activate" + else + # external file + # shellcheck source=/dev/null + source "$script_dir/venv/Scripts/activate" + fi + + # credentials are stored in a file as we can't use a sub-shell (stdout is shown too late in the terminal) + exit_state=0 + temp_file="$script_dir/aws_temp" + touch "$temp_file" + + "$script_dir"/get_aws_credentials.py "$temp_file" "$aws_account_id" || exit_state=$? + + deactivate + + aws_credentials_as_json=$(cat "$temp_file") + + rm -f "$temp_file" + + # exit state = 2 --> credentials already present + if [ $exit_state -eq 2 ]; then + read -r profile_name < <(echo "$aws_credentials_as_json" | jq -r '.aws_profile_name' | tr -d '\r\n') + + echo "Switching the AWS_PROFILE to $profile_name" + export AWS_PROFILE="$profile_name" + + return 0 + fi + + if [ $exit_state -ne 0 ]; then + echo "Failed to get the AWS credentials" + + return 1 + else + # SC2005: for some reason we need to "echo" here, otherwise the variables are not set + # SC2046: we need to split the variables here + # shellcheck disable=SC2005,SC2046 + read -r access_key secret_key session_token profile_name < \ + <(echo $(echo "$aws_credentials_as_json" | jq -r '.aws_access_key_id, .aws_secret_access_key, .aws_session_token, .aws_profile_name')) + + echo "Switching the AWS_PROFILE to $profile_name and setting the credentials" + + aws configure --profile "$profile_name" set aws_access_key_id "$(echo "$access_key" | tr -d '\r\n')" + aws configure --profile "$profile_name" set aws_secret_access_key "$(echo "$secret_key" | tr -d '\r\n')" + aws configure --profile "$profile_name" set aws_session_token "$(echo "$session_token" | tr -d '\r\n')" + + export AWS_PROFILE="$profile_name" + + return 0 + fi +} diff --git a/get_aws_credentials.py b/get_aws_credentials.py new file mode 100755 index 0000000..f146cda --- /dev/null +++ b/get_aws_credentials.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python + +# +# Creates a menu to select an AWS account and fetches the AWS credentials from Rackspace. The credentials are +# printed as json to stdout. Format: {"aws_access_key_id": "AKIA...", "aws_secret_access_key": "...", +# "aws_session_token": "...", "aws_profile_name": "xyz"} +# +# $1 optional, AWS account id, skips the menu +# +# Exit states: +# 0 - success, credentials returned in file +# 1 - error, no credentials returned. Check output for details. +# 2 - already authenticated with AWS. Outputs the profile name as json. +# + +import boto3 +import json +import os +import secrets +import subprocess +import sys + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from pathlib import Path + +from playwright.sync_api import sync_playwright, Page, BrowserContext + +CONFIG_PATH = f"{os.getenv('HOME')}/.config/rackspace-aws-login" +COOKIE_FILE = f"{CONFIG_PATH}/rackspace_cookies.enc" +DEFAULT_AWS_ACCOUNTS_FILE = f"{CONFIG_PATH}/aws_accounts.json" +USER_AWS_ACCOUNTS_FILE = f"{CONFIG_PATH}/aws_accounts_user.json" + +RACKSPACE_AWS_ACCOUNT_URL_PREFIX = "https://manage.rackspace.com/aws/accounts" +RACKSPACE_TIMEOUT_MS = 60000 + + +def get_aws_account_info(preselected_account_number: str | None) -> dict[str, str] | None: + if Path(USER_AWS_ACCOUNTS_FILE).exists(): + aws_accounts = json.loads(Path(USER_AWS_ACCOUNTS_FILE).read_text().replace('\n', ''))['aws_accounts'] + else: + aws_accounts = json.loads(Path(DEFAULT_AWS_ACCOUNTS_FILE).read_text().replace('\n', ''))['aws_accounts'] + + if preselected_account_number: + for aws_account in aws_accounts: + if aws_account['number'] == preselected_account_number: + return aws_account + + print("AWS account not found.") + + return None + else: + for index, aws_account in enumerate(aws_accounts, start=1): + print(f"{index:2}. {aws_account['number']:12} - {aws_account['name']}") + + choice = input("Select an AWS account: ") + try: + choice = int(choice) + if 1 <= choice <= len(aws_accounts): + return aws_accounts[choice - 1] + else: + print("Invalid choice. Please enter a valid number.") + return get_aws_account_info(None) + except ValueError: + print("Invalid input. Please enter a number.") + return get_aws_account_info(None) + + +def get_password(prompt: str) -> str: + print(prompt, end="", flush=True) + + # don't echo the password + subprocess.check_call(["stty", "-echo"]) + password = input() + subprocess.check_call(["stty", "echo"]) + + # print a \n to move the cursor to the next line + print() + + return password + + +def generate_aes_key_from_password(password: str) -> bytes: + return PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, # 32 bytes for AES + salt=b"no-salt-at-all", + iterations=100000, + backend=default_backend() + ).derive(password.encode()) + + +def write_encrypted_file(filename: str, plaintext: str, password: str): + key = generate_aes_key_from_password(password) + nonce = secrets.token_bytes(12) + ciphertext = nonce + AESGCM(key).encrypt(nonce, plaintext.encode(), b"") + + Path(filename).write_bytes(ciphertext) + + +def decrypt_from_file(filename: str, password: str) -> str: + key = generate_aes_key_from_password(password) + ciphertext = Path(filename).read_bytes() + + try: + return AESGCM(key).decrypt(ciphertext[:12], ciphertext[12:], b"").decode() + except Exception: + # decryption failed (usually due to changed password). File is deleted, nothing returned + Path(filename).unlink() + return '[]' + + +def rackspace_login(password: str, aws_account_number: str, page: Page, context: BrowserContext): + # login to be done manually by user + page.bring_to_front() + + # pre-fill the password, we know it already + page.fill('input[id="password"]', password) + page.click('input[id="username"]') + + # wait for user input, disable the default timeout + page.wait_for_url(f"{RACKSPACE_AWS_ACCOUNT_URL_PREFIX}/{aws_account_number}", timeout=0) + + write_cookies_to_file(context, password) + + +def write_cookies_to_file(context: BrowserContext, password: str): + write_encrypted_file(COOKIE_FILE, json.dumps(context.cookies()), password) + + +def check_current_aws_credentials_valid_for_account(aws_account_number: str, profile_name: str) -> bool: + try: + # use the AWS profile for the client to avoid using the default profile + session = boto3.session.Session(profile_name=profile_name) + identity = session.client("sts").get_caller_identity() + + return identity['Account'] == aws_account_number + except Exception as e: + print(e) + return False + + +def convert_aws_credentials_to_json(aws_credentials_for_credential_file: str, profile_name: str) -> str: + # throw away the first line and last line (profile name and a comment) + aws_credentials = aws_credentials_for_credential_file.split("\n")[1:-1] + + json_return_value = {'aws_profile_name': profile_name} + + for line in aws_credentials: + key, value = line.split("=", maxsplit=1) + json_return_value[key] = value + + return json.dumps(json_return_value) + + +def write_result(filename: str, aws_credentials_json: str): + Path(filename).write_text(aws_credentials_json) + + +def get_rackspace_page(url: str, rackspace_password: str, aws_account_number: str, context: BrowserContext)\ + -> Page: + page = context.new_page() + page.bring_to_front() + + # restore previous Rackspace session to skip the login procedure and speed up the process + context.clear_cookies() + if Path(COOKIE_FILE).exists(): + context.add_cookies(json.loads(decrypt_from_file(COOKIE_FILE, rackspace_password))) + + page.goto(url) + + if page.title() == "Rackspace Login": + rackspace_login(rackspace_password, aws_account_number, page, context) + + return page + + +def main(output_filename: str, aws_account_number: str | None): + selected_aws_account = get_aws_account_info(aws_account_number) + + # exit if no account was selected + if selected_aws_account is None: + exit(1) + + aws_profile_name = selected_aws_account['name'] + aws_account_number = selected_aws_account['number'] + + print(f"Fetching AWS credentials for account {aws_account_number} ...", flush=True) + + if check_current_aws_credentials_valid_for_account(aws_account_number, aws_profile_name): + print("AWS credentials still valid. No need to fetch credentials from Rackspace.") + + write_result(output_filename, json.dumps({'aws_profile_name': aws_profile_name})) + exit(2) + + rackspace_password = get_password("Enter Rackspace password: ") + + print("Please wait, getting the credentials from Rackspace takes some time ...", flush=True) + + with sync_playwright() as p: + try: + browser = p.chromium.launch(headless=False) + context = browser.new_context() + context.set_default_timeout(RACKSPACE_TIMEOUT_MS) + + aws_account_page = get_rackspace_page(f"https://manage.rackspace.com/aws/accounts/{aws_account_number}", + rackspace_password, aws_account_number, context) + + aws_account_page.click("button:has-text(\"Generate Credentials\")") + + aws_account_page.wait_for_selector("a[id=\"temporary-credentials-tabs-tab-AWS_TAB\"]") + aws_account_page.click('a[id="temporary-credentials-tabs-tab-AWS_TAB"]') + + aws_credential_field_selector = "pre[class=\"ja-code aws-credentials\"]" + aws_credentials = aws_account_page.text_content(aws_credential_field_selector) + + write_result(output_filename, convert_aws_credentials_to_json(aws_credentials, aws_profile_name)) + + # update the cookies in case they have changed + write_cookies_to_file(context, rackspace_password) + except Exception as e: + print(e) + exit(1) + + +if __name__ == "__main__": + aws_credentials_file = sys.argv[1] + aws_account_no = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2].isdigit() else None + + main(aws_credentials_file, aws_account_no) diff --git a/install_requirements.sh b/install_requirements.sh new file mode 100755 index 0000000..a5c40a4 --- /dev/null +++ b/install_requirements.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +function find_python_executable() { + if command -v python3 &> /dev/null; then + echo "python3" + elif command -v python &> /dev/null; then + echo "python" + else + echo "Python missing! Please install Python first." + exit 1 + fi +} + +# +# check requirements +# +if ! command -v pip &> /dev/null; then + echo "pip is not available. Please install pip first." + exit 1 +fi + +python_executable=$(find_python_executable) + +# +# install dependencies in a virtual environment +# +"$python_executable" -m venv venv + +if [ "$(uname)" == "Darwin" ]; then + # MacOS + + # external file + # shellcheck source=/dev/null + source venv/bin/activate +else + # external file + # shellcheck source=/dev/null + source venv/Scripts/activate +fi + +pip install --requirement requirements.txt + +playwright install + +deactivate + +# +# set up the configuration +# +mkdir -p ~/.config/rackspace-aws-login + +if [ -f ~/.config/rackspace-aws-login/aws_accounts.json ]; then + echo "aws_login.sh already exists in ~/.config/rackspace-aws-login." + exit 2 +else + cp aws_accounts.json ~/.config/rackspace-aws-login/ +fi diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..92786a7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +boto3==1.34.84 +cryptography==42.0.5 +pytest-playwright==0.4.4