diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cc68838..d6dabf0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,3 +15,8 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 0 # Only security updates, no regular version bumps + + - package-ecosystem: "docker" + directory: "/image" + schedule: + interval: "weekly" diff --git a/.github/workflows/image.yaml b/.github/workflows/image.yaml index a4500d2..f5b724b 100644 --- a/.github/workflows/image.yaml +++ b/.github/workflows/image.yaml @@ -7,6 +7,9 @@ on: - '.github/workflows/image.yaml' - 'client/src/farssh/const.py' - 'image/*' + schedule: + - cron: '17 4 * * 1' + workflow_dispatch: permissions: id-token: write @@ -20,17 +23,17 @@ jobs: steps: - name: Git clone the repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 with: role-to-assume: arn:aws:iam::329261680777:role/farssh-github role-session-name: github-action-push aws-region: us-east-1 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: apparentorder password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -43,10 +46,10 @@ jobs: # https://community.ibm.com/community/user/powerdeveloper/blogs/siddhesh-ghadi/2023/02/08/build-multi-arch-images-on-github-actions-with-bui - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: build and push to ECR env: @@ -54,8 +57,7 @@ jobs: run: | VERSION=$(python -c 'from client.src.farssh.const import FARSSH_VERSION; print(FARSSH_VERSION);') DATE=$(date +%Y-%m-%d) - docker buildx build --push --platform linux/arm64 --build-arg FARSSH_VERSION=$VERSION --build-arg FARSSH_DATE=$DATE --tag $ECR_REPOSITORY:$VERSION image - docker buildx build --push --platform linux/arm64 --build-arg FARSSH_VERSION=$VERSION --build-arg FARSSH_DATE=$DATE --tag $ECR_REPOSITORY:latest image + docker buildx build --push --platform linux/arm64 --build-arg FARSSH_VERSION=$VERSION --build-arg FARSSH_DATE=$DATE --tag $ECR_REPOSITORY:$VERSION --tag $ECR_REPOSITORY:v1 --tag $ECR_REPOSITORY:latest image - name: build and push to Dockerhub env: @@ -63,5 +65,4 @@ jobs: run: | VERSION=$(python -c 'from client.src.farssh.const import FARSSH_VERSION; print(FARSSH_VERSION);') DATE=$(date +%Y-%m-%d) - docker buildx build --push --platform linux/arm64 --build-arg FARSSH_VERSION=$VERSION --build-arg FARSSH_DATE=$DATE --tag $DOCKERHUB_REPOSITORY:$VERSION image - docker buildx build --push --platform linux/arm64 --build-arg FARSSH_VERSION=$VERSION --build-arg FARSSH_DATE=$DATE --tag $DOCKERHUB_REPOSITORY:latest image + docker buildx build --push --platform linux/arm64 --build-arg FARSSH_VERSION=$VERSION --build-arg FARSSH_DATE=$DATE --tag $DOCKERHUB_REPOSITORY:$VERSION --tag $DOCKERHUB_REPOSITORY:v1 --tag $DOCKERHUB_REPOSITORY:latest image diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml new file mode 100644 index 0000000..be48bd5 --- /dev/null +++ b/.github/workflows/publish-pypi.yaml @@ -0,0 +1,33 @@ +name: publish to pypi +on: + push: + tags: + - 'v*' + +permissions: + id-token: write + contents: read + +jobs: + pypi: + name: build and publish farssh to pypi + runs-on: ubuntu-latest + steps: + - name: Git clone the repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + + - name: Build + run: | + pip install build + python -m build + working-directory: client + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: client/dist diff --git a/.github/workflows/s3.yaml b/.github/workflows/s3.yaml index af03ee4..820ceaa 100644 --- a/.github/workflows/s3.yaml +++ b/.github/workflows/s3.yaml @@ -5,7 +5,6 @@ on: - main paths: - '.github/workflows/s3.yaml' - - 'client/src/farssh/const.py' - 'cloudformation/*' permissions: @@ -19,20 +18,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Git clone the repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 with: role-to-assume: arn:aws:iam::329261680777:role/farssh-github role-session-name: github-action-copy-s3 aws-region: eu-central-1 - name: copy cfn to s3 run: | - VERSION=$(python -c 'from client.src.farssh.const import FARSSH_VERSION; print(FARSSH_VERSION);') - sed -i "s|__VERSION__|$VERSION|g" cloudformation/farssh.yaml - # Validate placeholder was replaced - if grep -q '__VERSION__' cloudformation/farssh.yaml; then - echo "ERROR: __VERSION__ placeholder not replaced" - exit 1 - fi aws s3 sync cloudformation/ s3://farssh/cloudformation/ diff --git a/README.md b/README.md index f34a4b5..0075ed5 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,9 @@ That's it. For usage, see above. To update the FarSSH Cloudformation template, select the FarSSH stack in the Cloudformation console, hit "Update" and replace the template using this S3 url: `https://farssh.s3.amazonaws.com/cloudformation/farssh.yaml` -Update the image URI to reflect the updated version. +The default image tag is `v1`, which is updated in place when the FarSSH image is rebuilt (including sshd +patches). New tasks pick that up without changing `ImageUri`. A future incompatible image would be published +as `v2`. To update FarSSH settings, update the stack with the "Use current template" option. @@ -168,8 +170,12 @@ request a public IPv4 address, even when a clients connects via IPv6. With the p Alternatively, to fully avoid using public IPv4 addresses, change these options during the Cloudformation setup: -- set `ImageUri` to the `docker.io` address (the AWS ECR does not support IPv6) -- disable the `awslogs` driver (Cloudwatch Logs does not support IPv6) +- set `ImageUri` to the `docker.io` address. ECR Public can serve pulls over IPv6 + (`ecr-public.aws.com`; `public.ecr.aws` is still IPv4-only), but Fargate in a + dual-stack subnet still uses IPv4 AWS registry endpoints, and there is no + task-definition option to select the dual-stack hostname. +- disable the `awslogs` driver. CloudWatch Logs has dual-stack endpoints too, but + Fargate does not allow `awslogs-endpoint`, so it keeps calling the IPv4 Logs API. ## How it works @@ -186,11 +192,13 @@ architecture diagram: FarSSH publishes a container image in AWS Public ECR at `public.ecr.aws/apparentorder/farssh`. This is a tiny Alpine-based image that only runs an SSH server. There is also a background process that will -terminate the task if there are no active connections. +terminate the task if there are no active connections. The CloudFormation default tag `v1` moves when +the image is rebuilt; version tags (for example `1.0.0`) are also published. -The same image is also published to Dockerhub at `docker.io/apparentorder/farssh`, because Dockerhub -supports IPv6 and AWS Public ECR does not. Using Dockerhub over IPv4 might result in pull errors due -to rate limit though. +The same image is also published to Dockerhub at `docker.io/apparentorder/farssh`, +which is reachable over IPv6. Fargate in a dual-stack subnet still cannot pull +`public.ecr.aws` over IPv6 (see IPv6 Support above). Using Dockerhub over IPv4 +might result in pull errors due to rate limit though. ### Resources in the target environment @@ -216,6 +224,7 @@ the SSH client will "strictly" check the expected host key. * Currently, FarSSH can be deployed to only one VPC per region per account (deploying multiple times to different regions works fine) +* The SSH host private key is passed to the ECS task as an environment override (visible to IAM principals who can describe the task), and is only useful to an attacker who can also actively intercept that session's single SSH handshake. ## Future ideas diff --git a/client/.gitignore b/client/.gitignore index 8e596d4..9e3eec4 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -1,2 +1,5 @@ dist src/farssh/__pycache__ +__pycache__ +# Copied from the repo root during the package build. +README.md diff --git a/client/farssh_build.py b/client/farssh_build.py new file mode 100644 index 0000000..e8ffec1 --- /dev/null +++ b/client/farssh_build.py @@ -0,0 +1,24 @@ +"""PEP 517 backend: copy the repo README so hatchling can include it.""" + +import shutil +from pathlib import Path + + +def _ensure_readme(): + root = Path(__file__).resolve().parent + dst = root / "README.md" + src = root.parent / "README.md" + if src.is_file(): + shutil.copyfile(src, dst) + elif not dst.is_file(): + raise FileNotFoundError(dst) + + +def __getattr__(name): + _ensure_readme() + import hatchling.build as hatchling_build + + try: + return getattr(hatchling_build, name) + except AttributeError: + raise AttributeError(name) from None diff --git a/client/pyproject.toml b/client/pyproject.toml index 8c402d0..e6b7c28 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "farssh" dynamic = ["version"] -readme = "../README.md" +readme = "README.md" license = { text = "BSD 2-clause" } authors = [ { name="@apparentorder", email="apparentorder@neveragain.de" } ] description = "Secure on-demand connections into AWS VPCs" @@ -22,8 +22,9 @@ Homepage = "https://github.com/apparentorder/farssh" farssh = "farssh.__main__:main" [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["hatchling>=1.24,<2"] +build-backend = "farssh_build" +backend-path = ["."] [tool.hatch.version] path = "src/farssh/const.py" diff --git a/client/src/farssh/args.py b/client/src/farssh/args.py index 1ec9635..c1a2c08 100755 --- a/client/src/farssh/args.py +++ b/client/src/farssh/args.py @@ -6,19 +6,28 @@ from farssh.const import * from farssh.aws import get_farssh_ssm_parameters +_SSM_KEYS = ( + "public_subnets", + "security_group", + "ssh_port", + "force_public_ipv4", + "exec_task_role_arn", +) + class FarsshArguments: def __init__(self): self.cmd_args = self._parse_args() self.cmd_args['remote_port'] = self.cmd_args.get('remote_port') or self.cmd_args.get('local_port') - self.enable_execute_command = False - # defaults, if not found in Parameter Store self.force_public_ipv4 = False self.ssh_port = "20022" for (key, value) in get_farssh_ssm_parameters(FARSSH_ID).items(): - setattr(self, key, value) + if key in _SSM_KEYS: + setattr(self, key, value) + + self.enable_execute_command = bool(self.cmd_args.get('execute_command')) try: self.public_subnets = self.public_subnets.split(',') @@ -47,6 +56,7 @@ def _parse_args(self): parser.add_argument('-6', '--ipv6', action='store_true', help = 'use IPv6 (disables public IPv4 when possible)') parser.add_argument('-S', '--spot', action='store_true', dest = 'fargate_spot', help = 'use Fargate Spot') + parser.add_argument('--execute-command', action='store_true', dest = 'execute_command', help = 'enable ECS Exec (uses the exec task role)') parser.add_argument('-V', '--version', action='version', version = f'FarSSH {FARSSH_VERSION}') subparsers = parser.add_subparsers(dest = 'command', required = True) diff --git a/client/src/farssh/aws.py b/client/src/farssh/aws.py index 1f9c864..afeaf63 100755 --- a/client/src/farssh/aws.py +++ b/client/src/farssh/aws.py @@ -48,10 +48,6 @@ def run_ecs_task(args, ssh_keys, farssh_id): { "name": "FARSSH_SSH_HOST_ED25519_KEY_BASE64", "value": base64.b64encode(bytes(ssh_keys.ed25519_host_key, "utf-8")).decode("utf-8") - }, - { - "name": "FARSSH_SSH_HOST_RSA_KEY_BASE64", - "value": base64.b64encode(bytes(ssh_keys.rsa_host_key, "utf-8")).decode("utf-8") } ] @@ -63,6 +59,11 @@ def run_ecs_task(args, ssh_keys, farssh_id): overrides = {} overrides['containerOverrides'] = [ override_entry ] + if args.enable_execute_command: + exec_task_role_arn = getattr(args, 'exec_task_role_arn', None) + if exec_task_role_arn: + overrides['taskRoleArn'] = exec_task_role_arn + network_configuration = { "awsvpcConfiguration": { "subnets": args.public_subnets, @@ -89,6 +90,8 @@ def run_ecs_task(args, ssh_keys, farssh_id): task_id = task_arn.split('/')[-1] print(f"Launched FarSSH ECS task: {task_id} (provider: {capacity_provider})") + if args.enable_execute_command: + print("ECS Exec: enabled") print(f"Status: {task['lastStatus']}") while True: diff --git a/client/src/farssh/const.py b/client/src/farssh/const.py index cb84a9a..9576742 100755 --- a/client/src/farssh/const.py +++ b/client/src/farssh/const.py @@ -1,4 +1,4 @@ -FARSSH_VERSION = "0.6.1" +FARSSH_VERSION = "1.0.0" FARSSH_ID = 'default' FARSSH_URL = 'https://github.com/apparentorder/farssh' diff --git a/client/src/farssh/ssh.py b/client/src/farssh/ssh.py index bdb1a98..5279a50 100755 --- a/client/src/farssh/ssh.py +++ b/client/src/farssh/ssh.py @@ -13,27 +13,21 @@ def __init__(self, farssh_args) -> None: self.known_hosts_file = f"{self._tempdir.name}/known-hosts" subprocess.run(["ssh-keygen", "-q", "-N", "", "-t", "ed25519", "-f", f"{self._tempdir.name}/ssh_host_ed25519_key"], check = True) - subprocess.run(["ssh-keygen", "-q", "-N", "", "-t", "rsa", "-f", f"{self._tempdir.name}/ssh_host_rsa_key"], check = True) subprocess.run(["ssh-keygen", "-q", "-N", "", "-t", "ed25519", "-f", f"{self._tempdir.name}/ssh_login_key"], check = True) self.ed25519_host_key_file = f"{self._tempdir.name}/ssh_host_ed25519_key" self.ed25519_host_key_pub_file = f"{self._tempdir.name}/ssh_host_ed25519_key.pub" - self.rsa_host_key_file = f"{self._tempdir.name}/ssh_host_rsa_key" - self.rsa_host_key_pub_file = f"{self._tempdir.name}/ssh_host_rsa_key.pub" self.login_key_file = f"{self._tempdir.name}/ssh_login_key" self.login_key_pub_file = f"{self._tempdir.name}/ssh_login_key.pub" self.ed25519_host_key = open(self.ed25519_host_key_file, "r").read() self.ed25519_host_key_pub = open(self.ed25519_host_key_pub_file, "r").read() - self.rsa_host_key = open(self.rsa_host_key_file, "r").read() - self.rsa_host_key_pub = open(self.rsa_host_key_pub_file, "r").read() self.login_key_pub = open(self.login_key_pub_file, "r").read() # self.login_key isn't used here, so don't read. def write_known_hosts(self, ip_address) -> None: # Create temporary known-hosts file so the SSH client can verify the remote host's public key that we configured it with. # The `host` *must* be without port number when the port is 22; this seems to be a quirk of OpenSSH's known-hosts file format. - # Write Ed25519 key first (preferred), then RSA (fallback for backward compatibility). with open(self.known_hosts_file, "w") as f: host = ip_address @@ -42,4 +36,3 @@ def write_known_hosts(self, ip_address) -> None: host = f"[{host}]:{self.farssh_args.ssh_port}" f.write(f"{host} {self.ed25519_host_key_pub}\n") - f.write(f"{host} {self.rsa_host_key_pub}\n") diff --git a/cloudformation/farssh.yaml b/cloudformation/farssh.yaml index 8eba9da..531fbdd 100644 --- a/cloudformation/farssh.yaml +++ b/cloudformation/farssh.yaml @@ -43,14 +43,15 @@ Parameters: ImageUri: Description: | - When FarSSH runs in an IPv6 subnet, change this to the DockerHub image, as - AWS ECR Public does not support image pull over IPv6. + `:v1` tracks the current FarSSH v1 image (sshd patches land on the next + task start without updating the stack). Use the DockerHub image when the + task has no IPv4 path to the internet: ECR Public can serve IPv6 pulls, + but Fargate in a dual-stack subnet still uses IPv4 registry endpoints. Type: String - # Placeholder __VERSION__ is replaced during deployment to S3. - Default: public.ecr.aws/apparentorder/farssh:__VERSION__ + Default: public.ecr.aws/apparentorder/farssh:v1 AllowedValues: - - public.ecr.aws/apparentorder/farssh:__VERSION__ - - docker.io/apparentorder/farssh:__VERSION__ + - public.ecr.aws/apparentorder/farssh:v1 + - docker.io/apparentorder/farssh:v1 Conditions: AwslogsEnabled: !Equals [!Ref EnableAwslogsDriver, true] @@ -69,8 +70,18 @@ Resources: Condition: StringEquals: "aws:SourceAccount": !Ref AWS::AccountId - ManagedPolicyArns: - - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy + ArnLike: + "aws:SourceArn": !Sub arn:${AWS::Partition}:ecs:${AWS::Region}:${AWS::AccountId}:cluster/farssh + Policies: + - PolicyName: farssh-execution + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:CreateLogStream + - logs:PutLogEvents + Resource: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/ecs/farssh/${FarSshSuffix}:* FarSshTaskRole: Type: AWS::IAM::Role @@ -85,8 +96,27 @@ Resources: Condition: StringEquals: "aws:SourceAccount": !Ref AWS::AccountId + ArnLike: + "aws:SourceArn": !Sub arn:${AWS::Partition}:ecs:${AWS::Region}:${AWS::AccountId}:cluster/farssh + + # Attached only when the client passes --execute-command (RunTask taskRoleArn override). + FarSshExecTaskRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: sts:AssumeRole + Principal: + Service: ecs-tasks.amazonaws.com + Condition: + StringEquals: + "aws:SourceAccount": !Ref AWS::AccountId + ArnLike: + "aws:SourceArn": !Sub arn:${AWS::Partition}:ecs:${AWS::Region}:${AWS::AccountId}:cluster/farssh ManagedPolicyArns: - - arn:aws:iam::aws:policy/AmazonSSMManagedEC2InstanceDefaultPolicy # for debugging (ecs execute command) + - arn:aws:iam::aws:policy/AmazonSSMManagedEC2InstanceDefaultPolicy SsmParameterPublicSubnets: Type: AWS::SSM::Parameter @@ -116,6 +146,13 @@ Resources: Name: !Sub /farssh/${FarSshSuffix}/force_public_ipv4 Value: !Ref ForcePublicIpv4 + SsmParameterExecTaskRoleArn: + Type: AWS::SSM::Parameter + Properties: + Type: String + Name: !Sub /farssh/${FarSshSuffix}/exec_task_role_arn + Value: !GetAtt FarSshExecTaskRole.Arn + SecurityGroup: Type: AWS::EC2::SecurityGroup Properties: diff --git a/image/Dockerfile b/image/Dockerfile index 24c876f..7183b8f 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -6,8 +6,9 @@ ARG FARSSH_VERSION=dev ENV FARSSH_VERSION=${FARSSH_VERSION} ENV FARSSH_DATE=${FARSSH_DATE} -RUN apk add openssh +RUN apk add --no-cache openssh RUN sed -i "s/AllowTcpForwarding no/AllowTcpForwarding yes/" /etc/ssh/sshd_config +RUN echo "PasswordAuthentication no" >> /etc/ssh/sshd_config ADD motd /etc/motd ADD entrypoint /farssh-entrypoint diff --git a/image/entrypoint b/image/entrypoint index 5c7b65d..b5e8319 100755 --- a/image/entrypoint +++ b/image/entrypoint @@ -7,20 +7,10 @@ echo ">>> FarSSH container image $FARSSH_VERSION ($FARSSH_DATE)" sed -i "s/__FARSSH_VERSION__/$FARSSH_VERSION/g" /etc/motd sed -i "s/__FARSSH_DATE__/$FARSSH_DATE/g" /etc/motd -host_algos=$(env | sed -n 's/FARSSH_SSH_HOST_\([A-Z0-9]*\)_KEY_BASE64=.*/\1/p') - echo ">>> FarSSH host keys:" -for algo in $host_algos; do - algo_lower=$(echo "$algo" | tr '[A-Z]' '[a-z]') - - keyfile="/etc/ssh/ssh_host_${algo_lower}_key" - eval "key=\"\$FARSSH_SSH_HOST_${algo}_KEY_BASE64\"" - echo "$key" | base64 -d > "$keyfile" - chmod 600 "$keyfile" - - # output fingerprint - ssh-keygen -lf "$keyfile" -done +printf '%s\n' "$FARSSH_SSH_HOST_ED25519_KEY_BASE64" | base64 -d > /etc/ssh/ssh_host_ed25519_key +chmod 600 /etc/ssh/ssh_host_ed25519_key +ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key mkdir ~/.ssh echo "$FARSSH_SSH_AUTHORIZED_KEYS" > ~/.ssh/authorized_keys