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
5 changes: 5 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
19 changes: 10 additions & 9 deletions .github/workflows/image.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
Expand All @@ -43,25 +46,23 @@ 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:
ECR_REPOSITORY: public.ecr.aws/apparentorder/farssh
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:
DOCKERHUB_REPOSITORY: apparentorder/farssh
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
33 changes: 33 additions & 0 deletions .github/workflows/publish-pypi.yaml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 2 additions & 10 deletions .github/workflows/s3.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ on:
- main
paths:
- '.github/workflows/s3.yaml'
- 'client/src/farssh/const.py'
- 'cloudformation/*'

permissions:
Expand All @@ -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/
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions client/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
dist
src/farssh/__pycache__
__pycache__
# Copied from the repo root during the package build.
README.md
24 changes: 24 additions & 0 deletions client/farssh_build.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 4 additions & 3 deletions client/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down
16 changes: 13 additions & 3 deletions client/src/farssh/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(',')
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 7 additions & 4 deletions client/src/farssh/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
]

Expand All @@ -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,
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion client/src/farssh/const.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FARSSH_VERSION = "0.6.1"
FARSSH_VERSION = "1.0.0"
FARSSH_ID = 'default'
FARSSH_URL = 'https://github.com/apparentorder/farssh'

7 changes: 0 additions & 7 deletions client/src/farssh/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Loading