Compare commits

..

3 Commits

Author SHA1 Message Date
Xe Iaso
63d557b96e Update metadata
check-spelling run (pull_request) for Xe/actorify

Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
on-behalf-of: @check-spelling <check-spelling-bot@check-spelling.dev>
2025-09-12 18:30:49 +00:00
Xe Iaso
d718792881 docs(internal/actorify): document package
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-12 18:27:24 +00:00
Xe Iaso
b28840f1a9 feat(store/bbolt): implement actor pattern
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-12 18:01:55 +00:00
15 changed files with 49 additions and 234 deletions

View File

@@ -12,14 +12,12 @@ permissions:
jobs:
ssh:
if: github.repository == 'TecharoHQ/anubis'
runs-on: alrest-techarohq
runs-on: ubuntu-24.04
strategy:
matrix:
host:
- riscv64
- ppc64le
- aarch64-4k
- aarch64-16k
- ubuntu@riscv64.techaro.lol
- ci@ppc64le.techaro.lol
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

View File

@@ -56,7 +56,6 @@ var (
forcedLanguage = flag.String("forced-language", "", "if set, this language is being used instead of the one from the request's Accept-Language header")
hs512Secret = flag.String("hs512-secret", "", "secret used to sign JWTs, uses ed25519 if not set")
cookieSecure = flag.Bool("cookie-secure", true, "if true, sets the secure flag on Anubis cookies")
cookieSameSite = flag.String("cookie-same-site", "None", "sets the same site option on Anubis cookies, will auto-downgrade None to Lax if cookie-secure is false. Valid values are None, Lax, Strict, and Default.")
ed25519PrivateKeyHex = flag.String("ed25519-private-key-hex", "", "private key used to sign JWTs, if not set a random one will be assigned")
ed25519PrivateKeyHexFile = flag.String("ed25519-private-key-hex-file", "", "file name containing value for ed25519-private-key-hex")
metricsBind = flag.String("metrics-bind", ":9090", "network address to bind metrics to")
@@ -144,22 +143,6 @@ func parseBindNetFromAddr(address string) (string, string) {
return "", address
}
func parseSameSite(s string) (http.SameSite) {
switch strings.ToLower(s) {
case "none":
return http.SameSiteNoneMode
case "lax":
return http.SameSiteLaxMode
case "strict":
return http.SameSiteStrictMode
case "default":
return http.SameSiteDefaultMode
default:
log.Fatalf("invalid cookie same-site mode: %s, valid values are None, Lax, Strict, and Default", s)
}
return http.SameSiteDefaultMode
}
func setupListener(network string, address string) (net.Listener, string) {
formattedAddress := ""
@@ -449,7 +432,6 @@ func main() {
WebmasterEmail: *webmasterEmail,
OpenGraph: policy.OpenGraph,
CookieSecure: *cookieSecure,
CookieSameSite: parseSameSite(*cookieSameSite),
PublicUrl: *publicUrl,
JWTRestrictionHeader: *jwtRestrictionHeader,
DifficultyInJWT: *difficultyInJWT,

View File

@@ -13,8 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- This changes the project to: -->
- Add `contentLength` variable to bot expressions.
- Add `COOKIE_SAME_SITE_MODE` to force anubis cookies SameSite value, and downgrade automatically from `None` to `Lax` if cookie is insecure.
- Fix lock convoy problem in decaymap ([#1103](https://github.com/TecharoHQ/anubis/issues/1103)).
- Fix lock convoy problem in bbolt by implementing the actor pattern ([#1103](https://github.com/TecharoHQ/anubis/issues/1103)).
- Document missing environment variables in installation guide: `SLOG_LEVEL`, `COOKIE_PREFIX`, `FORCED_LANGUAGE`, and `TARGET_DISABLE_KEEPALIVE` ([#1086](https://github.com/TecharoHQ/anubis/pull/1086)).

View File

@@ -126,34 +126,6 @@ Your directory tree should look like this, assuming your data is in `./your_fold
For an example directory tree using some off-the-shelf images the Tango icon set, see the [testdata](https://github.com/TecharoHQ/botstopper/tree/main/testdata/static/img) folder.
### Header-based overlay dispatch
If you run BotStopper in a multi-tenant environment where each tenant needs its own branding, BotStopper supports the ability to use request header values to direct asset reads to different folders under your `OVERLAY_FOLDER`. One of the most common ways to do this is based on the HTTP Host of the request. For example, if you set `ASSET_LOOKUP_HEADER=Host` in BotStopper's environment:
```text
$OVERLAY_FOLDER
├── static
│ ├── css
│ │ ├── custom.css
│ │ └── eyesore.css
│ └── img
│ ├── happy.webp
│ ├── pensive.webp
│ └── reject.webp
└── test.anubis.techaro.lol
└── static
├── css
│ └── custom.css
└── img
├── happy.webp
├── pensive.webp
└── reject.webp
```
Requests to `test.anubis.techaro.lol` will load assets in `$OVERLAY_FOLDER/test.anubis.techaro.lol/static` and all other requests will load them from `$OVERLAY_FOLDER/static`.
For an example, look at [the testdata folder in the BotStopper repo](https://github.com/TecharoHQ/botstopper/tree/main/testdata).
### Custom CSS
CSS customization is done mainly with CSS variables. View [the example custom CSS file](https://github.com/TecharoHQ/botstopper/blob/main/testdata/static/css/custom.css) for more information about what can be customized.
@@ -227,9 +199,7 @@ $ du -hs *
## Custom HTML templates
If you need to completely control the HTML layout of all Anubis pages, you can customize the entire page with `USE_TEMPLATES=true`. This uses Go's standard library [html/template](https://pkg.go.dev/html/template) package to template HTML responses. Your templates can contain whatever HTML you want. The only catch is that you MUST include `{{ .Head }}` in the `<head>` element for challenge pages, and you MUST include `{{ .Body }}` in the `<body>` element for all pages.
In order to use this, you must define the following templates:
If you need to completely control the HTML layout of all Anubis pages, you can customize the entire page with `USE_TEMPLATES=true`. This uses Go's standard library [html/template](https://pkg.go.dev/html/template) package to template HTML responses. In order to use this, you must define the following templates:
| Template path | Usage |
| :----------------------------------------- | :---------------------------------------------- |
@@ -237,12 +207,6 @@ In order to use this, you must define the following templates:
| `$OVERLAY_FOLDER/templates/error.tmpl` | Error pages |
| `$OVERLAY_FOLDER/templates/impressum.tmpl` | [Impressum](./configuration/impressum.mdx) page |
:::note
Currently HTML templates don't work together with [Header-based overlay dispatch](#header-based-overlay-dispatch). This is a known issue that will be fixed soon. If you enable header-based overlay dispatch, BotStopper will use the global `templates` folder instead of using the templates present in the overlay.
:::
Here are minimal (but working) examples for each template:
<details>

View File

@@ -3,7 +3,6 @@
Anubis supports multiple challenge methods:
- [Meta Refresh](./metarefresh.mdx)
- [Preact](./preact.mdx)
- [Proof of Work](./proof-of-work.mdx)
Read the documentation to know which method is best for you.

View File

@@ -103,7 +103,6 @@ Anubis exposes the following variables to expressions:
| :-------------- | :-------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------- |
| `headers` | `map[string, string]` | The [headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers) of the request being processed. | `{"User-Agent": "Mozilla/5.0 Gecko/20100101 Firefox/137.0"}` |
| `host` | `string` | The [HTTP hostname](https://web.dev/articles/url-parts#host) the request is targeted to. | `anubis.techaro.lol` |
| `contentLength` | `int64` | The numerical value of the `Content-Length` header. |
| `load_1m` | `double` | The current system load average over the last one minute. This is useful for making [load-based checks](#using-the-system-load-average). |
| `load_5m` | `double` | The current system load average over the last five minutes. This is useful for making [load-based checks](#using-the-system-load-average). |
| `load_15m` | `double` | The current system load average over the last fifteen minutes. This is useful for making [load-based checks](#using-the-system-load-average). |

View File

@@ -2,16 +2,8 @@
title: Setting up Anubis
---
import EnterpriseOnly from "@site/src/components/EnterpriseOnly";
import RandomKey from "@site/src/components/RandomKey";
export const EO = () => (
<>
<EnterpriseOnly link="./botstopper/" />
<div style={{ marginBottom: "0.5rem" }} />
</>
);
Anubis is meant to sit between your reverse proxy (such as Nginx or Caddy) and your target service. One instance of Anubis must be used per service you are protecting.
<center>
@@ -67,31 +59,26 @@ Currently the following settings are configurable via the policy file:
Anubis uses these environment variables for configuration:
| Environment Variable | Default value | Explanation |
| :----------------------------- | :---------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ASSET_LOOKUP_HEADER` | unset | <EO /> If set, use the contents of this header in requests when looking up custom assets in `OVERLAY_FOLDER`. See [Header-based overlay dispatch](./botstopper.mdx#header-based-overlay-dispatch) for more details. |
| :----------------------------- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `BASE_PREFIX` | unset | If set, adds a global prefix to all Anubis endpoints (everything starting with `/.within.website/x/anubis/`). For example, setting this to `/myapp` would make Anubis accessible at `/myapp/` instead of `/`. This is useful when running Anubis behind a reverse proxy that routes based on path prefixes. |
| `BIND` | `:8923` | The network address that Anubis listens on. For `unix`, set this to a path: `/run/anubis/instance.sock` |
| `BIND_NETWORK` | `tcp` | The address family that Anubis listens on. Accepts `tcp`, `unix` and anything Go's [`net.Listen`](https://pkg.go.dev/net#Listen) supports. |
| `CHALLENGE_TITLE` | unset | <EO /> If set, override the translation stack to show a custom title for challenge pages such as "Making sure your connection is secure!". See [Customizing messages](./botstopper.mdx#customizing-messages) for more details. |
| `COOKIE_DOMAIN` | unset | The domain the Anubis challenge pass cookie should be set to. This should be set to the domain you bought from your registrar (EG: `techaro.lol` if your webapp is running on `anubis.techaro.lol`). See this [stackoverflow explanation of cookies](https://stackoverflow.com/a/1063760) for more information.<br/><br/>Note that unlike `REDIRECT_DOMAINS`, you should never include a port number in this variable. |
| `COOKIE_DYNAMIC_DOMAIN` | false | If set to true, automatically set cookie domain fields based on the hostname of the request. EG: if you are making a request to `anubis.techaro.lol`, the Anubis cookie will be valid for any subdomain of `techaro.lol`. |
| `COOKIE_EXPIRATION_TIME` | `168h` | The amount of time the authorization cookie is valid for. |
| `COOKIE_PARTITIONED` | `false` | If set to `true`, enables the [partitioned (CHIPS) flag](https://developers.google.com/privacy-sandbox/cookies/chips), meaning that Anubis inside an iframe has a different set of cookies than the domain hosting the iframe. |
| `COOKIE_PREFIX` | `anubis-cookie` | The prefix used for browser cookies created by Anubis. Useful for customization or avoiding conflicts with other applications. |
| `COOKIE_SECURE` | `true` | If set to `true`, enables the [Secure flag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#block_access_to_your_cookies), meaning that the cookies will only be transmitted over HTTPS. If Anubis is used in an unsecure context (plain HTTP), this will be need to be set to false |
| `COOKIE_SAME_SITE` | `None` | Controls the cookies [`SameSite` attribute](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value). Allowed: `None`, `Lax`, `Strict`, `Default`. `None` permits cross-site use but modern browsers require it to be **Secure**—so if `COOKIE_SECURE=false` or you serve over plain HTTP, use `Lax` (recommended) or `Strict` or the cookie will be rejected. `Default` uses the Go runtimes `SameSiteDefaultMode`. `None` will be downgraded to `Lax` automatically if cookie is set NOT to be secure. |
| `DIFFICULTY` | `4` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. |
| `DIFFICULTY_IN_JWT` | `false` | If set to `true`, adds the `difficulty` field into JWT claims, which indicates the difficulty the token has been generated. This may be useful for statistics and debugging. |
| `ED25519_PRIVATE_KEY_HEX` | unset | The hex-encoded ed25519 private key used to sign Anubis responses. If this is not set, Anubis will generate one for you. This should be exactly 64 characters long. **Required when using persistent storage backends** (like bbolt) to ensure challenges survive service restarts. When running multiple instances on the same base domain, the key must be the same across all instances. See below for details. |
| `ED25519_PRIVATE_KEY_HEX_FILE` | unset | Path to a file containing the hex-encoded ed25519 private key. Only one of this or its sister option may be set. **Required when using persistent storage backends** (like bbolt) to ensure challenges survive service restarts. When running multiple instances on the same base domain, the key must be the same across all instances. |
| `ERROR_TITLE` | unset | <EO /> If set, override the translation stack to show a custom title for error pages such as "Something went wrong!". See [Customizing messages](./botstopper.mdx#customizing-messages) for more details. |
| `JWT_RESTRICTION_HEADER` | `X-Real-IP` | If set, the JWT is only valid if the current value of this header matches the value when the JWT was created. You can use it e.g. to restrict a JWT to the source IP of the user using `X-Real-IP`. |
| `METRICS_BIND` | `:9090` | The network address that Anubis serves Prometheus metrics on. See `BIND` for more information. |
| `METRICS_BIND_NETWORK` | `tcp` | The address family that the Anubis metrics server listens on. See `BIND_NETWORK` for more information. |
| `OG_EXPIRY_TIME` | `24h` | The expiration time for the Open Graph tag cache. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. |
| `OG_PASSTHROUGH` | `false` | If set to `true`, Anubis will enable Open Graph tag passthrough. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. |
| `OG_CACHE_CONSIDER_HOST` | `false` | If set to `true`, Anubis will consider the host in the Open Graph tag cache key. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. |
| `OVERLAY_FOLDER` | unset | <EO /> If set, treat the given path as an [overlay folder](./botstopper.mdx#custom-images-and-css), allowing you to customize CSS, fonts, images, and add other assets to BotStopper deployments. |
| `POLICY_FNAME` | unset | The file containing [bot policy configuration](./policies.mdx). See the bot policy documentation for more details. If unset, the default bot policy configuration is used. |
| `PUBLIC_URL` | unset | The externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for Traefik forwardAuth). |
| `REDIRECT_DOMAINS` | unset | If set, restrict the domains that Anubis can redirect to when passing a challenge.<br/><br/>If this is unset, Anubis may redirect to any domain which could cause security issues in the unlikely case that an attacker passes a challenge for your browser and then tricks you into clicking a link to your domain.<br/><br/>Note that if you are hosting Anubis on a non-standard port (`https://example:com:8443`, `http://www.example.net:8080`, etc.), you must also include the port number here. |
@@ -102,7 +89,6 @@ Anubis uses these environment variables for configuration:
| `TARGET` | `http://localhost:3923` | The URL of the service that Anubis should forward valid requests to. Supports Unix domain sockets, set this to a URI like so: `unix:///path/to/socket.sock`. |
| `USE_REMOTE_ADDRESS` | unset | If set to `true`, Anubis will take the client's IP from the network socket. For production deployments, it is expected that a reverse proxy is used in front of Anubis, which pass the IP using headers, instead. |
| `USE_SIMPLIFIED_EXPLANATION` | false | If set to `true`, replaces the text when clicking "Why am I seeing this?" with a more simplified text for a non-tech-savvy audience. |
| `USE_TEMPLATES` | false | <EO /> If set to `true`, enable [custom HTML template support](./botstopper.mdx#custom-html-templates), allowing you to completely rewrite how BotStopper renders its HTML pages. |
| `WEBMASTER_EMAIL` | unset | If set, shows a contact email address when rendering error pages. This email address will be how users can get in contact with administrators. |
| `XFF_STRIP_PRIVATE` | `true` | If set, strip private addresses from `X-Forwarded-For` headers. To unset this, you must set `XFF_STRIP_PRIVATE=false` or `--xff-strip-private=false`. |

View File

@@ -1,11 +0,0 @@
import styles from './styles.module.css';
export default function EnterpriseOnly({ link }) {
return (
<a className={styles.link} href={link}>
<div className={styles.container}>
<span className={styles.label}>BotStopper Only</span>
</div>
</a>
);
}

View File

@@ -1,18 +0,0 @@
.link {
text-decoration: none;
}
.container {
background-color: #16a34a; /* green-500 */
color: #ffffff;
font-weight: 700;
padding: 0.5rem 1rem; /* py-2 px-4 */
border-radius: 9999px; /* rounded-full */
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); /* shadow-lg approximation */
display: inline-flex; /* flex */
align-items: center; /* items-center */
}
.label {
line-height: 1;
}

View File

@@ -299,7 +299,6 @@ func TestCookieSettings(t *testing.T) {
CookieDomain: "127.0.0.1",
CookiePartitioned: true,
CookieSecure: true,
CookieSameSite: http.SameSiteNoneMode,
CookieExpiration: anubis.CookieDefaultExpirationTime,
})
@@ -340,65 +339,6 @@ func TestCookieSettings(t *testing.T) {
if ckie.Secure != srv.opts.CookieSecure {
t.Errorf("wanted secure flag %v, got: %v", srv.opts.CookieSecure, ckie.Secure)
}
if ckie.SameSite != srv.opts.CookieSameSite {
t.Errorf("wanted same site option %v, got: %v", srv.opts.CookieSameSite, ckie.SameSite)
}
}
func TestCookieSettingsSameSiteNoneModeDowngradedToLaxWhenUnsecure(t *testing.T) {
pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0)
srv := spawnAnubis(t, Options{
Next: http.NewServeMux(),
Policy: pol,
CookieDomain: "127.0.0.1",
CookiePartitioned: true,
CookieSecure: false,
CookieSameSite: http.SameSiteNoneMode,
CookieExpiration: anubis.CookieDefaultExpirationTime,
})
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
defer ts.Close()
cli := httpClient(t)
chall := makeChallenge(t, ts, cli)
resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
if resp.StatusCode != http.StatusFound {
resp.Write(os.Stderr)
t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
}
var ckie *http.Cookie
for _, cookie := range resp.Cookies() {
t.Logf("%#v", cookie)
if cookie.Name == anubis.CookieName {
ckie = cookie
break
}
}
if ckie == nil {
t.Errorf("Cookie %q not found", anubis.CookieName)
return
}
if ckie.Domain != "127.0.0.1" {
t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain)
}
if ckie.Partitioned != srv.opts.CookiePartitioned {
t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
}
if ckie.Secure != srv.opts.CookieSecure {
t.Errorf("wanted secure flag %v, got: %v", srv.opts.CookieSecure, ckie.Secure)
}
if ckie.SameSite != http.SameSiteLaxMode {
t.Errorf("wanted same site Lax option %v, got: %v", http.SameSiteLaxMode, ckie.SameSite)
}
}
func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {

View File

@@ -43,7 +43,6 @@ type Options struct {
OpenGraph config.OpenGraph
ServeRobotsTXT bool
CookieSecure bool
CookieSameSite http.SameSite
Logger *slog.Logger
PublicUrl string
JWTRestrictionHeader string

View File

@@ -56,8 +56,6 @@ func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
var domain = s.opts.CookieDomain
var name = anubis.CookieName
var path = "/"
var sameSite = s.opts.CookieSameSite
if cookieOpts.Name != "" {
name = cookieOpts.Name
}
@@ -74,15 +72,11 @@ func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
cookieOpts.Expiry = s.opts.CookieExpiration
}
if s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure {
sameSite = http.SameSiteLaxMode
}
http.SetCookie(w, &http.Cookie{
Name: name,
Value: cookieOpts.Value,
Expires: time.Now().Add(cookieOpts.Expiry),
SameSite: sameSite,
SameSite: http.SameSiteNoneMode,
Domain: domain,
Secure: s.opts.CookieSecure,
Partitioned: s.opts.CookiePartitioned,
@@ -94,8 +88,6 @@ func (s *Server) ClearCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
var domain = s.opts.CookieDomain
var name = anubis.CookieName
var path = "/"
var sameSite = s.opts.CookieSameSite
if cookieOpts.Name != "" {
name = cookieOpts.Name
}
@@ -107,16 +99,13 @@ func (s *Server) ClearCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
domain = etld
}
}
if s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure {
sameSite = http.SameSiteLaxMode
}
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
MaxAge: -1,
Expires: time.Now().Add(-1 * time.Minute),
SameSite: sameSite,
SameSite: http.SameSiteNoneMode,
Partitioned: s.opts.CookiePartitioned,
Domain: domain,
Secure: s.opts.CookieSecure,

View File

@@ -61,8 +61,6 @@ func (cr *CELRequest) ResolveName(name string) (any, bool) {
switch name {
case "remoteAddress":
return cr.Header.Get("X-Real-Ip"), true
case "contentLength":
return cr.ContentLength, true
case "host":
return cr.Host, true
case "method":

View File

@@ -19,7 +19,6 @@ func BotEnvironment() (*cel.Env, error) {
return New(
// Variables exposed to CEL programs:
cel.Variable("remoteAddress", cel.StringType),
cel.Variable("contentLength", cel.IntType),
cel.Variable("host", cel.StringType),
cel.Variable("method", cel.StringType),
cel.Variable("userAgent", cel.StringType),

View File

@@ -7,32 +7,25 @@ if [ "$#" -ne 1 ]; then
echo "Usage: rigging.sh <user@host>"
fi
declare -A Hosts
Hosts["riscv64"]="ubuntu@riscv64.techaro.lol" # GOARCH=riscv64 GOOS=linux
Hosts["ppc64le"]="ci@ppc64le.techaro.lol" # GOARCH=ppc64le GOOS=linux
Hosts["aarch64-4k"]="rocky@192.168.2.52" # GOARCH=arm64 GOOS=linux 4k page size
Hosts["aarch64-16k"]="ci@192.168.2.28" # GOARCH=arm64 GOOS=linux 16k page size
CIRunnerImage="ghcr.io/techarohq/anubis/ci-runner:latest"
RunID=${GITHUB_RUN_ID:-$(uuidgen)}
RunFolder="anubis/runs/${RunID}"
Target="${Hosts["$1"]}"
Target="${1}"
ssh "${Target}" uname -av >/dev/null
ssh "${Target}" uname -av
ssh "${Target}" mkdir -p "${RunFolder}"
git archive HEAD | ssh "${Target}" tar xC "${RunFolder}"
ssh "${Target}" << EOF
set -euo pipefail
set -x
mkdir -p anubis/cache/{go,go-build,node}
mkdir -p "anubis/cache/{go,go-build,node}"
podman pull ${CIRunnerImage}
podman run --rm -it \
-v "\$HOME/${RunFolder}:/app/anubis:z" \
-v "\$HOME/anubis/cache/go:/root/go:z" \
-v "\$HOME/anubis/cache/go-build:/root/.cache/go-build:z" \
-v "\$HOME/anubis/cache/node:/root/.npm:z" \
-v "\$HOME/${RunFolder}:/app/anubis" \
-v "\$HOME/anubis/cache/go:/root/go" \
-v "\$HOME/anubis/cache/go-build:/root/.cache/go-build" \
-v "\$HOME/anubis/cache/node:/root/.npm" \
-w /app/anubis \
${CIRunnerImage} \
sh /app/anubis/test/ssh-ci/in-container.sh