361 lines
22 KiB
Markdown
361 lines
22 KiB
Markdown
[](https://github.com/blax-software)
|
|
|
|
# docker-bastion
|
|
|
|
[](https://alpinelinux.org)
|
|
[](https://www.openssh.com)
|
|
[](https://docs.docker.com/engine/reference/commandline/cli/)
|
|
[](#whats-inside)
|
|
[](#license)
|
|
|
|
A **minimal SSH + HTTP bastion** for routing commands per authenticated session. Authenticate by SSH key or HTTP bearer token, and the container either runs one preconfigured command (`FORCE_COMMAND` — `docker exec` into a sibling, `./deploy.sh`, `nginx -s reload`) or, in **broker mode**, runs whatever the client asks for *as long as it matches a regex allowlist* (`ALLOWED_COMMANDS`) — then streams the output back.
|
|
|
|
**Why it exists:** giving an agent or CI bot `docker exec` access usually means handing them the docker socket and trusting their entire toolchain not to misbehave. A bastion with a hard-coded `FORCE_COMMAND` is the inverse: the credential authorizes *one specific thing*, the surface is sshd + busybox httpd, and the same image works for a dozen different roles by varying `FORCE_COMMAND`.
|
|
|
|
## Available Tags
|
|
|
|
| Tag | Base | Notes |
|
|
|----------------------------------|--------------|--------------------------------------|
|
|
| `blaxsoftware/bastion:latest` | alpine 3.21 | Default tag, follows alpine releases |
|
|
| `blaxsoftware/bastion:alpine3.21`| alpine 3.21 | Pinned alpine version |
|
|
|
|
## Quick Start — drop into a WordPress container
|
|
|
|
The most common use: give a deploy agent SSH-shaped access *into* a running WordPress container. Every session lands inside the `wordpress-app` container's bash; clients can run WP-CLI commands, edit config, debug — same UX as `ssh user@host` against a VPS, but scoped to one container.
|
|
|
|
```yaml
|
|
services:
|
|
# The bastion. SSH on 2222, HTTP behind traefik on https://deploy-wp.example.com.
|
|
bastion:
|
|
image: blaxsoftware/bastion:latest
|
|
restart: unless-stopped
|
|
environment:
|
|
# REQUIRED — the single command that runs on every authenticated session.
|
|
# Shell metacharacters work: &&, ||, pipes, cd, redirects.
|
|
FORCE_COMMAND: "docker exec -it wordpress-app bash"
|
|
|
|
# OPTIONAL — enables the HTTP endpoint at /cgi-bin/run with basic auth.
|
|
# Value is "user:password". Works with `curl https://user:pass@host/…`.
|
|
# Without this (and without HTTP_TOKEN), the bastion is SSH-only.
|
|
HTTP_BASIC_AUTH: "${BASTION_HTTP_BASIC_AUTH}"
|
|
# Alternative: HTTP_TOKEN="$(openssl rand -hex 32)" for Bearer auth.
|
|
# Pick one — they're mutually exclusive (Basic wins if both are set).
|
|
|
|
volumes:
|
|
# REQUIRED when FORCE_COMMAND talks to docker (docker exec, docker compose, etc).
|
|
# Mounts the host's daemon socket so docker-cli inside the bastion reaches it.
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
|
|
# OPTIONAL — host-sourced authorized_keys. Your laptop's keys, or anything
|
|
# outside the repo. Read-only mount.
|
|
- ~/.ssh/authorized_keys:/etc/bastion/authorized_keys.host:ro
|
|
|
|
# OPTIONAL — repo-sourced authorized_keys. CI / deploy-bot keys committed
|
|
# alongside the project. At least one of these two must exist or the
|
|
# container refuses to start.
|
|
- ./docker/bastion/authorized_keys:/etc/bastion/authorized_keys.repo:ro
|
|
|
|
# REQUIRED — host keys persist across rebuilds. Bind mount, NEVER a named
|
|
# volume; `docker compose down -v` would wipe a named volume and clients
|
|
# would see "REMOTE HOST IDENTIFICATION HAS CHANGED" after every redeploy.
|
|
- ./docker-data/bastion/keys:/etc/ssh/keys
|
|
|
|
ports:
|
|
# OPTIONAL — expose SSH on the host directly. Skip this entirely if you
|
|
# only want the HTTP path through traefik.
|
|
- "2222:22"
|
|
|
|
labels:
|
|
# OPTIONAL — traefik HTTP route. Visit https://deploy-wp.example.com/cgi-bin/run
|
|
# with `Authorization: Bearer $BASTION_HTTP_TOKEN` to invoke FORCE_COMMAND.
|
|
# Remove these labels if you don't want the HTTP path published.
|
|
traefik.enable: "true"
|
|
traefik.docker.network: "web"
|
|
traefik.http.routers.bastion.rule: "Host(`deploy-wp.example.com`)"
|
|
traefik.http.routers.bastion.entrypoints: "websecure"
|
|
traefik.http.routers.bastion.tls: "true"
|
|
traefik.http.services.bastion.loadbalancer.server.port: "8080"
|
|
|
|
networks: [web]
|
|
|
|
# Your actual WordPress container — bastion's FORCE_COMMAND targets it by name.
|
|
wordpress-app:
|
|
image: wordpress:latest
|
|
container_name: wordpress-app
|
|
environment:
|
|
WORDPRESS_DB_HOST: db
|
|
WORDPRESS_DB_NAME: wp
|
|
WORDPRESS_DB_USER: wp
|
|
WORDPRESS_DB_PASSWORD: "${DB_PASSWORD}"
|
|
volumes:
|
|
- ./docker-data/wordpress:/var/www/html
|
|
networks: [web]
|
|
|
|
networks:
|
|
web:
|
|
external: true
|
|
```
|
|
|
|
From the client:
|
|
|
|
```bash
|
|
# Interactive shell inside the wp container — feels exactly like ssh-into-vps
|
|
ssh -p 2222 agent@your-host
|
|
|
|
# Or trigger from a URL — basic-auth protected, output streams back
|
|
curl https://user:pass@deploy-wp.example.com/cgi-bin/run
|
|
```
|
|
|
|
## Quick Start — reload nginx on a webhook
|
|
|
|
A scoped bastion that does exactly one thing: test the new nginx config and reload if it passes. The HTTP path lets a CI job (GitHub Action, Forgejo runner, anything that can `curl`) trigger a reload after pushing new configs to disk — no SSH keys to provision in CI.
|
|
|
|
```yaml
|
|
services:
|
|
bastion:
|
|
image: blaxsoftware/bastion:latest
|
|
restart: unless-stopped
|
|
environment:
|
|
# `nginx -t` exits non-zero on a syntax error; `&&` short-circuits so a
|
|
# broken config never gets applied. The exit code propagates back to
|
|
# the HTTP client (which sees the connection close mid-stream on failure).
|
|
FORCE_COMMAND: "docker exec nginx-app nginx -t && docker exec nginx-app nginx -s reload"
|
|
HTTP_TOKEN: "${BASTION_HTTP_TOKEN}"
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
# SSH path stays available for human debugging — same key, same scope.
|
|
- ~/.ssh/authorized_keys:/etc/bastion/authorized_keys.host:ro
|
|
- ./docker-data/bastion/keys:/etc/ssh/keys
|
|
labels:
|
|
traefik.enable: "true"
|
|
traefik.docker.network: "web"
|
|
traefik.http.routers.bastion-nginx.rule: "Host(`reload-nginx.example.com`)"
|
|
traefik.http.routers.bastion-nginx.entrypoints: "websecure"
|
|
traefik.http.routers.bastion-nginx.tls: "true"
|
|
traefik.http.services.bastion-nginx.loadbalancer.server.port: "8080"
|
|
networks: [web]
|
|
|
|
nginx-app:
|
|
image: nginx:alpine
|
|
container_name: nginx-app
|
|
volumes:
|
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
|
- ./conf.d:/etc/nginx/conf.d:ro
|
|
networks: [web]
|
|
|
|
networks:
|
|
web:
|
|
external: true
|
|
```
|
|
|
|
CI snippet:
|
|
|
|
```bash
|
|
# After updating nginx.conf on disk:
|
|
curl --fail-with-body -H "Authorization: Bearer $BASTION_HTTP_TOKEN" \
|
|
https://reload-nginx.example.com/cgi-bin/run
|
|
```
|
|
|
|
`curl --fail-with-body` makes the CI step fail (non-zero exit) if the bastion returns 4xx/5xx, with the body printed — so a `nginx -t` syntax error in the new config shows up in the CI log without extra wiring.
|
|
|
|
## Quick Start — manage docker-mailserver (broker mode)
|
|
|
|
The two examples above hard-code *one* command. **Broker mode** is the inverse: the client supplies the command and the bastion runs it only if it matches a regex allowlist. This is how you give a management UI (a Nuxt "mail manager", a cron job, a bot) a *menu* of allowed operations without handing it a shell.
|
|
|
|
Here one bastion fronts [docker-mailserver](https://github.com/docker-mailserver/docker-mailserver)'s `setup` CLI. The app sends `email add jane@example.com <pw>` over SSH on the internal network; the bastion validates it and runs `docker exec -i mailserver setup email add jane@example.com <pw>`.
|
|
|
|
```yaml
|
|
services:
|
|
bastion-mail:
|
|
image: blaxsoftware/bastion:latest
|
|
restart: unless-stopped
|
|
environment:
|
|
# Trusted prefix prepended to every validated request, so clients
|
|
# send clean `email add …` and never see the docker plumbing.
|
|
COMMAND_PREFIX: "docker exec -i mailserver setup"
|
|
|
|
# The whitelist. A YAML block scalar reads like a string array:
|
|
# one extended-regex (ERE) rule per line. A request is allowed only
|
|
# if it matches a rule WHOLE-LINE (anchored). Matched commands run
|
|
# WITHOUT a shell — ; | & $() are literal args, never operators —
|
|
# so keep argument classes tight ([^ ]+, not .*).
|
|
ALLOWED_COMMANDS: |
|
|
email add [^ ]+@[^ ]+ [^ ]+
|
|
email update [^ ]+@[^ ]+ [^ ]+
|
|
email del [^ ]+@[^ ]+
|
|
email list
|
|
alias add [^ ]+@[^ ]+ [^ ]+
|
|
alias del [^ ]+@[^ ]+ [^ ]+
|
|
alias list
|
|
quota set [^ ]+@[^ ]+ [0-9]+[KMGT]?
|
|
quota del [^ ]+@[^ ]+
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
- ./docker-data/bastion/users.d:/etc/bastion/users.d
|
|
- ./docker-data/bastion/keys:/etc/ssh/keys
|
|
networks: [web] # same network as the `mailserver` container
|
|
|
|
networks:
|
|
web:
|
|
external: true
|
|
```
|
|
|
|
From the mail-manager (same docker network, no host port needed):
|
|
|
|
```bash
|
|
# allowed → runs `docker exec -i mailserver setup email add …`
|
|
ssh agent@bastion-mail "email add jane@example.com $(openssl rand -base64 18 | tr -d /+=)"
|
|
|
|
ssh agent@bastion-mail "email list"
|
|
|
|
# refused → exits 126, nothing runs
|
|
ssh agent@bastion-mail "email del jane@example.com; rm -rf /"
|
|
```
|
|
|
|
A complete copy-paste setup (compose + a live-editable `allowed-commands.list`) is in [`examples/docker-mailserver/`](examples/docker-mailserver/).
|
|
|
|
## Command broker mode
|
|
|
|
Set `ALLOWED_COMMANDS` (or mount `/etc/bastion/allowed-commands.list`) and the bastion switches from "one fixed command" to "any command the client asks for, **if** it passes the allowlist". `FORCE_COMMAND` is then optional and ignored.
|
|
|
|
**How a request is judged**
|
|
|
|
1. The client-supplied command (SSH `SSH_ORIGINAL_COMMAND`, or the HTTP `X-Bastion-Command` header) is taken verbatim.
|
|
2. Multi-line requests are rejected outright (so a benign first line can't smuggle a second).
|
|
3. It is matched **whole-line, anchored** against each rule (`grep -Eqx`). First match wins.
|
|
4. On a match: the (optional) `COMMAND_PREFIX` and the request are **word-split with globbing off and `exec`'d directly — there is no `sh -c`.** `;` `|` `&` `` ` `` `$()` `<` `>` become literal arguments, never shell operators. On no match: refused, exit `126`, logged as `[broker] DENY: …`.
|
|
|
|
**Why shell-free execution matters.** It means the regex is a *containment* boundary, not just a *filter*. Even a careless rule like `email .*` cannot escalate to command injection — the worst case is that `setup` receives a weird argument. (Contrast the `sh -c` path used by `FORCE_COMMAND` mode, where a metacharacter *would* be interpreted.)
|
|
|
|
**Writing rules**
|
|
|
|
- One ERE per line; `#` comments and blank lines ignored; surrounding whitespace trimmed (so indented YAML block lines work).
|
|
- Anchoring is implicit — write `email list`, not `^email list$` (both work).
|
|
- Use tight argument classes: `[^ ]+` ("a run of non-spaces") beats `.*`. Add alternation for fixed verbs: `email (add|update|del|list)`.
|
|
- Values that must reach the target intact **cannot contain whitespace** (word-splitting) or be quoted (no shell). Generate passwords/tokens from a space-free alphabet — e.g. `openssl rand -base64 24 | tr -d /+=`.
|
|
|
|
**Two sources, additive:** `ALLOWED_COMMANDS` (env, snapshot at boot) **+** `/etc/bastion/allowed-commands.list` (bind mount, re-read every request — edit without a restart).
|
|
|
|
**SSH vs HTTP.** SSH is the recommended transport (clean stdout/stderr separation). The HTTP path works too — `POST`/`GET` with `Authorization` + `X-Bastion-Command: <cmd>` — but merges the broker's stderr into the response body (the CGI uses `2>&1`, same as the deploy examples).
|
|
|
|
**Where the audit line goes.** The broker prints `[broker] ALLOW: …` / `[broker] DENY: …` on the *command's* stderr — so over SSH it returns to the client on the channel's stderr stream, and over HTTP it is folded into the response body (`2>&1`). It does **not** land in the bastion container's own `docker logs` (sshd does not redirect a ForceCommand's stderr to the daemon log). If you want a container-side audit trail, tee it — e.g. have `COMMAND_PREFIX`/the wrapper append to a logfile, or run a syslog sidecar.
|
|
|
|
## Two channels, two shapes
|
|
|
|
| Channel | Best for | TTY? | Streaming? |
|
|
|---------|--------------------------------|----------|--------------------|
|
|
| SSH | Interactive (`docker exec -it`) **or** scripts | yes | yes |
|
|
| HTTP | Scripts only — no TTY | no | yes (chunked / close-delimited) |
|
|
|
|
Interactive commands (`docker exec -it app bash`) over HTTP fail because there's no TTY — use SSH for those. Both channels stream output line-by-line; both close as soon as `FORCE_COMMAND` exits and the exit code propagates (SSH: to the client; HTTP: nonzero closes the response mid-stream).
|
|
|
|
The client cannot override the command. `SSH_ORIGINAL_COMMAND` and HTTP request bodies are intentionally ignored.
|
|
|
|
## Authorized keys — three sources, two flavors
|
|
|
|
sshd consults three sources on every authentication attempt:
|
|
|
|
| Source | Read when | Mount UX |
|
|
|-----------------------------------------|----------------------|--------------------------------------------------------------------------|
|
|
| `/etc/bastion/users.d/*.pub` | **live, every login** | Drop one `.pub` file per user — `users.d/alice.pub`, `users.d/bob.pub`. No restart to add/revoke. |
|
|
| `/etc/bastion/authorized_keys.host` | merged at boot | Single file from the host — `~/.ssh/authorized_keys`. |
|
|
| `/etc/bastion/authorized_keys.repo` | merged at boot | Single file from the repo — `./docker/bastion/authorized_keys`. |
|
|
|
|
**Recommended: `users.d/`** — one file per identity, dropped in via a host bind mount, adds and revokes immediately. The two file-based sources stay for backward compatibility and for the "one big committed file" pattern.
|
|
|
|
Zero authorized keys is now a warning, not a startup failure — the bastion runs but every SSH attempt fails with `publickey denied` until you drop a key in.
|
|
|
|
## Environment variables
|
|
|
|
| Variable | Default | Description |
|
|
|-------------------------|----------------------------------------|----------------------------------------------------------------------------|
|
|
| `FORCE_COMMAND` | *(required unless `ALLOWED_COMMANDS` set)* | FORCE_COMMAND mode: the command run on every authenticated session. Shell metacharacters OK. Ignored in broker mode. |
|
|
| `ALLOWED_COMMANDS` | *(unset)* | Broker mode: newline-separated ERE allowlist. Set this (or mount the file below) to enable broker mode. Each rule must match a request whole-line. |
|
|
| `COMMAND_PREFIX` | *(unset)* | Broker mode, optional: trusted prefix prepended to every validated request (e.g. `docker exec -i mailserver setup`). |
|
|
| *(mount)* `/etc/bastion/allowed-commands.list` | *(unset)* | Broker mode, optional: same format as `ALLOWED_COMMANDS`, re-read every request (live edits, no restart). Additive. |
|
|
| `HTTP_BASIC_AUTH` | *(unset)* | Enables HTTP with Basic auth. Value is `user:password`. Works with `curl -u`, `curl https://user:pass@host/…`, and browser URL bars. |
|
|
| `HTTP_TOKEN` | *(unset)* | Enables HTTP with Bearer auth. Clients send `Authorization: Bearer <this>`. Mutually exclusive with `HTTP_BASIC_AUTH` (basic takes precedence). |
|
|
| `HTTP_PORT` | `8080` | Port for the HTTP listener (when either auth var is set). |
|
|
| `SSH_PORT` | `22` | Port for sshd inside the container. |
|
|
| `AUTHORIZED_KEYS_DIR` | `/etc/bastion/users.d` | Directory of `*.pub` files, **live-read** by sshd via `AuthorizedKeysCommand`. Drop a file → next login picks it up. |
|
|
| `AUTHORIZED_KEYS_HOST` | `/etc/bastion/authorized_keys.host` | Single-file source, merged at boot. Optional / legacy. |
|
|
| `AUTHORIZED_KEYS_REPO` | `/etc/bastion/authorized_keys.repo` | Single-file source, merged at boot. Optional / legacy. |
|
|
|
|
## Build args
|
|
|
|
| Arg | Default | Description |
|
|
|------------------|---------|-----------------------------------|
|
|
| `ALPINE_VERSION` | `3.21` | Alpine base image tag. |
|
|
| `SSH_UID` | `1000` | UID of the bastion `agent` user. |
|
|
| `SSH_GID` | `1000` | GID of the bastion `agent` group. |
|
|
|
|
## What's inside
|
|
|
|
- **openssh-server** — hardened config: key-only auth, no forwarding, no PAM, no user env, global `ForceCommand` directive (clients cannot bypass).
|
|
- **bastion-broker** — the allowlist gate for broker mode: whole-line ERE match, then shell-free word-split `exec`. Shared by the SSH and HTTP paths.
|
|
- **busybox httpd** (busybox-extras) — minimal HTTP listener for the URL path; CGI-driven; only starts when `HTTP_TOKEN` or `HTTP_BASIC_AUTH` is set.
|
|
- **docker-cli + docker-cli-compose** — so `FORCE_COMMAND` can target containers through a mounted docker socket. Group membership is auto-aligned to the host socket's GID at boot.
|
|
- **tini** — PID 1, signal handling, zombie reaping.
|
|
- **bash, ca-certificates, tzdata.**
|
|
|
|
Total image: ~105 MB. Most of that is docker-cli (~50 MB) and docker-cli-compose (~25 MB).
|
|
|
|
## Security model
|
|
|
|
The security boundary is **the authorized_keys file (SSH) and the `HTTP_TOKEN` (HTTP), plus the `ForceCommand` wrapper**. Once a key or bearer token authenticates, the session runs exactly one command — there is no fallback shell. The bastion holds the docker socket, which is host-root-equivalent, so the only thing standing between a remote attacker and host root is the auth layer + your key/token hygiene.
|
|
|
|
Practical checklist:
|
|
|
|
1. **Key-only SSH, no passwords** — enforced in `sshd_config`.
|
|
2. **HTTP requires auth** — either basic auth (via busybox httpd's `-c` conf, with `REMOTE_USER` set on the authenticated CGI) or bearer token (validated by the CGI script). No anonymous path.
|
|
3. **No agent / TCP / X11 forwarding, no port tunnels** — enforced in `sshd_config`.
|
|
4. **ForceCommand cannot be bypassed.** Clients can request any command (`ssh user@host arbitrary-thing`); sshd ignores it and runs `/etc/bastion/force-command`. In FORCE_COMMAND mode `SSH_ORIGINAL_COMMAND` is dropped entirely. In **broker mode** it is *read* but only ever run if it matches the allowlist whole-line, and even then via shell-free word-split `exec` (no `sh -c`), so the regex is a containment boundary, not just a filter — see [Command broker mode](#command-broker-mode). The bastion user's login shell is `/bin/sh` (not `nologin` — that would break ForceCommand itself, since sshd invokes the user's shell as `shell -c "<forced-command>"`), but it has no path to anything other than the wrapper.
|
|
5. **`PermitUserEnvironment no`, `PermitUserRC no`** — clients cannot inject env vars or rc files.
|
|
6. **Bind host ports to `127.0.0.1` or hide them behind traefik+TLS unless you genuinely need them publicly open on raw TCP.** The traefik path with `entrypoints: websecure` and `tls: true` is the recommended public exposure.
|
|
7. **Rotate `HTTP_TOKEN` regularly.** Generate with `openssl rand -hex 32`, store in `.env`, never commit.
|
|
8. **Keep alpine + openssh patched.** An unauth RCE in sshd or httpd here means host root. `apk upgrade` in a rebuild cycle.
|
|
9. **Lock down siblings.** Anyone who can `docker exec` into the app via this bastion can also `docker exec` into `mysql`/`redis`/etc through the same socket. `cap_drop: [ALL]` and `no-new-privileges: true` on every sibling caps the blast radius.
|
|
10. **One bastion per role.** Don't reuse a single `FORCE_COMMAND` for both interactive shells and deploy automation — separate ports and separate token/key sets make audit trails meaningful.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
start-container (entrypoint)
|
|
├─ generate host keys (idempotent, persisted via /etc/ssh/keys bind mount)
|
|
├─ merge AUTHORIZED_KEYS_HOST + AUTHORIZED_KEYS_REPO into authorized_keys
|
|
├─ pick mode: broker if $ALLOWED_COMMANDS / allowed-commands.list, else force-command
|
|
│ ├─ broker: snapshot allowlist + prefix; wrapper → bastion-broker
|
|
│ └─ force-command: write /etc/bastion/force-command wrapper from $FORCE_COMMAND
|
|
├─ align docker socket group membership to host GID (if socket is mounted)
|
|
├─ start httpd → /var/www/cgi-bin/run (if $HTTP_BASIC_AUTH or $HTTP_TOKEN is set)
|
|
└─ exec sshd -D -e
|
|
|
|
FORCE_COMMAND mode broker mode
|
|
------------------ -----------
|
|
ssh client ssh client
|
|
└ key auth as `agent` └ key auth as `agent`
|
|
└ ForceCommand wrapper └ ForceCommand wrapper
|
|
└ exec sh -c "$FORCE_COMMAND" └ exec bastion-broker "$SSH_ORIGINAL_COMMAND"
|
|
├ match vs allowlist (whole-line, anchored)
|
|
http client └ exec $COMMAND_PREFIX $request (no shell)
|
|
└ Authorization header
|
|
└ /cgi-bin/run validates auth http client
|
|
└ exec force-command wrapper └ Authorization header
|
|
└ exec sh -c "$FORCE_COMMAND" └ /cgi-bin/run validates auth
|
|
└ exec bastion-broker "$X-Bastion-Command"
|
|
```
|
|
|
|
## License
|
|
|
|
MIT.
|
|
|
|
## Star History
|
|
|
|
<a href="https://www.star-history.com/?repos=blax-software%2Fdocker-bastion&type=date&legend=top-left">
|
|
<picture>
|
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=blax-software/docker-bastion&type=date&theme=dark&legend=top-left" />
|
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=blax-software/docker-bastion&type=date&legend=top-left" />
|
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=blax-software/docker-bastion&type=date&legend=top-left" />
|
|
</picture>
|
|
</a>
|