refactor: Remove inbound IP allowlisting (#443)

## Summary

Removes Fabro's in-process inbound source-IP allowlist entirely.
`[server.ip_allowlist]` and
`[server.integrations.github.webhooks.ip_allowlist]` are gone from
config parsing, resolved settings types, the OpenAPI spec, generated API
clients, and the Settings > Security UI. Existing `settings.toml` files
containing those keys now fail as unknown fields — this is a hard
removal with no migration path.

Network source restrictions should be enforced upstream via a reverse
proxy, firewall, VPN, Tailscale ACLs, Kubernetes ingress, or platform
policy.

### What changed

- **Config/types** (`fabro-config`, `fabro-types`): Removed
`ServerIpAllowlistLayer`, `ServerIpAllowlistOverrideLayer`,
`ServerIpAllowlistSettings`, `ServerIpAllowlistOverrideSettings`,
`IpAllowEntry`, associated resolver functions, GitHub `/meta` hook-range
parsing, and Unix socket trusted-proxy validation. `ipnet` dropped from
`fabro-types`; kept in `fabro-config` for sandbox CIDR validation.
- **Server runtime** (`fabro-server`): Deleted `ip_allowlist.rs`,
removed `IpAllowlistConfig` parameter from `build_router_with_options`
and `RouterOptions`, removed the global allowlist middleware layer, and
removed `GitHubMetaResolver` startup logic. GitHub webhook HMAC
verification is unchanged.
- **OpenAPI + generated clients**: Removed `ServerIpAllowlistSettings`,
`ServerIpAllowlistOverrideSettings`, `IpAllowEntry`,
`LiteralIpAllowEntry`, `GitHubMetaHooksEntry` schemas; removed
`ip_allowlist` from `ServerNamespace` and `IntegrationWebhooksSettings`;
dropped `IpAllowEntry` re-exports from `fabro-api`.
- **Web UI**: Removed IP allowlist row from Settings > Security; updated
nav description and page copy.
- **Docs/changelog**: Security docs explicitly state Fabro provides no
source-IP filtering and direct operators upstream. Changelog entry dated
2026-05-27 documents the breaking removal and annotates the 2026-04-19
entry where the feature was introduced.

### Also in this diff (unrelated to IP allowlisting)

The worker control stream was migrated from reading newline-delimited
JSON on stdin to a reconnecting WebSocket
(`/api/v1/runs/{id}/worker/control-stream`). This adds
`tokio-tungstenite` to `fabro-cli`/`fabro-server`, introduces
`WorkerControlManagerHandle` with backoff reconnection and deduplication
of replayed delivery IDs, and adds `RunPause`/`RunUnpause` message
handling. A new integration test
(`detached_run_cancel_reaches_worker_over_control_websocket`) exercises
the full cancel path over the WebSocket.

### Key decisions

- **Hard removal via `deny_unknown_fields`**: stale config is
immediately visible as a startup error rather than silently ignored.
- **No stub or default pass-through**: `IpAllowlistConfig::default()` is
gone, not left as a no-op wrapper, to avoid keeping the feature shape
alive.
- **Webhook HMAC boundary unchanged**: source-IP filtering on webhook
routes is removed; cryptographic signature verification remains the
security boundary.


### Fabro Details

<details>
<summary>Ran 9 stages in 59m 53s for $27.24</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 15s | – | 0 |
| preflight_lint | 2m 22s | – | 0 |
| implement | 33m 54s | $22.81 | 0 |
| simplify_opus | 5m 55s | $0.75 | 0 |
| simplify_gpt | 3m 35s | $2.81 | 0 |
| verify | 8m 34s | – | 0 |
| fixup | 2m 24s | $0.87 | 0 |
| **Total** | **59m 53s** | **$27.24** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-27 22:29:08 -04:00 • committed by GitHub
parent 475b4ab650
commit 29a9a3f7d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 183 additions and 1636 deletions

5
Cargo.lock generated
View file

@ -2389,7 +2389,6 @@ dependencies = [
"hmac",
"http-body-util",
"httpmock",
"ipnet",
"jsonwebtoken",
"mime_guess",
"multer",
@ -2592,7 +2591,6 @@ dependencies = [
"fabro-model",
"fabro-util",
"hex",
"ipnet",
"serde",
"serde_json",
"sha2",
@ -3856,9 +3854,6 @@ name = "ipnet"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
dependencies = [
"serde",
]
[[package]]
name = "iri-string"

View file

@ -2,7 +2,6 @@ import type { ServerSettings } from "@qltysh/fabro-api-client";
import { useServerSettings } from "../lib/queries";
import {
Badge,
Count,
Muted,
Panel,
PanelSkeleton,
@ -10,7 +9,6 @@ import {
SettingsPageIntro,
UsernameList,
} from "../components/settings-panel";
import { plural } from "../lib/plural";
export function meta() {
return [{ title: "Security — Fabro" }];
@ -18,7 +16,7 @@ export function meta() {
const DESCRIPTION = (
<>
Authentication methods and network allowlist. Edit via{" "}
Authentication methods and permitted GitHub usernames. Edit via{" "}
<code className="font-mono text-fg-2">settings.toml</code>; changes take
effect on the next server restart.
</>
@ -37,7 +35,7 @@ export default function SettingsSecurity() {
}
function SecurityPanel({ settings }: { settings: ServerSettings }) {
const { auth, ip_allowlist } = settings.server;
const { auth } = settings.server;
const githubUsers = auth.github.allowed_usernames;
return (
<Panel title="Security">
@ -62,18 +60,6 @@ function SecurityPanel({ settings }: { settings: ServerSettings }) {
<UsernameList names={githubUsers} />
)}
</Row>
<Row title="IP allowlist" help="Network sources permitted to reach the API.">
<Count
n={ip_allowlist.entries.length}
singular="entry"
plural="entries"
suffix={
ip_allowlist.trusted_proxy_count > 0
? `· ${ip_allowlist.trusted_proxy_count} trusted ${plural(ip_allowlist.trusted_proxy_count, "proxy", "proxies")}`
: undefined
}
/>
</Row>
</Panel>
);
}

View file

@ -63,7 +63,7 @@ export const navSections: NavSection[] = [
name: "Security",
href: "/settings/security",
icon: ShieldCheckIcon,
description: "Authentication and network allowlist.",
description: "Authentication methods and access.",
match: (p) => p.startsWith("/settings/security"),
},
{

View file

@ -15,6 +15,7 @@ Fabro is single-tenant software designed for small, trusted teams. The following
|---|---|
| **Multi-tenancy** | Not supported. Fabro is single-tenant only — there is no organization or tenant isolation. |
| **Roles or ACLs** | Not supported. All authenticated users have identical, full access to every run, workflow, and API endpoint. |
| **Inbound source-IP allowlisting** | Not implemented in Fabro. Restrict source networks with a reverse proxy, firewall, VPN, Tailscale, platform ingress policy, or another deployment-layer control. |
| **Rate limiting** | Not implemented. The API server does not throttle requests. |
| **Custom security header policy** | Not configurable. Fabro emits default security headers, including enforced `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, and `Strict-Transport-Security` when HTTPS is detected. |
@ -24,6 +25,7 @@ Fabro is single-tenant software designed for small, trusted teams. The following
- **Deploy the web app on a private network.** Use a Tailscale tailnet, VPN, or internal network to keep the web UI off the public internet.
- **Deploy the API on a private network.** Only allow access from expected hosts (the web app, CI runners, developer machines). The API binds to `127.0.0.1` by default — do not expose it with `--host 0.0.0.0` unless it is behind a firewall or private network.
- **Enforce source-IP restrictions upstream.** Fabro does not provide inbound source-IP allowlisting. Use your reverse proxy, firewall, VPN, Tailscale ACLs, cloud load balancer, Kubernetes ingress, or platform policy to limit who can connect.
- **Use Daytona sandboxes with network controls.** When running untrusted or generated code, use the Daytona sandbox provider with `network = "block"` or a CIDR-based allow list to restrict agent egress.
### Authentication

View file

@ -12280,7 +12280,6 @@ components:
- api
- web
- auth
- ip_allowlist
- sandbox
- storage
- artifacts
@ -12297,8 +12296,6 @@ components:
$ref: "#/components/schemas/ServerWebSettings"
auth:
$ref: "#/components/schemas/ServerAuthSettings"
ip_allowlist:
$ref: "#/components/schemas/ServerIpAllowlistSettings"
sandbox:
$ref: "#/components/schemas/ServerSandboxSettings"
storage:
@ -12379,44 +12376,6 @@ components:
items:
type: string
ServerIpAllowlistSettings:
type: object
required: [entries, trusted_proxy_count]
properties:
entries:
type: array
items:
$ref: "#/components/schemas/IpAllowEntry"
trusted_proxy_count:
type: integer
ServerIpAllowlistOverrideSettings:
type: object
required: [entries, trusted_proxy_count]
properties:
entries:
type: ["array", "null"]
items:
$ref: "#/components/schemas/IpAllowEntry"
trusted_proxy_count:
type: ["integer", "null"]
IpAllowEntry:
oneOf:
- $ref: "#/components/schemas/LiteralIpAllowEntry"
- $ref: "#/components/schemas/GitHubMetaHooksEntry"
LiteralIpAllowEntry:
type: object
required: [Literal]
properties:
Literal:
type: string
GitHubMetaHooksEntry:
type: string
enum: [GitHubMetaHooks]
ServerSandboxSettings:
type: object
required: [providers]
@ -12571,16 +12530,12 @@ components:
IntegrationWebhooksSettings:
type: object
required: [strategy, ip_allowlist]
required: [strategy]
properties:
strategy:
oneOf:
- $ref: "#/components/schemas/WebhookStrategy"
- type: "null"
ip_allowlist:
oneOf:
- $ref: "#/components/schemas/ServerIpAllowlistOverrideSettings"
- type: "null"
WebhookStrategy:
type: string

View file

@ -51,7 +51,7 @@ The install flow is also available in the embedded web app, so Docker and hosted
- New `GET /api/v1/runs/{id}/files` endpoint returns paginated run file data and diff metadata
- New `POST /api/v1/runs/{id}/archive` and `POST /api/v1/runs/{id}/unarchive` endpoints manage archived terminal runs
- `GET /api/v1/runs` accepts `include_archived=true` for listing archived runs
- GitHub webhook handling now supports explicit source IP allowlisting with GitHub webhook range refresh
- GitHub webhook handling added explicit source IP allowlisting with GitHub webhook range refresh (removed on 2026-05-27)
</Accordion>
<Accordion title="CLI">

View file

@ -0,0 +1,23 @@
---
title: "Inbound IP allowlisting removed"
date: "2026-05-27"
---
## Inbound IP allowlisting removed
Fabro no longer includes an in-process inbound source-IP allowlist for the web UI, API, static assets, or GitHub webhook routes. The former `[server.ip_allowlist]` setting and GitHub webhook overlay at `[server.integrations.github.webhooks.ip_allowlist]` have been removed from server configuration and the settings API.
This is a hard removal: existing `settings.toml` files that still contain those keys now fail as unknown fields. Move any source-IP restrictions to deployment-layer controls such as a reverse proxy, firewall, VPN, Tailscale ACLs, Kubernetes ingress policy, cloud load balancer, or platform ingress.
GitHub webhook HMAC signature verification is unchanged. GitHub username allowlists and sandbox egress CIDR allow lists are also unchanged.
## More
<Accordion title="Breaking changes">
- Removed `server.ip_allowlist` and GitHub webhook `ip_allowlist` from `GET /api/v1/settings` responses and generated API clients
- Removed server config support for `[server.ip_allowlist]` and `[server.integrations.github.webhooks.ip_allowlist]`
</Accordion>
<Accordion title="Security">
- Fabro no longer performs runtime source-IP filtering; enforce inbound network restrictions upstream
</Accordion>

View file

@ -244,21 +244,6 @@ fn main() {
"fabro_types::settings::server::ServerAuthGithubSettings",
&[],
),
(
"ServerIpAllowlistSettings",
"fabro_types::settings::server::ServerIpAllowlistSettings",
&[],
),
(
"ServerIpAllowlistOverrideSettings",
"fabro_types::settings::server::ServerIpAllowlistOverrideSettings",
&[],
),
(
"IpAllowEntry",
"fabro_types::settings::server::IpAllowEntry",
&[],
),
(
"ServerSandboxSettings",
"fabro_types::settings::server::ServerSandboxSettings",

View file

@ -26,9 +26,8 @@ pub mod types {
pub use fabro_types::settings::ServerNamespace;
pub use fabro_types::settings::server::{
GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings,
IpAllowEntry, LogDestination, ObjectStoreSettings, ServerApiSettings,
ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings,
ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings,
LogDestination, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings,
ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsSettings,
ServerListenSettings, ServerLoggingSettings, ServerSandboxProviderSettings,
ServerSandboxProvidersSettings, ServerSandboxSettings, ServerSchedulerSettings,
ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,

View file

@ -64,6 +64,9 @@ strategy = "app"
app_id = "12345"
client_id = "Iv1.abcdef"
slug = "fabro-dev"
[server.integrations.github.webhooks]
strategy = "tailscale_funnel"
"#,
)
.expect("settings should resolve");
@ -85,6 +88,16 @@ slug = "fabro-dev"
json["server"]["sandbox"]["providers"]["daytona"]["enabled"],
false
);
assert!(
json["server"].get("ip_allowlist").is_none(),
"server settings API should not expose removed IP allowlist settings"
);
assert!(
json["server"]["integrations"]["github"]["webhooks"]
.get("ip_allowlist")
.is_none(),
"github webhook settings API should not expose removed IP allowlist settings"
);
assert!(json.get("features").is_none());
let round_trip: ApiServerSettings =

View file

@ -21,7 +21,6 @@ use chrono::{Duration as ChronoDuration, Utc};
use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, ServerTarget};
use fabro_config::{RunLayer, ServerSettingsBuilder};
use fabro_server::auth::GithubEndpoints;
use fabro_server::ip_allowlist::IpAllowlistConfig;
use fabro_server::jwt_auth::resolve_auth_mode_with_lookup;
use fabro_server::server::{RouterOptions, build_router_with_options};
use fabro_server::test_support::TestAppStateBuilder;
@ -92,21 +91,15 @@ impl RealAuthHarness {
.vault_entries([("GITHUB_APP_CLIENT_SECRET", github_client_secret.as_str())])
.build();
let github_base = github_base_url(&twin.base_url);
let router = build_router_with_options(
state,
&auth_mode,
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
web_enabled: true,
github_endpoints: Some(Arc::new(GithubEndpoints::with_bases(
github_base.clone(),
github_base,
))),
github_webhook_ip_allowlist: None,
static_asset_root: None,
watch_web: false,
},
);
let router = build_router_with_options(state, &auth_mode, RouterOptions {
web_enabled: true,
github_endpoints: Some(Arc::new(GithubEndpoints::with_bases(
github_base.clone(),
github_base,
))),
static_asset_root: None,
watch_web: false,
});
let api_requests = ListenerRequestLog::default();
let api_server = RunningHttpServer::start(api_listener, router, &api_requests);

View file

@ -41,10 +41,10 @@ pub use run::{
pub use server::{
GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer,
ServerIntegrationsLayer, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerLayer,
ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer,
ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer,
ServerWebLayer, SlackIntegrationLayer,
ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer,
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer,
SlackIntegrationLayer,
};
pub use settings::SettingsLayer;
pub use workflow::WorkflowLayer;

View file

@ -21,8 +21,6 @@ pub struct ServerLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<ServerAuthLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ip_allowlist: Option<ServerIpAllowlistLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<ServerSandboxLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<ServerStorageLayer>,
@ -93,24 +91,6 @@ pub struct ServerAuthGithubLayer {
pub allowed_usernames: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerIpAllowlistLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entries: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted_proxy_count: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerIpAllowlistOverrideLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entries: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted_proxy_count: Option<u32>,
}
/// `[server.sandbox]` — server-owned sandbox provider policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
@ -261,7 +241,5 @@ pub struct SlackIntegrationLayer {
#[serde(deny_unknown_fields)]
pub struct IntegrationWebhooksLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strategy: Option<WebhookStrategy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ip_allowlist: Option<ServerIpAllowlistOverrideLayer>,
pub strategy: Option<WebhookStrategy>,
}

View file

@ -55,11 +55,10 @@ pub use layers::{
RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer,
RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer,
RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer,
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer,
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer,
SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer,
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer,
ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer,
ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer,
ServerWebLayer, SettingsLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer,
};
pub use logging::{resolve_log_destination, resolve_log_destination_with_env};
pub use parse::ParseError;

View file

@ -1,9 +1,8 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::server::{
GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings,
IpAllowEntry, ObjectStoreProvider, ObjectStoreSettings, ServerApiSettings,
ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings,
ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings,
ObjectStoreProvider, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings,
ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsSettings,
ServerListenSettings, ServerLoggingSettings, ServerNamespace, ServerSandboxProviderSettings,
ServerSandboxProvidersSettings, ServerSandboxSettings, ServerSchedulerSettings,
ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,
@ -15,9 +14,9 @@ use super::{ResolveError, default_interp, parse_socket_addr, require_interp};
use crate::user::default_storage_dir;
use crate::{
IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer,
ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerSandboxLayer,
ServerSandboxProviderLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer,
ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer,
ServerSandboxLayer, ServerSandboxProviderLayer, ServerSlateDbLayer, ServerStorageLayer,
ServerWebLayer,
};
pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> ServerNamespace {
@ -25,10 +24,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> Se
let listen = resolve_listen(layer.listen.as_ref(), errors);
let web = resolve_web(layer.web.as_ref());
let auth = resolve_auth(layer.auth.as_ref(), errors);
let ip_allowlist = resolve_ip_allowlist(layer.ip_allowlist.as_ref(), errors);
let integrations = resolve_integrations(layer.integrations.as_ref(), errors);
validate_ip_allowlist_for_listen(&listen, &ip_allowlist, errors);
validate_github_webhook_ip_allowlist_for_listen(&listen, &ip_allowlist, &integrations, errors);
let integrations = resolve_integrations(layer.integrations.as_ref());
validate_github_webhook_strategy(&integrations, layer.api.as_ref(), errors);
ServerNamespace {
@ -38,7 +34,6 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> Se
},
web,
auth,
ip_allowlist,
sandbox: resolve_sandbox(layer.sandbox.as_ref()),
storage: storage.clone(),
artifacts: resolve_artifacts(layer.artifacts.as_ref(), &storage.root, errors),
@ -176,166 +171,6 @@ fn resolve_auth(
}
}
fn resolve_ip_allowlist(
layer: Option<&ServerIpAllowlistLayer>,
errors: &mut Vec<ResolveError>,
) -> ServerIpAllowlistSettings {
let entries = layer
.and_then(|allowlist| allowlist.entries.as_ref())
.map(|entries| {
resolve_ip_allow_entries(entries, "server.ip_allowlist.entries", false, errors)
})
.unwrap_or_default();
ServerIpAllowlistSettings {
entries,
trusted_proxy_count: layer
.and_then(|allowlist| allowlist.trusted_proxy_count)
.unwrap_or(0),
}
}
fn effective_ip_allowlist_settings(
global: &ServerIpAllowlistSettings,
overlay: Option<&ServerIpAllowlistOverrideSettings>,
) -> ServerIpAllowlistSettings {
let Some(overlay) = overlay else {
return global.clone();
};
ServerIpAllowlistSettings {
entries: overlay
.entries
.clone()
.unwrap_or_else(|| global.entries.clone()),
trusted_proxy_count: overlay
.trusted_proxy_count
.unwrap_or(global.trusted_proxy_count),
}
}
fn resolve_ip_allowlist_override(
layer: Option<&ServerIpAllowlistOverrideLayer>,
path: &str,
allow_github_meta_hooks: bool,
errors: &mut Vec<ResolveError>,
) -> Option<ServerIpAllowlistOverrideSettings> {
layer.map(|allowlist| ServerIpAllowlistOverrideSettings {
entries: allowlist.entries.as_ref().map(|entries| {
resolve_ip_allow_entries(
entries,
&format!("{path}.entries"),
allow_github_meta_hooks,
errors,
)
}),
trusted_proxy_count: allowlist.trusted_proxy_count,
})
}
fn resolve_ip_allow_entries(
entries: &[String],
path: &str,
allow_github_meta_hooks: bool,
errors: &mut Vec<ResolveError>,
) -> Vec<IpAllowEntry> {
entries
.iter()
.enumerate()
.filter_map(|(index, entry)| {
resolve_ip_allow_entry(
entry,
&format!("{path}[{index}]"),
allow_github_meta_hooks,
errors,
)
})
.collect()
}
fn resolve_ip_allow_entry(
entry: &str,
path: &str,
allow_github_meta_hooks: bool,
errors: &mut Vec<ResolveError>,
) -> Option<IpAllowEntry> {
if entry == IpAllowEntry::GITHUB_META_HOOKS_KEYWORD {
if allow_github_meta_hooks {
return Some(IpAllowEntry::GitHubMetaHooks);
}
errors.push(ResolveError::Invalid {
path: path.to_string(),
reason: format!(
"`{}` is only valid in server.integrations.github.webhooks.ip_allowlist.entries",
IpAllowEntry::GITHUB_META_HOOKS_KEYWORD
),
});
return None;
}
match IpAllowEntry::parse_literal(entry) {
Ok(parsed) => Some(parsed),
Err(reason) => {
errors.push(ResolveError::ParseFailure {
path: path.to_string(),
reason,
});
None
}
}
}
fn validate_ip_allowlist_for_listen(
listen: &ServerListenSettings,
ip_allowlist: &ServerIpAllowlistSettings,
errors: &mut Vec<ResolveError>,
) {
if matches!(listen, ServerListenSettings::Unix { .. })
&& !ip_allowlist.entries.is_empty()
&& ip_allowlist.trusted_proxy_count == 0
{
errors.push(ResolveError::Invalid {
path: "server.ip_allowlist.trusted_proxy_count".to_string(),
reason: "must be greater than 0 when using a Unix socket listener with a non-empty IP allowlist".to_string(),
});
}
}
fn validate_github_webhook_ip_allowlist_for_listen(
listen: &ServerListenSettings,
global_ip_allowlist: &ServerIpAllowlistSettings,
integrations: &ServerIntegrationsSettings,
errors: &mut Vec<ResolveError>,
) {
let Some(overlay) = integrations
.github
.webhooks
.as_ref()
.and_then(|webhooks| webhooks.ip_allowlist.as_ref())
else {
return;
};
let effective = effective_ip_allowlist_settings(global_ip_allowlist, Some(overlay));
if effective == *global_ip_allowlist {
return;
}
if matches!(listen, ServerListenSettings::Unix { .. })
&& !effective.entries.is_empty()
&& effective.trusted_proxy_count == 0
{
errors.push(ResolveError::Invalid {
path: "server.integrations.github.webhooks.ip_allowlist.trusted_proxy_count"
.to_string(),
reason:
"must be greater than 0 when using a Unix socket listener with a non-empty GitHub webhook IP allowlist"
.to_string(),
});
}
}
fn validate_github_webhook_strategy(
integrations: &ServerIntegrationsSettings,
api_layer: Option<&ServerApiLayer>,
@ -476,10 +311,7 @@ fn object_store_default_root(storage_root: &InterpString, domain: &str) -> Inter
InterpString::parse(&format!("{root}/objects/{domain}"))
}
fn resolve_integrations(
layer: Option<&ServerIntegrationsLayer>,
errors: &mut Vec<ResolveError>,
) -> ServerIntegrationsSettings {
fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegrationsSettings {
ServerIntegrationsSettings {
github: layer
.and_then(|integrations| integrations.github.as_ref())
@ -489,9 +321,7 @@ fn resolve_integrations(
app_id: github.app_id.clone(),
client_id: github.client_id.clone(),
slug: github.slug.clone(),
webhooks: github.webhooks.as_ref().map(|webhooks| {
resolve_github_webhooks(webhooks, "server.integrations.github.webhooks", errors)
}),
webhooks: github.webhooks.as_ref().map(resolve_github_webhooks),
})
.unwrap_or_default(),
slack: layer
@ -504,18 +334,8 @@ fn resolve_integrations(
}
}
fn resolve_github_webhooks(
layer: &IntegrationWebhooksLayer,
path: &str,
errors: &mut Vec<ResolveError>,
) -> IntegrationWebhooksSettings {
fn resolve_github_webhooks(layer: &IntegrationWebhooksLayer) -> IntegrationWebhooksSettings {
IntegrationWebhooksSettings {
strategy: layer.strategy,
ip_allowlist: resolve_ip_allowlist_override(
layer.ip_allowlist.as_ref(),
&format!("{path}.ip_allowlist"),
true,
errors,
),
strategy: layer.strategy,
}
}

View file

@ -5,7 +5,7 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::server::{
GithubIntegrationStrategy, IpAllowEntry, LogDestination, ObjectStoreSettings, ServerAuthMethod,
GithubIntegrationStrategy, LogDestination, ObjectStoreSettings, ServerAuthMethod,
ServerListenSettings, ServerNamespace,
};
use fabro_util::Home;
@ -427,92 +427,37 @@ disk_cache = true
}
#[test]
fn resolves_empty_ip_allowlist_by_default() {
let settings = resolve_server(&empty_settings_with_auth_methods());
assert!(settings.ip_allowlist.entries.is_empty());
assert_eq!(settings.ip_allowlist.trusted_proxy_count, 0);
}
#[test]
fn resolves_global_ip_allowlist_entries_and_proxy_count() {
let file = parse(
r#"
_version = 1
[server.ip_allowlist]
entries = ["10.0.0.0/8", "2001:db8::/32", "192.0.2.42"]
trusted_proxy_count = 2
"#,
);
let settings = resolve_server(&file);
assert_eq!(settings.ip_allowlist.entries, vec![
IpAllowEntry::parse_literal("10.0.0.0/8").unwrap(),
IpAllowEntry::parse_literal("2001:db8::/32").unwrap(),
IpAllowEntry::parse_literal("192.0.2.42").unwrap(),
]);
assert_eq!(settings.ip_allowlist.trusted_proxy_count, 2);
}
#[test]
fn resolves_github_webhook_ip_allowlist_overlay_with_inheritance() {
let file = parse(
r#"
fn parsing_rejects_removed_server_ip_allowlist() {
let err = r#"
_version = 1
[server.ip_allowlist]
entries = ["10.0.0.0/8"]
trusted_proxy_count = 2
"#
.parse::<SettingsLayer>()
.expect_err("removed server IP allowlist setting should be rejected");
assert!(
err.to_string().contains("ip_allowlist"),
"unexpected error: {err}"
);
}
#[test]
fn parsing_rejects_removed_github_webhook_ip_allowlist() {
let err = r#"
_version = 1
[server.integrations.github.webhooks.ip_allowlist]
entries = ["github_meta_hooks"]
"#,
"#
.parse::<SettingsLayer>()
.expect_err("removed github webhook IP allowlist setting should be rejected");
assert!(
err.to_string().contains("ip_allowlist"),
"unexpected error: {err}"
);
let settings = resolve_server(&file);
let webhook_allowlist = settings
.integrations
.github
.webhooks
.expect("github webhooks settings should resolve")
.ip_allowlist
.expect("github webhook ip allowlist overlay should resolve");
assert_eq!(
webhook_allowlist.entries,
Some(vec![IpAllowEntry::GitHubMetaHooks])
);
assert_eq!(webhook_allowlist.trusted_proxy_count, None);
}
#[test]
fn resolves_github_webhook_ip_allowlist_override_proxy_count() {
let file = parse(
r#"
_version = 1
[server.ip_allowlist]
entries = ["10.0.0.0/8"]
trusted_proxy_count = 2
[server.integrations.github.webhooks.ip_allowlist]
trusted_proxy_count = 3
"#,
);
let settings = resolve_server(&file);
let webhook_allowlist = settings
.integrations
.github
.webhooks
.expect("github webhooks settings should resolve")
.ip_allowlist
.expect("github webhook ip allowlist overlay should resolve");
assert_eq!(webhook_allowlist.entries, None);
assert_eq!(webhook_allowlist.trusted_proxy_count, Some(3));
}
#[test]
@ -558,91 +503,6 @@ strategy = "tailscale_funnel"
assert!(rendered.contains("server.integrations.github.app_id"));
}
#[test]
fn rejects_invalid_ip_allowlist_entry() {
let file = parse(
r#"
_version = 1
[server.ip_allowlist]
entries = ["10.0.0.0/33"]
"#,
);
let rendered = render_resolve_error_lines(
ServerSettingsBuilder::from_layer(&file).expect_err("invalid CIDR should fail"),
);
assert!(rendered.contains("server.ip_allowlist.entries[0]"));
}
#[test]
fn rejects_github_meta_hooks_in_global_scope() {
let file = parse(
r#"
_version = 1
[server.ip_allowlist]
entries = ["github_meta_hooks"]
"#,
);
let rendered = render_resolve_error_lines(
ServerSettingsBuilder::from_layer(&file)
.expect_err("github_meta_hooks should be rejected outside github webhooks"),
);
assert!(rendered.contains("server.ip_allowlist.entries[0]"));
}
#[test]
fn rejects_unix_socket_allowlist_without_trusted_proxy() {
let file = parse(
r#"
_version = 1
[server.listen]
type = "unix"
path = "/tmp/fabro.sock"
[server.ip_allowlist]
entries = ["10.0.0.0/8"]
"#,
);
let rendered = render_resolve_error_lines(
ServerSettingsBuilder::from_layer(&file)
.expect_err("unix allowlist without trusted proxies should fail"),
);
assert!(rendered.contains("server.ip_allowlist.trusted_proxy_count"));
}
#[test]
fn rejects_unix_socket_github_webhook_allowlist_without_trusted_proxy() {
let file = parse(
r#"
_version = 1
[server.listen]
type = "unix"
path = "/tmp/fabro.sock"
[server.integrations.github.webhooks.ip_allowlist]
entries = ["github_meta_hooks"]
"#,
);
let rendered = render_resolve_error_lines(
ServerSettingsBuilder::from_layer(&file)
.expect_err("unix github webhook allowlist without trusted proxies should fail"),
);
assert!(
rendered.contains("server.integrations.github.webhooks.ip_allowlist.trusted_proxy_count")
);
}
#[test]
fn resolve_storage_root_defaults_with_minimal_server_auth_methods() {
let settings = ServerSettingsBuilder::from_layer(&empty_settings_with_auth_methods())

View file

@ -93,7 +93,6 @@ semver.workspace = true
walkdir.workspace = true
multer = "3"
thiserror.workspace = true
ipnet = "2.11.0"
percent-encoding.workspace = true
url = "2"
zeroize.workspace = true

View file

@ -546,12 +546,7 @@ methods = ["dev-token"]
#[tokio::test]
async fn full_router_rejects_demo_cookie_without_credentials() {
let state = test_state();
let app = server::build_router_with_options(
state,
&auth_mode(),
Arc::new(crate::ip_allowlist::IpAllowlistConfig::default()),
RouterOptions::default(),
);
let app = server::build_router_with_options(state, &auth_mode(), RouterOptions::default());
let response = app
.oneshot(
@ -575,7 +570,6 @@ methods = ["dev-token"]
let app = server::build_router_with_options(
Arc::clone(&state),
&auth_mode(),
Arc::new(crate::ip_allowlist::IpAllowlistConfig::default()),
RouterOptions::default(),
);
@ -601,12 +595,7 @@ methods = ["dev-token"]
#[tokio::test]
async fn full_router_does_not_demo_dispatch_auth_routes() {
let state = test_state();
let app = server::build_router_with_options(
state,
&auth_mode(),
Arc::new(crate::ip_allowlist::IpAllowlistConfig::default()),
RouterOptions::default(),
);
let app = server::build_router_with_options(state, &auth_mode(), RouterOptions::default());
let response = app
.oneshot(
@ -630,18 +619,12 @@ methods = ["dev-token"]
#[tokio::test]
async fn full_router_accepts_dev_token_bearer_when_web_is_disabled() {
let state = test_state();
let app = server::build_router_with_options(
state,
&auth_mode(),
Arc::new(crate::ip_allowlist::IpAllowlistConfig::default()),
RouterOptions {
web_enabled: false,
github_endpoints: None,
github_webhook_ip_allowlist: None,
static_asset_root: None,
watch_web: false,
},
);
let app = server::build_router_with_options(state, &auth_mode(), RouterOptions {
web_enabled: false,
github_endpoints: None,
static_asset_root: None,
watch_web: false,
});
let response = app
.oneshot(

View file

@ -1,609 +0,0 @@
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use axum::extract::{ConnectInfo, Request, State};
use axum::http::Request as HttpRequest;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use fabro_types::settings::server::{
IpAllowEntry, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings,
};
use ipnet::IpNet;
use serde::{Deserialize, Serialize};
use tokio::fs;
use tracing::warn;
use crate::ApiError;
const GITHUB_META_URL: &str = "https://api.github.com/meta";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct IpAllowlist {
entries: Vec<IpNet>,
}
impl IpAllowlist {
pub fn new(entries: Vec<IpNet>) -> Self {
Self { entries }
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn contains(&self, ip: &IpAddr) -> bool {
let ip = normalize_ip(*ip);
self.entries.iter().any(|entry| entry.contains(&ip))
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct IpAllowlistConfig {
pub allowlist: IpAllowlist,
pub trusted_proxy_count: u32,
}
#[derive(Clone)]
pub struct GitHubMetaResolver {
client: HttpClient,
meta_url: String,
cache_path: PathBuf,
}
impl GitHubMetaResolver {
pub fn new(client: HttpClient, meta_url: String, cache_path: PathBuf) -> Self {
Self {
client,
meta_url,
cache_path,
}
}
pub fn from_cache_dir(cache_dir: &Path) -> Result<Self> {
Ok(Self::new(
fabro_http::http_client().context("building GitHub meta HTTP client")?,
GITHUB_META_URL.to_string(),
github_meta_cache_path(cache_dir),
))
}
async fn resolve_hooks(&self) -> Result<Vec<IpNet>> {
let cached = self.load_cache().await?;
let mut request = self
.client
.get(&self.meta_url)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "fabro");
if let Some(etag) = cached.as_ref().and_then(|cache| cache.etag.as_deref()) {
request = request.header("If-None-Match", etag);
}
let response = match request.send().await {
Ok(response) => response,
Err(error) => {
return self.cached_hooks_or_error(
cached.as_ref(),
anyhow::Error::new(error).context("fetching GitHub /meta"),
);
}
};
if response.status() == fabro_http::StatusCode::NOT_MODIFIED {
let cached = cached.ok_or_else(|| {
anyhow!("GitHub /meta returned 304 Not Modified but no usable cache was present")
})?;
return parse_ip_nets(&cached.hooks);
}
if !response.status().is_success() {
return self.cached_hooks_or_error(
cached.as_ref(),
anyhow!("GitHub /meta returned {}", response.status()),
);
}
let etag = response
.headers()
.get("etag")
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
let payload: GitHubMetaResponse = match response.json().await {
Ok(payload) => payload,
Err(error) => {
return self.cached_hooks_or_error(
cached.as_ref(),
anyhow::Error::new(error).context("parsing GitHub /meta response"),
);
}
};
let hooks = match parse_ip_nets(&payload.hooks) {
Ok(hooks) => hooks,
Err(error) => return self.cached_hooks_or_error(cached.as_ref(), error),
};
self.store_cache(&GitHubMetaCache {
etag,
hooks: payload.hooks,
})
.await?;
Ok(hooks)
}
async fn load_cache(&self) -> Result<Option<GitHubMetaCache>> {
match fs::read(&self.cache_path).await {
Ok(contents) => match serde_json::from_slice(&contents) {
Ok(cache) => Ok(Some(cache)),
Err(error) => {
warn!(
path = %self.cache_path.display(),
error = %error,
"Ignoring invalid GitHub meta cache"
);
Ok(None)
}
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => {
Err(error).with_context(|| format!("reading {}", self.cache_path.display()))
}
}
}
async fn store_cache(&self, cache: &GitHubMetaCache) -> Result<()> {
if let Some(parent) = self.cache_path.parent() {
fs::create_dir_all(parent)
.await
.with_context(|| format!("creating {}", parent.display()))?;
}
let contents = serde_json::to_vec(cache).context("serializing GitHub meta cache")?;
fs::write(&self.cache_path, contents)
.await
.with_context(|| format!("writing {}", self.cache_path.display()))?;
Ok(())
}
fn cached_hooks_or_error(
&self,
cached: Option<&GitHubMetaCache>,
error: anyhow::Error,
) -> Result<Vec<IpNet>> {
let Some(cached) = cached else {
return Err(error);
};
warn!(
path = %self.cache_path.display(),
error = %error,
"Using cached GitHub meta hooks after refresh failed"
);
parse_ip_nets(&cached.hooks)
}
}
type HttpClient = fabro_http::HttpClient;
#[derive(Debug, Deserialize)]
struct GitHubMetaResponse {
hooks: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct GitHubMetaCache {
etag: Option<String>,
hooks: Vec<String>,
}
pub fn effective_ip_allowlist_settings(
global: &ServerIpAllowlistSettings,
overlay: Option<&ServerIpAllowlistOverrideSettings>,
) -> ServerIpAllowlistSettings {
let Some(overlay) = overlay else {
return global.clone();
};
ServerIpAllowlistSettings {
entries: overlay
.entries
.clone()
.unwrap_or_else(|| global.entries.clone()),
trusted_proxy_count: overlay
.trusted_proxy_count
.unwrap_or(global.trusted_proxy_count),
}
}
pub async fn resolve_ip_allowlist_config(
global: &ServerIpAllowlistSettings,
overlay: Option<&ServerIpAllowlistOverrideSettings>,
github_meta_resolver: &GitHubMetaResolver,
) -> Result<IpAllowlistConfig> {
let effective = effective_ip_allowlist_settings(global, overlay);
let allowlist = expand_ip_allow_entries(&effective.entries, github_meta_resolver).await?;
Ok(IpAllowlistConfig {
allowlist: IpAllowlist::new(allowlist),
trusted_proxy_count: effective.trusted_proxy_count,
})
}
pub fn extract_client_ip<B>(request: &HttpRequest<B>, trusted_proxy_count: u32) -> Option<IpAddr> {
if trusted_proxy_count == 0 {
return request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|connect_info| normalize_ip(connect_info.0.ip()));
}
let header = request.headers().get("x-forwarded-for")?.to_str().ok()?;
let entries = header
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.collect::<Vec<_>>();
let trusted_proxy_count = trusted_proxy_count as usize;
let client_index = entries.len().checked_sub(trusted_proxy_count + 1)?;
entries[client_index]
.parse::<IpAddr>()
.ok()
.map(normalize_ip)
}
pub async fn ip_allowlist_middleware(
State(config): State<Arc<IpAllowlistConfig>>,
request: Request,
next: Next,
) -> Response {
if config.allowlist.is_empty() || request.uri().path() == "/health" {
return next.run(request).await;
}
let path = request.uri().path().to_string();
match extract_client_ip(&request, config.trusted_proxy_count) {
Some(client_ip) if config.allowlist.contains(&client_ip) => next.run(request).await,
Some(client_ip) => {
warn!(client_ip = %client_ip, path = %path, "request rejected: IP not in allowlist");
ApiError::forbidden().into_response()
}
None => {
warn!(path = %path, "request rejected: IP not in allowlist");
ApiError::forbidden().into_response()
}
}
}
async fn expand_ip_allow_entries(
entries: &[IpAllowEntry],
github_meta_resolver: &GitHubMetaResolver,
) -> Result<Vec<IpNet>> {
let github_hooks = if entries
.iter()
.any(|entry| matches!(entry, IpAllowEntry::GitHubMetaHooks))
{
github_meta_resolver.resolve_hooks().await?
} else {
Vec::new()
};
let mut expanded = Vec::new();
for entry in entries {
match entry {
IpAllowEntry::Literal(net) => expanded.push(*net),
IpAllowEntry::GitHubMetaHooks => expanded.extend(github_hooks.iter().copied()),
}
}
Ok(expanded)
}
fn parse_ip_nets(values: &[String]) -> Result<Vec<IpNet>> {
values
.iter()
.map(|value| {
value
.parse::<IpNet>()
.with_context(|| format!("invalid IP range `{value}` in GitHub /meta hooks"))
})
.collect()
}
fn normalize_ip(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V4(_) => ip,
IpAddr::V6(address) => address
.to_ipv4_mapped()
.map_or(IpAddr::V6(address), IpAddr::V4),
}
}
pub fn github_meta_cache_path(cache_dir: &Path) -> PathBuf {
cache_dir.join("github-meta-hooks.json")
}
#[cfg(test)]
#[expect(
clippy::disallowed_methods,
reason = "tests stage IP allowlist fixtures with sync std::fs::write"
)]
mod tests {
use std::net::Ipv4Addr;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::routing::get;
use axum::{Router, middleware};
use httpmock::MockServer;
use tower::ServiceExt;
use super::*;
fn literal(value: &str) -> IpAllowEntry {
IpAllowEntry::parse_literal(value).unwrap()
}
macro_rules! assert_status {
($response:expr, $expected:expr) => {
fabro_test::assert_axum_status($response, $expected, concat!(file!(), ":", line!()))
};
}
#[test]
fn effective_scope_inherits_global_fields_and_prefers_override_values() {
let global = ServerIpAllowlistSettings {
entries: vec![literal("10.0.0.0/8")],
trusted_proxy_count: 1,
};
let overlay = ServerIpAllowlistOverrideSettings {
entries: Some(vec![IpAllowEntry::GitHubMetaHooks]),
trusted_proxy_count: None,
};
let effective = effective_ip_allowlist_settings(&global, Some(&overlay));
assert_eq!(effective.entries, vec![IpAllowEntry::GitHubMetaHooks]);
assert_eq!(effective.trusted_proxy_count, 1);
}
#[test]
fn extract_client_ip_uses_connect_info_without_trusted_proxies() {
let request = Request::builder()
.uri("/api/v1/runs")
.body(Body::empty())
.unwrap();
let mut request = request;
request
.extensions_mut()
.insert(ConnectInfo(SocketAddr::from((
Ipv4Addr::new(192, 0, 2, 42),
8080,
))));
assert_eq!(
extract_client_ip(&request, 0),
Some(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 42)))
);
}
#[test]
fn extract_client_ip_uses_rightmost_minus_trusted_proxy_count_from_x_forwarded_for() {
let request = Request::builder()
.uri("/api/v1/runs")
.header(
"x-forwarded-for",
"198.51.100.10, 203.0.113.20, 203.0.113.30",
)
.body(Body::empty())
.unwrap();
assert_eq!(
extract_client_ip(&request, 2),
Some(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 10)))
);
}
#[test]
fn extract_client_ip_fails_closed_when_x_forwarded_for_chain_is_too_short() {
let request = Request::builder()
.uri("/api/v1/runs")
.header("x-forwarded-for", "198.51.100.10")
.body(Body::empty())
.unwrap();
assert_eq!(extract_client_ip(&request, 1), None);
}
#[test]
fn ip_allowlist_matches_ipv4_mapped_ipv6_addresses_against_ipv4_ranges() {
let allowlist = IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]);
assert!(allowlist.contains(&"::ffff:10.1.2.3".parse().unwrap()));
}
#[test]
fn github_meta_resolver_uses_storage_cache_dir() {
let cache_dir = tempfile::tempdir().unwrap();
let resolver = GitHubMetaResolver::from_cache_dir(cache_dir.path()).unwrap();
assert_eq!(
resolver.cache_path,
cache_dir.path().join("github-meta-hooks.json")
);
}
#[tokio::test]
async fn resolve_ip_allowlist_config_expands_github_meta_hooks() {
let mock_server = MockServer::start_async().await;
mock_server
.mock_async(|when, then| {
when.method("GET").path("/meta");
then.status(200)
.header("content-type", "application/json")
.header("etag", "\"meta-v1\"")
.body(r#"{"hooks":["192.30.252.0/22","185.199.108.0/22"]}"#);
})
.await;
let resolver = GitHubMetaResolver::new(
fabro_http::test_http_client().unwrap(),
format!("{}/meta", mock_server.url("")),
tempfile::tempdir().unwrap().path().join("github-meta.json"),
);
let global = ServerIpAllowlistSettings {
entries: vec![IpAllowEntry::GitHubMetaHooks],
trusted_proxy_count: 1,
};
let config = resolve_ip_allowlist_config(&global, None, &resolver)
.await
.unwrap();
assert!(config.allowlist.contains(&"192.30.252.45".parse().unwrap()));
assert!(config.allowlist.contains(&"185.199.109.1".parse().unwrap()));
assert_eq!(config.trusted_proxy_count, 1);
}
#[tokio::test]
async fn resolve_ip_allowlist_config_reuses_cached_github_meta_on_not_modified() {
let mock_server = MockServer::start_async().await;
mock_server
.mock_async(|when, then| {
when.method("GET")
.path("/meta")
.header("if-none-match", "\"meta-v1\"");
then.status(304);
})
.await;
let cache_dir = tempfile::tempdir().unwrap();
std::fs::write(
cache_dir.path().join("github-meta.json"),
r#"{"etag":"\"meta-v1\"","hooks":["192.30.252.0/22"]}"#,
)
.unwrap();
let resolver = GitHubMetaResolver::new(
fabro_http::test_http_client().unwrap(),
format!("{}/meta", mock_server.url("")),
cache_dir.path().join("github-meta.json"),
);
let global = ServerIpAllowlistSettings {
entries: vec![IpAllowEntry::GitHubMetaHooks],
trusted_proxy_count: 0,
};
let config = resolve_ip_allowlist_config(&global, None, &resolver)
.await
.unwrap();
assert!(config.allowlist.contains(&"192.30.252.42".parse().unwrap()));
}
#[tokio::test]
async fn resolve_ip_allowlist_config_uses_cached_github_meta_when_github_is_unavailable() {
let mock_server = MockServer::start_async().await;
mock_server
.mock_async(|when, then| {
when.method("GET").path("/meta");
then.status(503);
})
.await;
let cache_dir = tempfile::tempdir().unwrap();
std::fs::write(
cache_dir.path().join("github-meta.json"),
r#"{"etag":"\"meta-v1\"","hooks":["192.30.252.0/22"]}"#,
)
.unwrap();
let resolver = GitHubMetaResolver::new(
fabro_http::test_http_client().unwrap(),
format!("{}/meta", mock_server.url("")),
cache_dir.path().join("github-meta.json"),
);
let global = ServerIpAllowlistSettings {
entries: vec![IpAllowEntry::GitHubMetaHooks],
trusted_proxy_count: 0,
};
let config = resolve_ip_allowlist_config(&global, None, &resolver)
.await
.expect("cached GitHub meta hooks should be reused");
assert!(config.allowlist.contains(&"192.30.252.42".parse().unwrap()));
}
#[tokio::test]
async fn middleware_reads_client_ip_from_x_forwarded_for_when_trusted_proxy_count_is_set() {
let config = Arc::new(IpAllowlistConfig {
allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]),
trusted_proxy_count: 1,
});
let app = Router::new()
.route("/api/v1/runs", get(|| async { StatusCode::OK }))
.layer(middleware::from_fn_with_state(
Arc::clone(&config),
ip_allowlist_middleware,
));
let allowed_request = Request::builder()
.uri("/api/v1/runs")
.header("x-forwarded-for", "10.0.0.1, 198.51.100.1")
.body(Body::empty())
.unwrap();
let allowed_response = app.clone().oneshot(allowed_request).await.unwrap();
assert_status!(allowed_response, StatusCode::OK).await;
let blocked_request = Request::builder()
.uri("/api/v1/runs")
.header("x-forwarded-for", "203.0.113.1, 198.51.100.1")
.body(Body::empty())
.unwrap();
let blocked_response = app.oneshot(blocked_request).await.unwrap();
assert_status!(blocked_response, StatusCode::FORBIDDEN).await;
}
#[tokio::test]
async fn middleware_allows_health_and_blocks_non_allowlisted_requests() {
let config = Arc::new(IpAllowlistConfig {
allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]),
trusted_proxy_count: 0,
});
let app = Router::new()
.route("/health", get(|| async { StatusCode::OK }))
.route("/api/v1/runs", get(|| async { StatusCode::OK }))
.layer(middleware::from_fn_with_state(
Arc::clone(&config),
ip_allowlist_middleware,
));
let health_response = app
.clone()
.oneshot(request_with_connect_info(
"/health",
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 10)),
))
.await
.unwrap();
assert_status!(health_response, StatusCode::OK).await;
let blocked_response = app
.oneshot(request_with_connect_info(
"/api/v1/runs",
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 10)),
))
.await
.unwrap();
assert_status!(blocked_response, StatusCode::FORBIDDEN).await;
}
fn request_with_connect_info(path: &str, ip: IpAddr) -> Request<Body> {
let request = Request::builder().uri(path).body(Body::empty()).unwrap();
let mut request = request;
request
.extensions_mut()
.insert(ConnectInfo(SocketAddr::new(ip, 8080)));
request
}
}

View file

@ -27,7 +27,6 @@ pub mod diagnostics;
pub mod error;
pub mod github_webhooks;
pub mod install;
pub mod ip_allowlist;
pub mod jwt_auth;
pub mod manifest_validation;
mod migrations;

View file

@ -1,5 +1,4 @@
use std::future::{Future, IntoFuture};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::Duration;
@ -34,7 +33,6 @@ use tracing::{error, info, warn};
use crate::canonical_origin::resolve_canonical_origin;
use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV};
use crate::ip_allowlist::{GitHubMetaResolver, IpAllowlistConfig, resolve_ip_allowlist_config};
use crate::server::{
AppState, AppStateConfig, ResolvedAppStateSettings, RouterOptions, build_app_state,
build_router_with_options, reconcile_incomplete_runs_on_startup, shutdown_active_workers,
@ -251,40 +249,6 @@ fn serve_overrides(args: &ServeArgs) -> (Option<RunLayer>, Option<ServerLayer>)
)
}
async fn resolve_github_webhook_ip_allowlist(
resolved_server_settings: &ServerNamespace,
github_meta_resolver: &GitHubMetaResolver,
) -> anyhow::Result<Arc<IpAllowlistConfig>> {
let config = resolve_ip_allowlist_config(
&resolved_server_settings.ip_allowlist,
resolved_server_settings
.integrations
.github
.webhooks
.as_ref()
.and_then(|webhooks| webhooks.ip_allowlist.as_ref()),
github_meta_resolver,
)
.await
.context("resolving GitHub webhook IP allowlist")?;
Ok(Arc::new(config))
}
async fn resolve_startup_github_webhook_ip_allowlist(
resolved_server_settings: &ServerNamespace,
github_meta_resolver: &GitHubMetaResolver,
webhook_secret_present: bool,
) -> anyhow::Result<Option<Arc<IpAllowlistConfig>>> {
if !webhook_secret_present {
return Ok(None);
}
resolve_github_webhook_ip_allowlist(resolved_server_settings, github_meta_resolver)
.await
.map(Some)
}
enum WebhookPreconditions {
Ready {
app_id: String,
@ -769,8 +733,6 @@ where
} else {
false
};
let github_meta_resolver = GitHubMetaResolver::from_cache_dir(&storage.cache_dir())?;
let (object_store, slatedb_prefix, flush_interval, disk_cache) =
build_slatedb_store_with_server_secrets(&resolved_server_settings, &server_secrets)?;
let cache_path = if disk_cache {
@ -827,33 +789,12 @@ where
);
}
spawn_scheduler(Arc::clone(&state));
let default_ip_allowlist = Arc::new(
resolve_ip_allowlist_config(
&resolved_server_settings.ip_allowlist,
None,
&github_meta_resolver,
)
.await
.context("resolving server IP allowlist")?,
);
let github_webhook_ip_allowlist = resolve_startup_github_webhook_ip_allowlist(
&resolved_server_settings,
&github_meta_resolver,
webhook_secret_present,
)
.await?;
let router = build_router_with_options(
Arc::clone(&state),
&auth_mode,
Arc::clone(&default_ip_allowlist),
RouterOptions {
web_enabled,
github_webhook_ip_allowlist,
#[cfg(debug_assertions)]
watch_web,
..RouterOptions::default()
},
);
let router = build_router_with_options(Arc::clone(&state), &auth_mode, RouterOptions {
web_enabled,
#[cfg(debug_assertions)]
watch_web,
..RouterOptions::default()
});
let bound_listener = bind_listener(&bind_request).await?;
let bind_addr = bound_listener.bind.clone();
@ -997,11 +938,7 @@ where
BoundListener::Tcp(listener) => {
announce_server_ready(&bind_addr, styles);
serve_until_shutdown(
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown({
axum::serve(listener, router).with_graceful_shutdown({
let token = shutdown.clone();
async move { token.cancelled().await }
}),
@ -1251,13 +1188,12 @@ mod tests {
use tokio_util::sync::CancellationToken;
use super::{
GitHubMetaResolver, SHUTDOWN_GRACE_PERIOD, ServeArgs, ServerTitlePhase,
apply_effective_log_destination, bind_tcp_host_with_fallback,
build_local_object_store_with_preference, build_object_store_from_settings_with_lookup,
build_slatedb_store, force_exit_after_shutdown, resolve_bind_request_from_server_settings,
resolve_github_webhook_ip_allowlist, resolve_interp,
resolve_startup_github_webhook_ip_allowlist, serve_overrides, serve_until_shutdown,
server_bind_title, server_title, spawn_shutdown_orchestrator_inner,
SHUTDOWN_GRACE_PERIOD, ServeArgs, ServerTitlePhase, apply_effective_log_destination,
bind_tcp_host_with_fallback, build_local_object_store_with_preference,
build_object_store_from_settings_with_lookup, build_slatedb_store,
force_exit_after_shutdown, resolve_bind_request_from_server_settings, resolve_interp,
serve_overrides, serve_until_shutdown, server_bind_title, server_title,
spawn_shutdown_orchestrator_inner,
};
use crate::server::ResolvedAppStateSettings;
@ -1820,78 +1756,4 @@ disk_cache = true
assert_ne!(resolved.port(), occupied_port);
assert!(bound.used_random_port_fallback);
}
#[tokio::test]
async fn resolve_github_webhook_ip_allowlist_propagates_resolution_errors() {
let settings = server_settings(
r#"
_version = 1
[server.listen]
type = "tcp"
address = "127.0.0.1:0"
[server.integrations.github]
strategy = "app"
app_id = "123"
[server.integrations.github.webhooks.ip_allowlist]
entries = ["github_meta_hooks"]
"#,
)
.server;
let cache_dir = tempfile::tempdir().unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let resolver = GitHubMetaResolver::new(
fabro_http::test_http_client().unwrap(),
format!("http://127.0.0.1:{port}/meta"),
cache_dir.path().join("github-meta.json"),
);
let error = resolve_github_webhook_ip_allowlist(&settings, &resolver)
.await
.expect_err("github webhook allowlist resolution should fail closed");
assert!(error.to_string().contains("GitHub webhook IP allowlist"));
}
#[tokio::test]
async fn resolve_startup_github_webhook_ip_allowlist_skips_resolution_without_webhook_secret() {
let settings = server_settings(
r#"
_version = 1
[server.listen]
type = "tcp"
address = "127.0.0.1:0"
[server.integrations.github]
strategy = "app"
app_id = "123"
[server.integrations.github.webhooks.ip_allowlist]
entries = ["github_meta_hooks"]
"#,
)
.server;
let cache_dir = tempfile::tempdir().unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let resolver = GitHubMetaResolver::new(
fabro_http::test_http_client().unwrap(),
format!("http://127.0.0.1:{port}/meta"),
cache_dir.path().join("github-meta.json"),
);
let allowlist = resolve_startup_github_webhook_ip_allowlist(&settings, &resolver, false)
.await
.expect("inactive webhook route should skip GitHub meta resolution");
assert!(allowlist.is_none());
}
}

View file

@ -149,7 +149,6 @@ use crate::error::ApiError;
use crate::github_webhooks::{
WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV, parse_event_metadata, verify_signature,
};
use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware};
use crate::jwt_auth::{self, AuthMode};
use crate::principal_middleware::{
AuthContextSlot, RequestAuth, RequestAuthContext, RequireRunBlob, RequireRunManagementTarget,
@ -1676,35 +1675,28 @@ fn start_optional_slack_service(state: &Arc<AppState>) {
reason = "Public router helper keeps the existing ergonomic API and forwards by reference."
)]
pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
build_router_with_options(
state,
&auth_mode,
Arc::new(IpAllowlistConfig::default()),
RouterOptions::default(),
)
build_router_with_options(state, &auth_mode, RouterOptions::default())
}
#[derive(Clone, Debug)]
pub struct RouterOptions {
pub web_enabled: bool,
pub static_asset_root: Option<PathBuf>,
pub github_endpoints: Option<Arc<GithubEndpoints>>,
pub github_webhook_ip_allowlist: Option<Arc<IpAllowlistConfig>>,
pub web_enabled: bool,
pub static_asset_root: Option<PathBuf>,
pub github_endpoints: Option<Arc<GithubEndpoints>>,
/// Set when serving with the `--watch-web` dev flag. The static-file
/// handler then refuses to fall back to the embedded SPA snapshot and
/// returns a 503 "build in progress" page on miss, so developers see
/// their edits or a clear signal — never stale embedded bytes.
pub watch_web: bool,
pub watch_web: bool,
}
impl Default for RouterOptions {
fn default() -> Self {
Self {
web_enabled: true,
static_asset_root: None,
github_endpoints: None,
github_webhook_ip_allowlist: None,
watch_web: false,
web_enabled: true,
static_asset_root: None,
github_endpoints: None,
watch_web: false,
}
}
}
@ -1717,20 +1709,19 @@ fn removed_web_route(path: &str) -> bool {
pub fn build_router_with_options(
state: Arc<AppState>,
auth_mode: &AuthMode,
ip_allowlist_config: Arc<IpAllowlistConfig>,
options: RouterOptions,
) -> Router {
start_optional_slack_service(&state);
let web_enabled = options.web_enabled;
let static_asset_root = options.static_asset_root.clone();
let watch_web = options.watch_web;
let webhook_ip_allowlist = options.github_webhook_ip_allowlist;
let RouterOptions {
web_enabled,
static_asset_root,
github_endpoints,
watch_web,
} = options;
let translation_state = Arc::clone(&state);
let state_for_canonical_host = Arc::clone(&state);
let github_endpoints = options
.github_endpoints
.clone()
.unwrap_or_else(|| Arc::new(GithubEndpoints::production_defaults()));
let github_endpoints =
github_endpoints.unwrap_or_else(|| Arc::new(GithubEndpoints::production_defaults()));
let webhook_secret = state.vault_secret(WEBHOOK_SECRET_ENV);
let principal_layer = middleware::from_fn_with_state(Arc::clone(&state), principal_middleware);
let api_common = if web_enabled {
@ -1812,10 +1803,6 @@ pub fn build_router_with_options(
}
}));
app_router = app_router.layer(middleware::from_fn_with_state(
Arc::clone(&ip_allowlist_config),
ip_allowlist_middleware,
));
app_router = app_router.layer(middleware::from_fn_with_state(
translation_state,
auth_translation_middleware,
@ -1825,9 +1812,8 @@ pub fn build_router_with_options(
let mut router = app_router;
if let Some(secret) = webhook_secret {
let allowlist = webhook_ip_allowlist.unwrap_or(ip_allowlist_config);
let secret: Arc<[u8]> = Arc::from(secret.into_bytes().into_boxed_slice());
router = github_webhook_routes(secret, allowlist).merge(router);
router = github_webhook_routes(secret).merge(router);
}
router
@ -1936,14 +1922,10 @@ async fn http_log_middleware(mut req: axum_extract::Request, next: Next) -> Resp
response
}
fn github_webhook_routes(secret: Arc<[u8]>, ip_allowlist_config: Arc<IpAllowlistConfig>) -> Router {
fn github_webhook_routes(secret: Arc<[u8]>) -> Router {
Router::new()
.route(WEBHOOK_ROUTE, post(github_webhook))
.with_state(secret)
.layer(middleware::from_fn_with_state(
ip_allowlist_config,
ip_allowlist_middleware,
))
}
async fn github_webhook(

View file

@ -90,14 +90,10 @@ fn resolved_runtime_settings_from_toml(source: &str) -> ResolvedAppStateSettings
fn test_app_with() -> Router {
let state = test_app_state();
crate::test_support::build_test_router_with_options(
state,
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
static_asset_root: Some(spa_fixture_root()),
..RouterOptions::default()
},
)
crate::test_support::build_test_router_with_options(state, RouterOptions {
static_asset_root: Some(spa_fixture_root()),
..RouterOptions::default()
})
}
fn spa_fixture_root() -> PathBuf {
@ -499,15 +495,10 @@ fn webhook_test_app(auth_mode: AuthMode) -> Router {
.expect("test vault should not be locked")
.set(WEBHOOK_SECRET_ENV, &secret, SecretType::Token, None)
.unwrap();
build_router_with_options(
state,
&auth_mode,
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
web_enabled: false,
..RouterOptions::default()
},
)
build_router_with_options(state, &auth_mode, RouterOptions {
web_enabled: false,
..RouterOptions::default()
})
}
fn webhook_request(
@ -1005,11 +996,7 @@ fn canonical_host_test_app() -> Router {
RunLayer::default(),
5,
);
crate::test_support::build_test_router_with_options(
state,
Arc::new(IpAllowlistConfig::default()),
RouterOptions::default(),
)
crate::test_support::build_test_router_with_options(state, RouterOptions::default())
}
#[tokio::test]

View file

@ -31,7 +31,6 @@ use ulid::Ulid;
use crate::auth;
use crate::automation_materializer::AutomationRunMaterializer;
pub use crate::automation_materializer::TestAutomationRunMaterializer;
use crate::ip_allowlist::IpAllowlistConfig;
use crate::jwt_auth::{AuthMode, ConfiguredAuth};
#[cfg(test)]
use crate::principal_middleware::{AuthContextSlot, RequestAuthContext};
@ -533,15 +532,10 @@ pub fn build_test_router(state: Arc<AppState>) -> Router {
with_test_user(server::build_router(state, test_auth_mode()))
}
pub fn build_test_router_with_options(
state: Arc<AppState>,
ip_allowlist_config: Arc<IpAllowlistConfig>,
options: RouterOptions,
) -> Router {
pub fn build_test_router_with_options(state: Arc<AppState>, options: RouterOptions) -> Router {
with_test_user(server::build_router_with_options(
state,
&test_auth_mode(),
ip_allowlist_config,
options,
))
}

View file

@ -1401,7 +1401,6 @@ client_id = "github-client-id"
let app = server::build_router_with_options(
state,
&github_auth_mode(),
Arc::new(crate::ip_allowlist::IpAllowlistConfig::default()),
server::RouterOptions::default(),
);
@ -1494,10 +1493,9 @@ client_id = "github-client-id"
let app = crate::server::build_router_with_options(
state,
&github_auth_mode(),
Arc::new(crate::ip_allowlist::IpAllowlistConfig::default()),
crate::server::RouterOptions {
web_enabled: true,
github_endpoints: Some(Arc::new(GithubEndpoints::with_bases(
web_enabled: true,
github_endpoints: Some(Arc::new(GithubEndpoints::with_bases(
"http://127.0.0.1:12345/"
.parse()
.expect("oauth base should parse"),
@ -1505,9 +1503,8 @@ client_id = "github-client-id"
.parse()
.expect("api base should parse"),
))),
github_webhook_ip_allowlist: None,
static_asset_root: None,
watch_web: false,
static_asset_root: None,
watch_web: false,
},
);
@ -1700,19 +1697,15 @@ client_id = "github-client-id"
None,
)
.unwrap();
let app = server::build_router_with_options(
state,
&github_auth_mode(),
Arc::new(crate::ip_allowlist::IpAllowlistConfig::default()),
server::RouterOptions {
let app =
server::build_router_with_options(state, &github_auth_mode(), server::RouterOptions {
web_enabled: true,
github_endpoints: Some(Arc::new(GithubEndpoints::with_bases(
github.url("/").parse().expect("oauth base should parse"),
github.url("/api/").parse().expect("api base should parse"),
))),
..server::RouterOptions::default()
},
);
});
let key = test_cookie_key();
let mut jar = cookie::CookieJar::new();
super::add_oauth_state_cookie(

View file

@ -5,7 +5,6 @@ use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use cookie::{Cookie, CookieJar, Key};
use fabro_server::ip_allowlist::IpAllowlistConfig;
use fabro_server::jwt_auth::resolve_auth_mode_with_lookup;
use fabro_server::server::{RouterOptions, build_router_with_options};
use fabro_server::test_support::{TEST_SESSION_SECRET, TestAppStateBuilder};
@ -45,12 +44,7 @@ fn test_app(source: &str) -> (axum::Router, Arc<Database>) {
TEST_SESSION_SECRET.to_string(),
)]))
.build();
let app = build_router_with_options(
state,
&auth_mode,
Arc::new(IpAllowlistConfig::default()),
RouterOptions::default(),
);
let app = build_router_with_options(state, &auth_mode, RouterOptions::default());
(app, store)
}

View file

@ -4,7 +4,6 @@ use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use base64::Engine;
use fabro_server::ip_allowlist::IpAllowlistConfig;
use fabro_server::jwt_auth::resolve_auth_mode_with_lookup;
use fabro_server::server::{RouterOptions, build_router_with_options};
use fabro_server::test_support::test_app_state_with_store_and_runtime_settings;
@ -42,7 +41,6 @@ fn test_app(source: &str) -> (axum::Router, Arc<Database>) {
artifact_store,
),
&auth_mode,
Arc::new(IpAllowlistConfig::default()),
RouterOptions::default(),
);
(app, store)

View file

@ -1,12 +1,8 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{Method, Request, StatusCode};
use fabro_config::ServerSettingsBuilder;
use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig};
use fabro_server::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup};
use fabro_server::server::RouterOptions;
use fabro_server::test_support::{
@ -63,7 +59,6 @@ async fn old_unversioned_routes_return_404() {
async fn root_and_health_stay_at_root() {
let app = fabro_server::test_support::build_test_router_with_options(
test_app_state(),
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
static_asset_root: Some(spa_fixture_root()),
..RouterOptions::default()
@ -163,7 +158,6 @@ async fn web_enabled_serves_web_only_routes() {
let app = fabro_server::server::build_router_with_options(
test_app_state(),
&auth_mode,
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
static_asset_root: Some(spa_fixture_root()),
..RouterOptions::default()
@ -256,7 +250,6 @@ async fn web_enabled_serves_web_only_routes() {
async fn security_headers_are_applied_to_all_responses() {
let app = fabro_server::test_support::build_test_router_with_options(
test_app_state(),
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
static_asset_root: Some(spa_fixture_root()),
..RouterOptions::default()
@ -404,7 +397,6 @@ enabled = false
settings.manifest_run_defaults,
5,
),
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
web_enabled: false,
..RouterOptions::default()
@ -463,7 +455,6 @@ enabled = false
settings.manifest_run_defaults,
5,
),
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
web_enabled: false,
..RouterOptions::default()
@ -481,60 +472,3 @@ enabled = false
let response = app.oneshot(request).await.unwrap();
response_status(response, StatusCode::NOT_FOUND, "GET /api/v1/runs/{id}").await;
}
#[tokio::test]
async fn allowlist_blocks_non_allowlisted_api_requests() {
let app = fabro_server::test_support::build_test_router_with_options(
test_app_state(),
Arc::new(IpAllowlistConfig {
allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]),
trusted_proxy_count: 0,
}),
RouterOptions::default(),
);
let response = app
.oneshot(request_with_connect_info(
"/api/v1/runs",
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 10)),
))
.await
.unwrap();
response_status(response, StatusCode::FORBIDDEN, "GET /api/v1/runs").await;
}
#[tokio::test]
async fn allowlist_exempts_health_checks() {
let app = fabro_server::test_support::build_test_router_with_options(
test_app_state(),
Arc::new(IpAllowlistConfig {
allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]),
trusted_proxy_count: 0,
}),
RouterOptions::default(),
);
let response = app
.oneshot(request_with_connect_info(
"/health",
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 10)),
))
.await
.unwrap();
response_status(response, StatusCode::OK, "GET /health").await;
}
fn request_with_connect_info(path: &str, ip: IpAddr) -> Request<Body> {
let request = Request::builder()
.method("GET")
.uri(path)
.body(Body::empty())
.expect("routing test request should build");
let mut request = request;
request
.extensions_mut()
.insert(ConnectInfo(SocketAddr::new(ip, 8080)));
request
}

View file

@ -28,7 +28,12 @@ methods = ["dev-token", "github"]
allowed_usernames = ["alice"]
[server.integrations.github]
strategy = "app"
app_id = "12345"
client_id = "Iv1.abcdef"
[server.integrations.github.webhooks]
strategy = "tailscale_funnel"
"#,
);
let app = fabro_server::test_support::build_test_router(
@ -66,6 +71,16 @@ client_id = "Iv1.abcdef"
body["server"]["integrations"]["github"]["client_id"],
"Iv1.abcdef"
);
assert!(
body["server"].get("ip_allowlist").is_none(),
"settings response should not expose removed server IP allowlist"
);
assert!(
body["server"]["integrations"]["github"]["webhooks"]
.get("ip_allowlist")
.is_none(),
"settings response should not expose removed GitHub webhook IP allowlist"
);
assert!(body.get("features").is_none());
assert!(body.get("cli").is_none());
assert!(body.get("run").is_none());

View file

@ -5,13 +5,11 @@
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use axum::http::StatusCode;
use fabro_config::bind::Bind;
use fabro_config::{RuntimeDirectory, ServerSettingsBuilder};
use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig};
use fabro_server::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup};
use fabro_server::serve::{ServeArgs, serve_command};
use fabro_server::server::{RouterOptions, build_router_with_options};
@ -24,7 +22,7 @@ use tokio::time::sleep;
use crate::helpers::{api, reqwest_status};
async fn start_tcp_server(auth_mode: AuthMode, ip_allowlist: Arc<IpAllowlistConfig>) -> SocketAddr {
async fn start_tcp_server(auth_mode: AuthMode) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("test TCP listener should bind");
@ -33,15 +31,10 @@ async fn start_tcp_server(auth_mode: AuthMode, ip_allowlist: Arc<IpAllowlistConf
.expect("test TCP listener should have a local address");
let state = test_app_state();
let router =
build_router_with_options(state, &auth_mode, ip_allowlist, RouterOptions::default());
let router = build_router_with_options(state, &auth_mode, RouterOptions::default());
tokio::spawn(async move {
let _ = axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.await;
let _ = axum::serve(listener, router).await;
});
addr
@ -177,7 +170,7 @@ methods = ["dev-token"]
_ => None,
})
.expect("auth mode should resolve");
let addr = start_tcp_server(auth_mode, Arc::new(IpAllowlistConfig::default())).await;
let addr = start_tcp_server(auth_mode).await;
let client = fabro_http::test_http_client().unwrap();
let url = format!("http://127.0.0.1:{}{}", addr.port(), api("/runs"));
@ -229,24 +222,3 @@ methods = ["dev-token"]
reqwest_status(response, StatusCode::OK, "GET /api/v1/runs").await;
handle.abort();
}
#[tokio::test]
async fn tcp_ip_allowlist_uses_connect_info() {
let addr = start_tcp_server(
fabro_server::test_support::test_auth_mode(),
Arc::new(IpAllowlistConfig {
allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]),
trusted_proxy_count: 0,
}),
)
.await;
let client = fabro_http::test_http_client().unwrap();
let response = client
.get(format!("http://127.0.0.1:{}{}", addr.port(), api("/runs")))
.send()
.await
.unwrap();
reqwest_status(response, StatusCode::FORBIDDEN, "GET /api/v1/runs").await;
}

View file

@ -24,7 +24,6 @@ dirs.workspace = true
fabro-model = { path = "../fabro-model" }
fabro-util = { path = "../fabro-util" }
hex.workspace = true
ipnet = { version = "2.11.0", features = ["serde"] }
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true

View file

@ -45,12 +45,11 @@ pub use run::{
RunScmSettings, ScmGitHubSettings, TlsMode,
};
pub use server::{
GithubIntegrationSettings, IntegrationWebhooksSettings, IpAllowEntry, LogDestination,
ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings, ServerAuthGithubSettings,
ServerAuthMethod, ServerAuthSettings, ServerIntegrationsSettings,
ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, ServerListenSettings,
ServerLoggingSettings, ServerNamespace, ServerSchedulerSettings, ServerSlateDbSettings,
ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,
GithubIntegrationSettings, IntegrationWebhooksSettings, LogDestination, ObjectStoreSettings,
ServerApiSettings, ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod,
ServerAuthSettings, ServerIntegrationsSettings, ServerListenSettings, ServerLoggingSettings,
ServerNamespace, ServerSchedulerSettings, ServerSlateDbSettings, ServerStorageSettings,
ServerWebSettings, SlackIntegrationSettings,
};
pub use size::{ParseSizeError, Size};
pub use workflow::WorkflowNamespace;

View file

@ -8,7 +8,6 @@
use std::net::SocketAddr;
use std::time::Duration as StdDuration;
use ipnet::IpNet;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
@ -28,7 +27,6 @@ pub struct ServerNamespace {
pub api: ServerApiSettings,
pub web: ServerWebSettings,
pub auth: ServerAuthSettings,
pub ip_allowlist: ServerIpAllowlistSettings,
pub sandbox: ServerSandboxSettings,
pub storage: ServerStorageSettings,
pub artifacts: ServerArtifactsSettings,
@ -50,7 +48,6 @@ impl ServerNamespace {
api: ServerApiSettings::default(),
web: ServerWebSettings::default(),
auth: ServerAuthSettings::default(),
ip_allowlist: ServerIpAllowlistSettings::default(),
sandbox: ServerSandboxSettings::default(),
storage: ServerStorageSettings::default(),
artifacts: ServerArtifactsSettings::default(),
@ -123,18 +120,6 @@ pub struct ServerAuthGithubSettings {
pub allowed_usernames: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerIpAllowlistSettings {
pub entries: Vec<IpAllowEntry>,
pub trusted_proxy_count: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerIpAllowlistOverrideSettings {
pub entries: Option<Vec<IpAllowEntry>>,
pub trusted_proxy_count: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerSandboxSettings {
pub providers: ServerSandboxProvidersSettings,
@ -175,24 +160,6 @@ impl Default for ServerSandboxProviderSettings {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum IpAllowEntry {
Literal(IpNet),
GitHubMetaHooks,
}
impl IpAllowEntry {
pub const GITHUB_META_HOOKS_KEYWORD: &str = "github_meta_hooks";
pub fn parse_literal(value: &str) -> Result<Self, String> {
value
.parse::<IpNet>()
.or_else(|_| value.parse::<std::net::IpAddr>().map(IpNet::from))
.map_err(|error| error.to_string())
.map(Self::Literal)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerStorageSettings {
pub root: InterpString,
@ -331,8 +298,7 @@ impl Default for SlackIntegrationSettings {
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct IntegrationWebhooksSettings {
pub strategy: Option<WebhookStrategy>,
pub ip_allowlist: Option<ServerIpAllowlistOverrideSettings>,
pub strategy: Option<WebhookStrategy>,
}
fn serialize_socket_addr<S>(value: &SocketAddr, serializer: S) -> Result<S::Ok, S::Error>

View file

@ -56,6 +56,7 @@ models/auth-session-user.ts
models/auth-session.ts
models/auth-sessions-response.ts
models/automation-api-trigger.ts
models/automation-list-meta.ts
models/automation-list-response.ts
models/automation-ref.ts
models/automation-schedule-trigger.ts
@ -143,7 +144,6 @@ models/fork-response.ts
models/fork-source-ref.ts
models/git-author-settings.ts
models/git-context.ts
models/git-hub-meta-hooks-entry.ts
models/github-integration-settings.ts
models/github-integration-strategy.ts
models/health-response.ts
@ -184,9 +184,7 @@ models/integration-webhooks-settings.ts
models/interview-option.ts
models/interview-provider-settings.ts
models/interview-question-record.ts
models/ip-allow-entry.ts
models/link-run-pull-request-request.ts
models/literal-ip-allow-entry.ts
models/log-destination.ts
models/manifest-args.ts
models/manifest-config.ts
@ -417,8 +415,6 @@ models/server-auth-github-settings.ts
models/server-auth-method.ts
models/server-auth-settings.ts
models/server-integrations-settings.ts
models/server-ip-allowlist-override-settings.ts
models/server-ip-allowlist-settings.ts
models/server-listen-settings.ts
models/server-listen-tcp-settings.ts
models/server-listen-unix-settings.ts

View file

@ -16,6 +16,8 @@
// May contain unused imports in some cases
// @ts-ignore
import type { Automation } from './automation';
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationListMeta } from './automation-list-meta';
/**

View file

@ -1,22 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export const GitHubMetaHooksEntry = {
GIT_HUB_META_HOOKS: 'GitHubMetaHooks'
} as const;
export type GitHubMetaHooksEntry = typeof GitHubMetaHooksEntry[keyof typeof GitHubMetaHooksEntry];

View file

@ -118,7 +118,6 @@ export * from './fork-response';
export * from './fork-source-ref';
export * from './git-author-settings';
export * from './git-context';
export * from './git-hub-meta-hooks-entry';
export * from './github-integration-settings';
export * from './github-integration-strategy';
export * from './health-response';
@ -158,9 +157,7 @@ export * from './integration-webhooks-settings';
export * from './interview-option';
export * from './interview-provider-settings';
export * from './interview-question-record';
export * from './ip-allow-entry';
export * from './link-run-pull-request-request';
export * from './literal-ip-allow-entry';
export * from './log-destination';
export * from './manifest-args';
export * from './manifest-config';
@ -391,8 +388,6 @@ export * from './server-auth-github-settings';
export * from './server-auth-method';
export * from './server-auth-settings';
export * from './server-integrations-settings';
export * from './server-ip-allowlist-override-settings';
export * from './server-ip-allowlist-settings';
export * from './server-listen-settings';
export * from './server-listen-tcp-settings';
export * from './server-listen-unix-settings';

View file

@ -13,14 +13,10 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { ServerIpAllowlistOverrideSettings } from './server-ip-allowlist-override-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { WebhookStrategy } from './webhook-strategy';
export interface IntegrationWebhooksSettings {
'strategy': WebhookStrategy | null;
'ip_allowlist': ServerIpAllowlistOverrideSettings | null;
}

View file

@ -1,26 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { GitHubMetaHooksEntry } from './git-hub-meta-hooks-entry';
// May contain unused imports in some cases
// @ts-ignore
import type { LiteralIpAllowEntry } from './literal-ip-allow-entry';
/**
* @type IpAllowEntry
*/
export type IpAllowEntry = GitHubMetaHooksEntry | LiteralIpAllowEntry;

View file

@ -1,19 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface LiteralIpAllowEntry {
'Literal': string;
}

View file

@ -1,23 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { IpAllowEntry } from './ip-allow-entry';
export interface ServerIpAllowlistOverrideSettings {
'entries': Array<IpAllowEntry> | null;
'trusted_proxy_count': number | null;
}

View file

@ -1,23 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { IpAllowEntry } from './ip-allow-entry';
export interface ServerIpAllowlistSettings {
'entries': Array<IpAllowEntry>;
'trusted_proxy_count': number;
}

View file

@ -27,9 +27,6 @@ import type { ServerAuthSettings } from './server-auth-settings';
import type { ServerIntegrationsSettings } from './server-integrations-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { ServerIpAllowlistSettings } from './server-ip-allowlist-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { ServerListenSettings } from './server-listen-settings';
// May contain unused imports in some cases
// @ts-ignore
@ -55,7 +52,6 @@ export interface ServerNamespace {
'api': ServerApiSettings;
'web': ServerWebSettings;
'auth': ServerAuthSettings;
'ip_allowlist': ServerIpAllowlistSettings;
'sandbox': ServerSandboxSettings;
'storage': ServerStorageSettings;
'artifacts': ServerArtifactsSettings;