From 7466138ee85fd453aea3f84e1ac35efeb4c18e9d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 21:08:49 +0000 Subject: [PATCH 01/16] Drop RSA host keys and document the ECS override tradeoff. Ed25519 is the only host key now. The host private key still goes to the task as an environment override; the README caveat records that this is visible via the ECS API and only useful with an active MITM of the one SSH handshake. Co-authored-by: apparentorder-bot --- README.md | 1 + client/src/farssh/aws.py | 4 ---- client/src/farssh/ssh.py | 7 ------- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/README.md b/README.md index f34a4b5..01ddd27 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,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/src/farssh/aws.py b/client/src/farssh/aws.py index 1f9c864..95ebcc3 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") } ] 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") From c8eba95e2d97c26cad3097e04eb0b81ec62a899d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 21:28:53 +0000 Subject: [PATCH 02/16] Disable SSH password authentication in the FarSSH image. Append PasswordAuthentication no so sshd does not offer password auth even if Alpine defaults change. Co-authored-by: apparentorder-bot --- image/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/image/Dockerfile b/image/Dockerfile index 24c876f..6101c9e 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -8,6 +8,7 @@ ENV FARSSH_DATE=${FARSSH_DATE} RUN apk add 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 From 928c6b13c56f0a124d8220cb927002b061a5151c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 21:35:00 +0000 Subject: [PATCH 03/16] Give ECS Exec its own task role, selected only when requested. The default task role no longer has the SSM managed policy. --execute-command enables Exec and overrides the task role to FarSshExecTaskRole; the flag is applied after SSM so a parameter cannot turn Exec on. Co-authored-by: apparentorder-bot --- client/src/farssh/args.py | 6 ++++-- client/src/farssh/aws.py | 8 ++++++++ cloudformation/farssh.yaml | 23 ++++++++++++++++++++++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/client/src/farssh/args.py b/client/src/farssh/args.py index 1ec9635..e41c3bb 100755 --- a/client/src/farssh/args.py +++ b/client/src/farssh/args.py @@ -11,8 +11,6 @@ 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" @@ -20,6 +18,9 @@ def __init__(self): for (key, value) in get_farssh_ssm_parameters(FARSSH_ID).items(): setattr(self, key, value) + # CLI only; set after SSM so a parameter cannot enable this. + self.enable_execute_command = bool(self.cmd_args.get('execute_command')) + try: self.public_subnets = self.public_subnets.split(',') self.force_public_ipv4 = (self.force_public_ipv4 == "true") @@ -47,6 +48,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 95ebcc3..cc9fe97 100755 --- a/client/src/farssh/aws.py +++ b/client/src/farssh/aws.py @@ -59,6 +59,12 @@ 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 not exec_task_role_arn: + raise SystemExit("ERROR: ECS Exec requested, but exec_task_role_arn not found. Update the FarSSH CloudFormation stack.") + overrides['taskRoleArn'] = exec_task_role_arn + network_configuration = { "awsvpcConfiguration": { "subnets": args.public_subnets, @@ -85,6 +91,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/cloudformation/farssh.yaml b/cloudformation/farssh.yaml index 8eba9da..c6fc7f3 100644 --- a/cloudformation/farssh.yaml +++ b/cloudformation/farssh.yaml @@ -73,6 +73,20 @@ Resources: - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy FarSshTaskRole: + 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 + + FarSshExecTaskRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: @@ -86,7 +100,7 @@ Resources: StringEquals: "aws:SourceAccount": !Ref AWS::AccountId 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 +130,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: From f67b02a9796d871b66a82155990bb8723dafcfbb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 21:37:31 +0000 Subject: [PATCH 04/16] Fall back to the task definition role when exec_task_role_arn is absent. --execute-command on a stack that has not been updated still enables Exec and uses the existing task role, which already has the SSM policy. Co-authored-by: apparentorder-bot --- client/src/farssh/aws.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client/src/farssh/aws.py b/client/src/farssh/aws.py index cc9fe97..afeaf63 100755 --- a/client/src/farssh/aws.py +++ b/client/src/farssh/aws.py @@ -61,9 +61,8 @@ def run_ecs_task(args, ssh_keys, farssh_id): if args.enable_execute_command: exec_task_role_arn = getattr(args, 'exec_task_role_arn', None) - if not exec_task_role_arn: - raise SystemExit("ERROR: ECS Exec requested, but exec_task_role_arn not found. Update the FarSSH CloudFormation stack.") - overrides['taskRoleArn'] = exec_task_role_arn + if exec_task_role_arn: + overrides['taskRoleArn'] = exec_task_role_arn network_configuration = { "awsvpcConfiguration": { From 51e8a95037cd5f2f86d8ff9030748f2ef76cdd2d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 21:43:22 +0000 Subject: [PATCH 05/16] Allowlist SSM parameter names applied to FarsshArguments. Only public_subnets, security_group, ssh_port, force_public_ipv4, and exec_task_role_arn are copied from Parameter Store. Co-authored-by: apparentorder-bot --- client/src/farssh/args.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/client/src/farssh/args.py b/client/src/farssh/args.py index e41c3bb..c1a2c08 100755 --- a/client/src/farssh/args.py +++ b/client/src/farssh/args.py @@ -6,6 +6,14 @@ 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() @@ -16,9 +24,9 @@ def __init__(self): 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) - # CLI only; set after SSM so a parameter cannot enable this. self.enable_execute_command = bool(self.cmd_args.get('execute_command')) try: From 42b97057033c8154e07cad4c4ef9bda9a80b0ee0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 19:45:00 +0000 Subject: [PATCH 06/16] Default the FarSSH image to a moving v0 tag. CloudFormation now uses :v0 so one-shot stacks pick up rebuilt images (including sshd patches) on the next task. CI publishes :v0 alongside the version tag, weekly plus workflow_dispatch. Dependabot watches the Dockerfile. The S3 template no longer substitutes a frozen version. Co-authored-by: apparentorder-bot --- .github/dependabot.yml | 5 +++++ .github/workflows/image.yaml | 9 +++++---- .github/workflows/s3.yaml | 8 -------- README.md | 7 +++++-- cloudformation/farssh.yaml | 13 +++++++------ 5 files changed, 22 insertions(+), 20 deletions(-) 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..5525667 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 @@ -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:v0 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:v0 image diff --git a/.github/workflows/s3.yaml b/.github/workflows/s3.yaml index af03ee4..7386d1b 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: @@ -28,11 +27,4 @@ jobs: 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 01ddd27..0b9ecd8 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 `v0`, 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 `v1`. To update FarSSH settings, update the stack with the "Use current template" option. @@ -186,7 +188,8 @@ 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 `v0` moves when +the image is rebuilt; version tags (for example `0.6.1`) 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 diff --git a/cloudformation/farssh.yaml b/cloudformation/farssh.yaml index c6fc7f3..441ee49 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. + `:v0` tracks the current FarSSH v0 image (sshd patches land on the next + task start without updating the stack). Use the DockerHub image when + FarSSH runs in an IPv6 subnet; AWS ECR Public does not support pulls + over IPv6. Type: String - # Placeholder __VERSION__ is replaced during deployment to S3. - Default: public.ecr.aws/apparentorder/farssh:__VERSION__ + Default: public.ecr.aws/apparentorder/farssh:v0 AllowedValues: - - public.ecr.aws/apparentorder/farssh:__VERSION__ - - docker.io/apparentorder/farssh:__VERSION__ + - public.ecr.aws/apparentorder/farssh:v0 + - docker.io/apparentorder/farssh:v0 Conditions: AwslogsEnabled: !Equals [!Ref EnableAwslogsDriver, true] From de440f527606c378c8b3078fa9da8ae9d1ec9567 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 19:46:39 +0000 Subject: [PATCH 07/16] Keep publishing the latest image tag alongside v0. Existing stacks that pull :latest should still receive rebuilt images. Co-authored-by: apparentorder-bot --- .github/workflows/image.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/image.yaml b/.github/workflows/image.yaml index 5525667..e7fc1bd 100644 --- a/.github/workflows/image.yaml +++ b/.github/workflows/image.yaml @@ -57,7 +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 --tag $ECR_REPOSITORY:v0 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:v0 --tag $ECR_REPOSITORY:latest image - name: build and push to Dockerhub env: @@ -65,4 +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 --tag $DOCKERHUB_REPOSITORY:v0 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:v0 --tag $DOCKERHUB_REPOSITORY:latest image From e9001efee80528af1f714a8f4cf5c54002b4613c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 19:49:58 +0000 Subject: [PATCH 08/16] Pin GitHub Actions to commit SHAs. Dependabot already watches github-actions (security updates only) and will refresh these pins. Co-authored-by: apparentorder-bot --- .github/workflows/image.yaml | 10 +++++----- .github/workflows/s3.yaml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/image.yaml b/.github/workflows/image.yaml index e7fc1bd..e980dc1 100644 --- a/.github/workflows/image.yaml +++ b/.github/workflows/image.yaml @@ -23,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 }} @@ -46,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: diff --git a/.github/workflows/s3.yaml b/.github/workflows/s3.yaml index 7386d1b..820ceaa 100644 --- a/.github/workflows/s3.yaml +++ b/.github/workflows/s3.yaml @@ -18,9 +18,9 @@ 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 From a035c7d2affcb0ef5a792a830b904eda5c787f64 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 20:03:18 +0000 Subject: [PATCH 09/16] Add PyPI trusted publishing on version tags. publish-pypi.yaml builds the client package and uploads via OIDC when a v* tag is pushed. No environment, matching the PyPI publisher setup. Co-authored-by: apparentorder-bot --- .github/workflows/publish-pypi.yaml | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/publish-pypi.yaml 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 From 846248aedca2012de72554b80193dbaa3ded55f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 20:05:27 +0000 Subject: [PATCH 10/16] Install the Ed25519 host key without eval. The entrypoint only supports FARSSH_SSH_HOST_ED25519_KEY_BASE64 now that RSA host keys are gone. Co-authored-by: apparentorder-bot --- image/entrypoint | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) 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 From 713589625e0cbdeea700f115009aa4700cbc94bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 20:09:39 +0000 Subject: [PATCH 11/16] Scope the ECS execution role and tighten role trust. Replace AmazonECSTaskExecutionRolePolicy with logs on /ecs/farssh/${suffix} only. Trust policies require aws:SourceArn on cluster/farssh as well as SourceAccount. Co-authored-by: apparentorder-bot --- cloudformation/farssh.yaml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/cloudformation/farssh.yaml b/cloudformation/farssh.yaml index 441ee49..e4fc53d 100644 --- a/cloudformation/farssh.yaml +++ b/cloudformation/farssh.yaml @@ -70,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 @@ -86,6 +96,8 @@ Resources: Condition: StringEquals: "aws:SourceAccount": !Ref AWS::AccountId + ArnLike: + "aws:SourceArn": !Sub arn:${AWS::Partition}:ecs:${AWS::Region}:${AWS::AccountId}:cluster/farssh FarSshExecTaskRole: Type: AWS::IAM::Role @@ -100,6 +112,8 @@ Resources: 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 From b46eb0661ddbc134a15858ce37285a98f1aeb898 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 20:33:02 +0000 Subject: [PATCH 12/16] Avoid apk cache in the image and bound hatchling. apk add --no-cache so the image does not keep the package index. hatchling is pinned to >=1.24,<1.32 because 1.32 rejects a readme path outside the project directory (this package uses ../README.md). Co-authored-by: apparentorder-bot --- client/pyproject.toml | 2 +- image/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/pyproject.toml b/client/pyproject.toml index 8c402d0..1948ed3 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -22,7 +22,7 @@ Homepage = "https://github.com/apparentorder/farssh" farssh = "farssh.__main__:main" [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.24,<1.32"] build-backend = "hatchling.build" [tool.hatch.version] diff --git a/image/Dockerfile b/image/Dockerfile index 6101c9e..7183b8f 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -6,7 +6,7 @@ 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 From 90246fe5bb404485c8c95318d57f8900095d99bb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 20:36:36 +0000 Subject: [PATCH 13/16] Copy the repo README during the client package build. hatchling 1.32 rejects a readme path outside the project directory, so the PEP 517 backend copies ../README.md into client/ before delegating to hatchling. That lets the hatchling pin stay at >=1.24,<2. Co-authored-by: apparentorder-bot --- client/.gitignore | 3 +++ client/farssh_build.py | 24 ++++++++++++++++++++++++ client/pyproject.toml | 7 ++++--- 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 client/farssh_build.py 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 1948ed3..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>=1.24,<1.32"] -build-backend = "hatchling.build" +requires = ["hatchling>=1.24,<2"] +build-backend = "farssh_build" +backend-path = ["."] [tool.hatch.version] path = "src/farssh/const.py" From 565650e4501f5ac622e606922027664901326667 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 20:56:18 +0000 Subject: [PATCH 14/16] Correct the IPv6 notes for ECR Public and CloudWatch Logs. Both services have dual-stack endpoints now. Fargate in a dual-stack subnet still uses IPv4 for registry and awslogs calls, with no task-definition switch, so Docker Hub and disabling awslogs remain the no-public-IPv4 path. Co-authored-by: apparentorder-bot --- README.md | 15 ++++++++++----- cloudformation/farssh.yaml | 6 +++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0b9ecd8..d764421 100644 --- a/README.md +++ b/README.md @@ -170,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 @@ -191,9 +195,10 @@ a tiny Alpine-based image that only runs an SSH server. There is also a backgrou terminate the task if there are no active connections. The CloudFormation default tag `v0` moves when the image is rebuilt; version tags (for example `0.6.1`) 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 diff --git a/cloudformation/farssh.yaml b/cloudformation/farssh.yaml index e4fc53d..fe04eb6 100644 --- a/cloudformation/farssh.yaml +++ b/cloudformation/farssh.yaml @@ -44,9 +44,9 @@ Parameters: ImageUri: Description: | `:v0` tracks the current FarSSH v0 image (sshd patches land on the next - task start without updating the stack). Use the DockerHub image when - FarSSH runs in an IPv6 subnet; AWS ECR Public does not support pulls - over IPv6. + 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 Default: public.ecr.aws/apparentorder/farssh:v0 AllowedValues: From 0b493976d3a07502d1e9041b5f93daacf8135efa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 21:07:14 +0000 Subject: [PATCH 15/16] Note that FarSshExecTaskRole is only used with ECS Exec. Co-authored-by: apparentorder-bot --- cloudformation/farssh.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/cloudformation/farssh.yaml b/cloudformation/farssh.yaml index fe04eb6..2e78419 100644 --- a/cloudformation/farssh.yaml +++ b/cloudformation/farssh.yaml @@ -99,6 +99,7 @@ Resources: 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: From 4c014787ebba707ca388339c7da01cfbea59c6ff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 21:16:54 +0000 Subject: [PATCH 16/16] Release 1.0.0 with a moving v1 image tag. The compatible image line is :v1 (weekly sshd rebuilds). CloudFormation AllowedValues are only the v1 ECR Public and Docker Hub URIs. Co-authored-by: apparentorder-bot --- .github/workflows/image.yaml | 4 ++-- README.md | 8 ++++---- client/src/farssh/const.py | 2 +- cloudformation/farssh.yaml | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/image.yaml b/.github/workflows/image.yaml index e980dc1..f5b724b 100644 --- a/.github/workflows/image.yaml +++ b/.github/workflows/image.yaml @@ -57,7 +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 --tag $ECR_REPOSITORY:v0 --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: @@ -65,4 +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 --tag $DOCKERHUB_REPOSITORY:v0 --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/README.md b/README.md index d764421..0075ed5 100644 --- a/README.md +++ b/README.md @@ -135,9 +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` -The default image tag is `v0`, which is updated in place when the FarSSH image is rebuilt (including sshd +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 `v1`. +as `v2`. To update FarSSH settings, update the stack with the "Use current template" option. @@ -192,8 +192,8 @@ 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. The CloudFormation default tag `v0` moves when -the image is rebuilt; version tags (for example `0.6.1`) are also published. +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`, which is reachable over IPv6. Fargate in a dual-stack subnet still cannot pull 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/cloudformation/farssh.yaml b/cloudformation/farssh.yaml index 2e78419..531fbdd 100644 --- a/cloudformation/farssh.yaml +++ b/cloudformation/farssh.yaml @@ -43,15 +43,15 @@ Parameters: ImageUri: Description: | - `:v0` tracks the current FarSSH v0 image (sshd patches land on the next + `: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 - Default: public.ecr.aws/apparentorder/farssh:v0 + Default: public.ecr.aws/apparentorder/farssh:v1 AllowedValues: - - public.ecr.aws/apparentorder/farssh:v0 - - docker.io/apparentorder/farssh:v0 + - public.ecr.aws/apparentorder/farssh:v1 + - docker.io/apparentorder/farssh:v1 Conditions: AwslogsEnabled: !Equals [!Ref EnableAwslogsDriver, true]