Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a source IP allowlist for user-facing services #5686

Merged
merged 1 commit into from
May 5, 2024

Conversation

bnaecker
Copy link
Collaborator

@bnaecker bnaecker commented May 2, 2024

  • Add database table and updates with an allowlist of IPs. This is currently structured as a table with one row, and an array of INETs as the allowlist.
  • Add Nexus API for viewing / updating allowlist. Also does basic sanity checks on the list, most importantly that the source IP the request came from is on the list.
  • Add the allowlist to wicket example config file
  • Add the allowlist into the rack initialization request, and insert it into the database during Nexus's internal server handling of that request.
  • Read allowlist and convert to firewall rules when plumbing any service firewall rules to sled agents.
  • Add method for waiting on this plumbing before starting Nexus's external server, to ensure the IP allowlist is set before anything can reach Nexus.

@bnaecker bnaecker marked this pull request as draft May 2, 2024 05:38
@bnaecker
Copy link
Collaborator Author

bnaecker commented May 2, 2024

This still needs a bit of work mostly around testing, but I'd like to get some early eyes from folks familiar with the rack init process.

bootstore/src/schemes/v0/peer.rs Outdated Show resolved Hide resolved
common/src/api/internal/shared.rs Outdated Show resolved Hide resolved
nexus/db-model/src/allowed_source_ip.rs Outdated Show resolved Hide resolved
nexus/db-model/src/allowed_source_ip.rs Outdated Show resolved Hide resolved
nexus/db-queries/src/db/fixed_data/allowed_source_ips.rs Outdated Show resolved Hide resolved
nexus/src/app/allowed_source_ips.rs Outdated Show resolved Hide resolved
nexus/src/app/allowed_source_ips.rs Outdated Show resolved Hide resolved
schema/crdb/dbinit.sql Show resolved Hide resolved
sled-agent/src/bootstrap/params.rs Outdated Show resolved Hide resolved
@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

Alright, I'm working on the wicket updates as well, following #5685 as a guide. Apart from having to use replace as I mentioned in the comment thread above, it looks like the allowlist is populated correctly from some barebones testing I've done. Here's a quick screenshot, where I fill in some dummy data:

Screen Shot 2024-05-02 at 18 13 50

I'm sure I'm not testing things quite correctly though. I followed the very helpful README in omicron/wicket, though wicketd appears unwilling to continue through the actual rack setup without any bootstrap peers. I'm not sure how to convince or fool it into moving forward. I'm going to push the code I've got, but would love some more eyes on that bit, maybe from @sunshowers?

@bnaecker bnaecker marked this pull request as ready for review May 3, 2024 01:21
@bnaecker bnaecker requested a review from sunshowers May 3, 2024 01:21
@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

I'm still going to try to sneak in a small RPW that syncs this specific set of firewall rules only, along with some more tests and work to check that we return a 503 when we fail to plumb the rules down when someone calls the actual Nexus udpate API.

@jgallagher
Copy link
Contributor

jgallagher commented May 3, 2024

I'm sure I'm not testing things quite correctly though. I followed the very helpful README in omicron/wicket, though wicketd appears unwilling to continue through the actual rack setup without any bootstrap peers. I'm not sure how to convince or fool it into moving forward. I'm going to push the code I've got, but would love some more eyes on that bit, maybe from @sunshowers?

The screenshot looks great. wicketd's willingness to run against dummy data stops here though; to actually trigger rack setup you need a real rack. :( We'll need to test on madrid or london.

@sunshowers
Copy link
Contributor

Not on a computer for the rest of the day, but is this something that can wait until the oxpop work lands? There's probably going to be some collisions

@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

@sunshowers Yeah, I expect some collisions. The changes I've made are quite small, so I should be able to resolve them fairly easily. But 🤞!

@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

Ok, I'm pretty happy with this at this point. There's still a failing authz test here that I haven't been able to track down. The other failures look like expectorate output comparisons for the most part, which I'll fix up now.

I'd also like to run things locally a few times to see how the rules get plumbed down with the new logic and background task. I'll do that now, and hopefully we'll be ready for Dogfood tomorrow.

@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

I think most of the test failures are pretty harmless changes to expectorated file contents and the like. There's one exception though, which is [rack_setup::plan::sled::tests::test_read_known_rss_sled_plans](https://buildomat.eng.oxide.computer/wg/0/details/01HWY6N51W0XK40STHZBZ702Y3/eqQqMsUcw7gjsYPx3dQEmA7dniqSAgXK5lEExtFPDQXBgNMw/01HWY6NHG55R552RCGJ8FT0XS4#S5931). I think this one catches a very important situation, which I'd forgotten about until now and would like a second opinion one.

This test loads some previously-serialized plan that the sled-agent has saved, with the results of RSS and the resulting plan for deploying resources around the rack. A similar file exists on existing customer racks. If the sled-agent reboots, it looks like it will attempt to load an existing plan via the Plan::load() constructor. This will fail on deserialization! I'm not sure how this is ever intended to be evolved, since no other fields are marked with #[serde(default)] or similar. It seems like we must add that now (say with AllowedSourceIps::Any), so that when new sleds reboot after getting this update don't die loading this plan. @jgallagher or maybe @smklein Does this sound correct to you?

@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

Ok, I've verified the background task is running correctly on deployed Nexus's. I spun the whole control plane up on my local (non-Gimlet) developer machine, and we can see the initial plumbing of the rules first:

05:45:26.906Z INFO 3a355d64-988c-4589-8ed2-df1310a1a10a (ServerContext): plumbed initial IP allowlist
    file = nexus/src/app/allowed_source_ips.rs:132

A bit later, the background task is kicked and runs how I'd expect:

05:49:28.395Z DEBG 3a355d64-988c-4589-8ed2-df1310a1a10a (ServerContext): activating
    background_task = service_firewall_rule_propagation
    iteration = 3
    reason = Timeout
05:49:28.395Z DEBG 3a355d64-988c-4589-8ed2-df1310a1a10a (ServerContext): starting background task for service firewall rule propagation
    background_task = service_firewall_rule_propagation
<SNIP>
05:49:28.485Z DEBG 3a355d64-988c-4589-8ed2-df1310a1a10a (ServerContext): Allowlist for user-facing services is set to allow any inbound traffic. Existing VPC firewall rules will not be modified.
    background_task = service_firewall_rule_propagation
05:49:28.485Z DEBG 3a355d64-988c-4589-8ed2-df1310a1a10a (ServerContext): resolved firewall rules for sled agents
    background_task = service_firewall_rule_propagation
    sled_agent_rules = [VpcFirewallRule { action: Allow, direction: Inbound, filter_hosts: None, filter_ports: Some([L4PortRange("53")]), filter_protocols: Some([Udp]), priority: 65534, status: Enabled, targets: [NetworkInterface { id: 5d4a7a6f-ad51-4c5f-8648-123379242f5b, kind: Service { id: 964205b5-2de2-4a7c-b2dd-6ce2da5c0b0d }, name: Name("external-dns-964205b5-2de2-4a7c-b2dd-6ce2da5c0b0d"), ip: 172.30.1.5, mac: MacAddr(MacAddr6([168, 64, 37, 255, 152, 24])), subnet: V4(Ipv4Net(Ipv4Network { addr: 172.30.1.0, prefix: 24 })), vni: Vni(100), primary: true, slot: 0 }, NetworkInterface { id: 724b8b3a-e41c-445f-b8de-c7ffc98c92d1, kind: Service { id: 2e2cacdf-6844-4f45-a28d-c8b282eaf26a }, name: Name("external-dns-2e2cacdf-6844-4f45-a28d-c8b282eaf26a"), ip: 172.30.1.6, mac: MacAddr(MacAddr6([168, 64, 37, 255, 195, 12])), subnet: V4(Ipv4Net(Ipv4Network { addr: 172.30.1.0, prefix: 24 })), vni: Vni(100), primary: true, slot: 0 }] }, VpcFirewallRule { action: Allow, direction: Inbound, filter_hosts: None, filter_ports: Some([L4PortRange("80"), L4PortRange("443")]), filter_protocols: Some([Tcp]), priority: 65534, status: Enabled, targets: [NetworkInterface { id: 1faa6015-b862-40ea-bfbc-5e18ca606e84, kind: Service { id: aafab0f6-7827-4bc4-b6cc-1681cfe88e59 }, name: Name("nexus-aafab0f6-7827-4bc4-b6cc-1681cfe88e59"), ip: 172.30.2.5, mac: MacAddr(MacAddr6([168, 64, 37, 255, 202, 165])), subnet: V4(Ipv4Net(Ipv4Network { addr: 172.30.2.0, prefix: 24 })), vni: Vni(100), primary: true, slot: 0 }, NetworkInterface { id: b044bd53-a3ba-4b03-963b-fe309be7e1aa, kind: Service { id: d3fc39e8-7288-4b22-bbdc-d21ba77605d7 }, name: Name("nexus-d3fc39e8-7288-4b22-bbdc-d21ba77605d7"), ip: 172.30.2.6, mac: MacAddr(MacAddr6([168, 64, 37, 255, 220, 26])), subnet: V4(Ipv4Net(Ipv4Network { addr: 172.30.2.0, prefix: 24 })), vni: Vni(100), primary: true, slot: 0 }, NetworkInterface { id: cec13ee7-b559-49ab-bad5-757038430a43, kind: Service { id: 3a355d64-988c-4589-8ed2-df1310a1a10a }, name: Name("nexus-3a355d64-988c-4589-8ed2-df1310a1a10a"), ip: 172.30.2.7, mac: MacAddr(MacAddr6([168, 64, 37, 255, 243, 134])), subnet: V4(Ipv4Net(Ipv4Network { addr: 172.30.2.0, prefix: 24 })), vni: Vni(100), primary: true, slot: 0 }] }]
05:49:28.485Z DEBG 3a355d64-988c-4589-8ed2-df1310a1a10a (ServerContext): resolved 2 rules for sleds
    background_task = service_firewall_rule_propagation
05:49:28.502Z DEBG 3a355d64-988c-4589-8ed2-df1310a1a10a (ServerContext): resolved sleds for vpc oxide-services
    background_task = service_firewall_rule_propagation
    vpc_to_sled = [Sled { identity: SledIdentity { id: 280b0ebd-8b08-46da-a43e-a451de9c4061, time_created: 2024-05-03T05:45:02.576344Z, time_modified: 2024-05-03T05:45:02.576344Z }, time_deleted: None, rcgen: Generation(Generation(19)), rack_id: aadeb37d-889f-427a-9a18-6463dad8e9d8, is_scrimlet: true, serial_number: "shale", part_number: "i86pc", revision: 0, usable_hardware_threads: SqlU32(24), usable_physical_ram: ByteCount(ByteCount(68659703808)), reservoir_size: ByteCount(ByteCount(0)), ip: fd00:1122:3344:101::1, port: SqlU16(12345), last_used_address: fd00:1122:3344:101::ffff, policy: InService, state: Active, sled_agent_gen: Generation(Generation(1)) }]
05:49:28.502Z DEBG 3a355d64-988c-4589-8ed2-df1310a1a10a (ServerContext): sending firewall rules to sled agents
    background_task = service_firewall_rule_propagation
<SNIP>

Next, I put together a quick version of the Oxide CLI built from this PR, and used it to update the rules. This shows that partially working:

bnaecker@shale : ~/oxide.rs $ ./target/release/oxide system networking allowed-source-ips view
{
  "allowed_ips": {
    "allow": "any"
  },
  "time_created": "2024-05-03T05:45:26.283508Z",
  "time_modified": "2024-05-03T05:45:26.283508Z"
}
bnaecker@shale : ~/oxide.rs $ cat updates.json
{
    "allowed_ips": {
        "allow": "list",
         "ips": [ "192.168.1.0/24"]
    }
}
bnaecker@shale : ~/oxide.rs $ ./target/release/oxide system networking allowed-source-ips update --json-body updates.json
error
Error Response: status: 503 Service Unavailable; headers: {"content-type": "application/json", "x-request-id": "ab0ddbc0-0a3e-485f-b20a-95bc8636809e", "content-length": "133", "date": "Fri, 03 May 2024 06:23:37 GMT"}; value: Error { error_code: Some("ServiceNotAvailable"), message: "Service Unavailable", request_id: "ab0ddbc0-0a3e-485f-b20a-95bc8636809e" }
bnaecker@shale : ~/oxide.rs $ ./target/release/oxide system networking allowed-source-ips view
{
  "allowed_ips": {
    "allow": "list",
    "ips": [
      "192.168.1.0/24"
    ]
  },
  "time_created": "2024-05-03T05:45:26.283508Z",
  "time_modified": "2024-05-03T06:23:38.023064Z"
}

So a couple things here. First, we can see that the rules are correctly being received and inserted / retrieved in the DB. That's not a surprise, we have tests for that already in the PR. Second, we can see the 503 being returned in the case where propagating the rules to the sled-agents fails. So that's good.

But why did it fail? I found the Nexus that handled the request, and we see this at the tail end:

06:23:38.071Z DEBG aafab0f6-7827-4bc4-b6cc-1681cfe88e59 (dropshot_external): authorize result
    action = Read
    actor = Some(Actor::SiloUser { silo_user_id: e3ae3c69-1adf-4f33-b899-063c6bff1c9b, silo_id: 1fcb2d14-26be-443e-a27d-10bc83ccaab6, .. })
    actor_id = e3ae3c69-1adf-4f33-b899-063c6bff1c9b
    authenticated = true
    local_addr = 172.30.2.5:80
    method = PUT
    remote_addr = 192.168.1.82:39653
    req_id = ab0ddbc0-0a3e-485f-b20a-95bc8636809e
    resource = Vpc { parent: Project { parent: Silo { parent: Fleet, key: 001de000-5110-4000-8000-000000000001, lookup_type: ById(001de000-5110-4000-8000-000000000001) }, key: 001
de000-4401-4000-8000-000000000000, lookup_type: ById(001de000-4401-4000-8000-000000000000) }, key: 001de000-074c-4000-8000-000000000000, lookup_type: ById(001de000-074c-4000-8000-
000000000000) }
    result = Err(ObjectNotFound { type_name: Vpc, lookup_type: ById(001de000-074c-4000-8000-000000000000) })
    uri = //v1/system/networking/allowed-source-ips
06:23:38.074Z ERRO aafab0f6-7827-4bc4-b6cc-1681cfe88e59 (ServerContext): failed to update sled-agents with new allowlist
    error = ObjectNotFound { type_name: Vpc, lookup_type: ById(001de000-074c-4000-8000-000000000000) }
    file = nexus/src/app/allowed_source_ips.rs:101
06:23:38.074Z INFO aafab0f6-7827-4bc4-b6cc-1681cfe88e59 (dropshot_external): request completed
    error_message_external = Service Unavailable
    error_message_internal = Failed to plumb allowlist as firewall rules to relevant sled agents. The request must be retried for them to take effect.
    file = /home/bnaecker/.cargo/git/checkouts/dropshot-a4a923d29dccc492/29ae98d/dropshot/src/server.rs:837
    latency_us = 76113
    local_addr = 172.30.2.5:80
    method = PUT
    remote_addr = 192.168.1.82:39653
    req_id = ab0ddbc0-0a3e-485f-b20a-95bc8636809e
    response_code = 503
    uri = //v1/system/networking/allowed-source-ips

We're failing the check to look up the VPC there. I think that makes sense, since we're using the op-context from the public API request. That actor (SiloUser) should not be able to fetch this VPC! I think we should be using a more-privileged context for this part of the code instead. I'll try that out next.

In any case, I activated the RPW manually with omdb:

bnaecker@shale : ~/omicron $ cargo run --bin omdb -- -w nexus background-tasks activate service_firewall_rule_propagation
    Finished dev [unoptimized + debuginfo] target(s) in 0.78s
     Running `target/debug/omdb -w nexus background-tasks activate service_firewall_rule_propagation`
note: Nexus URL not specified.  Will pick one from DNS.
note: using DNS server for subnet fd00:1122:3344::/48
note: (if this is not right, use --dns-server to specify an alternate DNS server)
note: using Nexus URL http://[fd00:1122:3344:101::c]:12221
activated background tasks: service_firewall_rule_propagation

And found the Nexus that ran it. It indeed fetched the right rules and modified those passed to the sled-agent appropriately:

06:28:45.422Z DEBG d3fc39e8-7288-4b22-bbdc-d21ba77605d7 (ServerContext): Found allowlist for user-facing services with explicit IP list. Existing VPC firewall rules will be modifi
ed to match.
    allow_list = IpAllowList([V4(Ipv4Net(Ipv4Network { addr: 192.168.1.0, prefix: 24 }))])
    background_task = service_firewall_rule_propagation
06:28:45.422Z DEBG d3fc39e8-7288-4b22-bbdc-d21ba77605d7 (ServerContext): resolved firewall rules for sled agents
    background_task = service_firewall_rule_propagation
    sled_agent_rules = [VpcFirewallRule { action: Allow, direction: Inbound, filter_hosts: Some([Ip(V4(Ipv4Net("192.168.1.0/24")))]), filter_ports: Some([L4PortRange("53")]), filt
er_protocols: Some([Udp]), priority: 65534, status: Enabled, targets: [NetworkInterface { id: 5d4a7a6f-ad51-4c5f-8648-123379242f5b, kind: Service { id: 964205b5-2de2-4a7c-b2dd-6ce
2da5c0b0d }, name: Name("external-dns-964205b5-2de2-4a7c-b2dd-6ce2da5c0b0d"), ip: 172.30.1.5, mac: MacAddr(MacAddr6([168, 64, 37, 255, 152, 24])), subnet: V4(Ipv4Net(Ipv4Network {
 addr: 172.30.1.0, prefix: 24 })), vni: Vni(100), primary: true, slot: 0 }, NetworkInterface { id: 724b8b3a-e41c-445f-b8de-c7ffc98c92d1, kind: Service { id: 2e2cacdf-6844-4f45-a28
d-c8b282eaf26a }, name: Name("external-dns-2e2cacdf-6844-4f45-a28d-c8b282eaf26a"), ip: 172.30.1.6, mac: MacAddr(MacAddr6([168, 64, 37, 255, 195, 12])), subnet: V4(Ipv4Net(Ipv4Netw
ork { addr: 172.30.1.0, prefix: 24 })), vni: Vni(100), primary: true, slot: 0 }] }, VpcFirewallRule { action: Allow, direction: Inbound, filter_hosts: Some([Ip(V4(Ipv4Net("192.168
.1.0/24")))]), filter_ports: Some([L4PortRange("80"), L4PortRange("443")]), filter_protocols: Some([Tcp]), priority: 65534, status: Enabled, targets: [NetworkInterface { id: 1faa6
015-b862-40ea-bfbc-5e18ca606e84, kind: Service { id: aafab0f6-7827-4bc4-b6cc-1681cfe88e59 }, name: Name("nexus-aafab0f6-7827-4bc4-b6cc-1681cfe88e59"), ip: 172.30.2.5, mac: MacAddr
(MacAddr6([168, 64, 37, 255, 202, 165])), subnet: V4(Ipv4Net(Ipv4Network { addr: 172.30.2.0, prefix: 24 })), vni: Vni(100), primary: true, slot: 0 }, NetworkInterface { id: b044bd
53-a3ba-4b03-963b-fe309be7e1aa, kind: Service { id: d3fc39e8-7288-4b22-bbdc-d21ba77605d7 }, name: Name("nexus-d3fc39e8-7288-4b22-bbdc-d21ba77605d7"), ip: 172.30.2.6, mac: MacAddr(
MacAddr6([168, 64, 37, 255, 220, 26])), subnet: V4(Ipv4Net(Ipv4Network { addr: 172.30.2.0, prefix: 24 })), vni: Vni(100), primary: true, slot: 0 }, NetworkInterface { id: cec13ee7
-b559-49ab-bad5-757038430a43, kind: Service { id: 3a355d64-988c-4589-8ed2-df1310a1a10a }, name: Name("nexus-3a355d64-988c-4589-8ed2-df1310a1a10a"), ip: 172.30.2.7, mac: MacAddr(Ma
cAddr6([168, 64, 37, 255, 243, 134])), subnet: V4(Ipv4Net(Ipv4Network { addr: 172.30.2.0, prefix: 24 })), vni: Vni(100), primary: true, slot: 0 }] }]
06:28:45.423Z DEBG d3fc39e8-7288-4b22-bbdc-d21ba77605d7 (ServerContext): resolved 2 rules for sleds
    background_task = service_firewall_rule_propagation

The filter_hosts field in that rule matches the new allowlist I set up via the CLI. And the sled-agent indeed applied it correctly, as shown in its log file:

06:34:28.794Z INFO SledAgent (PortManager): Ensuring VPC firewall rules
    file = illumos-utils/src/opte/port_manager.rs:525
    rules = [VpcFirewallRule { status: Enabled, direction: Inbound, targets: [NetworkInterface { id: 5d4a7a6f-ad51-4c5f-8648-123379242f5b, kind: Service { id: 964205b5-2de2-4a7c-b
2dd-6ce2da5c0b0d }, name: Name("external-dns-964205b5-2de2-4a7c-b2dd-6ce2da5c0b0d"), ip: 172.30.1.5, mac: MacAddr(MacAddr6([168, 64, 37, 255, 152, 24])), subnet: V4(Ipv4Net(Ipv4Ne
twork { addr: 172.30.1.0, prefix: 24 })), vni: Vni(100), primary: true, slot: 0 }, NetworkInterface { id: 724b8b3a-e41c-445f-b8de-c7ffc98c92d1, kind: Service { id: 2e2cacdf-6844-4
f45-a28d-c8b282eaf26a }, name: Name("external-dns-2e2cacdf-6844-4f45-a28d-c8b282eaf26a"), ip: 172.30.1.6, mac: MacAddr(MacAddr6([168, 64, 37, 255, 195, 12])), subnet: V4(Ipv4Net(I
pv4Network { addr: 172.30.1.0, prefix: 24 })), vni: Vni(100), primary: true, slot: 0 }], filter_hosts: Some([Ip(V4(Ipv4Net(Ipv4Network { addr: 192.168.1.0, prefix: 24 })))]), filt
er_ports: Some([L4PortRange { first: L4Port(53), last: L4Port(53) }]), filter_protocols: Some([Udp]), action: Allow, priority: VpcFirewallRulePriority(65534) }, VpcFirewallRule {
status: Enabled, direction: Inbound, targets: [NetworkInterface { id: 1faa6015-b862-40ea-bfbc-5e18ca606e84, kind: Service { id: aafab0f6-7827-4bc4-b6cc-1681cfe88e59 }, name: Name(
"nexus-aafab0f6-7827-4bc4-b6cc-1681cfe88e59"), ip: 172.30.2.5, mac: MacAddr(MacAddr6([168, 64, 37, 255, 202, 165])), subnet: V4(Ipv4Net(Ipv4Network { addr: 172.30.2.0, prefix: 24
})), vni: Vni(100), primary: true, slot: 0 }, NetworkInterface { id: b044bd53-a3ba-4b03-963b-fe309be7e1aa, kind: Service { id: d3fc39e8-7288-4b22-bbdc-d21ba77605d7 }, name: Name("
nexus-d3fc39e8-7288-4b22-bbdc-d21ba77605d7"), ip: 172.30.2.6, mac: MacAddr(MacAddr6([168, 64, 37, 255, 220, 26])), subnet: V4(Ipv4Net(Ipv4Network { addr: 172.30.2.0, prefix: 24 })
), vni: Vni(100), primary: true, slot: 0 }, NetworkInterface { id: cec13ee7-b559-49ab-bad5-757038430a43, kind: Service { id: 3a355d64-988c-4589-8ed2-df1310a1a10a }, name: Name("ne
xus-3a355d64-988c-4589-8ed2-df1310a1a10a"), ip: 172.30.2.7, mac: MacAddr(MacAddr6([168, 64, 37, 255, 243, 134])), subnet: V4(Ipv4Net(Ipv4Network { addr: 172.30.2.0, prefix: 24 }))
, vni: Vni(100), primary: true, slot: 0 }], filter_hosts: Some([Ip(V4(Ipv4Net(Ipv4Network { addr: 192.168.1.0, prefix: 24 })))]), filter_ports: Some([L4PortRange { first: L4Port(8
0), last: L4Port(80) }, L4PortRange { first: L4Port(443), last: L4Port(443) }]), filter_protocols: Some([Tcp]), action: Allow, priority: VpcFirewallRulePriority(65534) }]
    vni = Vni(100)
06:34:28.794Z INFO SledAgent (PortManager): Setting OPTE firewall rules
    file = illumos-utils/src/opte/port_manager.rs:544
    port = "opte3"
    rules = [FirewallRule { direction: In, filters: Filters { hosts: Subnet(Ip4(Ipv4Cidr { ip: Ipv4Addr { inner: 192.168.1.0 }, prefix_len: Ipv4PrefixLen(24) })), protocol: Proto(
TCP), ports: PortList([80, 443]) }, action: Allow, priority: 65534 }]
06:34:28.794Z INFO SledAgent (PortManager): Setting OPTE firewall rules
    file = illumos-utils/src/opte/port_manager.rs:544
    port = "opte2"
    rules = [FirewallRule { direction: In, filters: Filters { hosts: Subnet(Ip4(Ipv4Cidr { ip: Ipv4Addr { inner: 192.168.1.0 }, prefix_len: Ipv4PrefixLen(24) })), protocol: Proto(
UDP), ports: PortList([53]) }, action: Allow, priority: 65534 }]
06:34:28.794Z INFO SledAgent (PortManager): Setting OPTE firewall rules
    file = illumos-utils/src/opte/port_manager.rs:544
    port = "opte5"
    rules = [FirewallRule { direction: In, filters: Filters { hosts: Subnet(Ip4(Ipv4Cidr { ip: Ipv4Addr { inner: 192.168.1.0 }, prefix_len: Ipv4PrefixLen(24) })), protocol: Proto(
UDP), ports: PortList([53]) }, action: Allow, priority: 65534 }]
06:34:28.794Z INFO SledAgent (PortManager): Setting OPTE firewall rules
    file = illumos-utils/src/opte/port_manager.rs:544
    port = "opte4"
    rules = [FirewallRule { direction: In, filters: Filters { hosts: Subnet(Ip4(Ipv4Cidr { ip: Ipv4Addr { inner: 192.168.1.0 }, prefix_len: Ipv4PrefixLen(24) })), protocol: Proto(
TCP), ports: PortList([80, 443]) }, action: Allow, priority: 65534 }]
06:34:28.794Z INFO SledAgent (PortManager): Setting OPTE firewall rules
    file = illumos-utils/src/opte/port_manager.rs:544
    port = "opte1"
    rules = [FirewallRule { direction: In, filters: Filters { hosts: Subnet(Ip4(Ipv4Cidr { ip: Ipv4Addr { inner: 192.168.1.0 }, prefix_len: Ipv4PrefixLen(24) })), protocol: Proto(
TCP), ports: PortList([80, 443]) }, action: Allow, priority: 65534 }]
06:34:28.794Z INFO SledAgent (PortManager): Setting OPTE firewall rules
    file = illumos-utils/src/opte/port_manager.rs:544
    port = "opte0"
    rules = []

opteadm confirms that as well:

Port opte1 - Layer firewall
======================================================================
Inbound Flows
----------------------------------------------------------------------
PROTO  SRC IP  SPORT  DST IP  DPORT  HITS  ACTION

Outbound Flows
----------------------------------------------------------------------
PROTO  SRC IP  SPORT  DST IP  DPORT  HITS  ACTION

Inbound Rules
----------------------------------------------------------------------
ID   PRI    HITS  PREDICATES                   ACTION
34   65534  0     inner.ip.proto=TCP           "Stateful Allow"
                  inner.ulp.dst=80,443
                  inner.ip.src=192.168.1.0/24

DEF  --     0     --                           "deny"

Outbound Rules
----------------------------------------------------------------------
ID   PRI  HITS  PREDICATES  ACTION
DEF  --   0     --          "stateful allow"

That's one of the Nexus zone's OPTE ports, where we can see we've left in place the right ports and protocols.

There are a few more sanity checks. First, we correctly prevent someone from cutting off their own access:

bnaecker@shale : ~/oxide.rs $ ipadm | grep igb0
igb0/dhcp         dhcp     ok           192.168.1.82/24
bnaecker@shale : ~/oxide.rs $ cat updates.json
{
    "allowed_ips": {
        "allow": "list",
         "ips": [ "10.0.0.0/24" ]
    }
}
bnaecker@shale : ~/oxide.rs $ ./target/release/oxide system networking allowed-source-ips update --json-body updates.json
error
Error Response: status: 400 Bad Request; headers: {"content-type": "application/json", "x-request-id": "5587d746-ac17-4de3-9812-9bdcd52bbc59", "content-length": "270", "date": "Fri, 03 May 2024 06:41:01 GMT"}; value: Error { error_code: Some("InvalidRequest"), message: "The source IP allow list would prevent access from the current client! Ensure that the allowlist contains an entry that continues to allow access from this peer.", request_id: "5587d746-ac17-4de3-9812-9bdcd52bbc59" }

I did also find one more bug -- you can't actually set a single IP address in the allowlist! This is because we're using IpNet explicitly, not an enum of that or IpAddr.

bnaecker@shale : ~/oxide.rs $ cat updates.json
{
    "allowed_ips": {
        "allow": "list",
         "ips": [ "192.168.1.82" ]
    }
}
bnaecker@shale : ~/oxide.rs $ ./target/release/oxide system networking allowed-source-ips update --json-body updates.json
thread 'tokio-runtime-worker' panicked at cli/src/generated_cli.rs:10027:82:
called `Result::unwrap()` on an `Err` value: Error("data did not match any variant of untagged enum IpNet", line: 4, column: 34)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread 'main' panicked at cli/src/main.rs:66:10:
called `Result::unwrap()` on an `Err` value: JoinError::Panic(Id(25), ...)

But if I hack around that by using a /32, then I can definitely cut off access from another local machine on my network. That's nice :)

bnaecker@shale : ~/oxide.rs $ cat updates.json
{
    "allowed_ips": {
        "allow": "list",
         "ips": [ "192.168.1.82/32" ]
    }
}
bnaecker@shale : ~/oxide.rs $ ./target/release/oxide system networking allowed-source-ips update --json-body updates.json
error
Error Response: status: 503 Service Unavailable; headers: {"content-type": "application/json", "x-request-id": "9ec4e5ef-f728-4635-8153-34a6d563a027", "content-length": "133", "date": "Fri, 03 May 2024 06:44:19 GMT"}; value: Error { error_code: Some("ServiceNotAvailable"), message: "Service Unavailable", request_id: "9ec4e5ef-f728-4635-8153-34a6d563a027" }
bnaecker@shale : ~/omicron $ pfexec opteadm dump-layer -p opte3 firewall
Port opte3 - Layer firewall
======================================================================
Inbound Flows
----------------------------------------------------------------------
PROTO  SRC IP  SPORT  DST IP  DPORT  HITS  ACTION

Outbound Flows
----------------------------------------------------------------------
PROTO  SRC IP  SPORT  DST IP  DPORT  HITS  ACTION

Inbound Rules
----------------------------------------------------------------------
ID   PRI    HITS  PREDICATES                 ACTION
41   65534  0     inner.ip.proto=TCP         "Stateful Allow"
                  inner.ulp.dst=80,443
                  inner.ip.src=192.168.1.82

DEF  --     72    --                         "deny"

Outbound Rules
----------------------------------------------------------------------
ID   PRI  HITS  PREDICATES  ACTION
DEF  --   0     --          "stateful allow"

@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

Ok 8386fb4 fixed the opcontext problem, updating the allowlist no longer returns a 503. I haven't figured out how to get the allowlist to accept either a IP address or CIDR yet, without also requiring that we use that enum internally everywhere, which sucks. So there are three things left, I think

  • The IP-or-CIDR issue. We can defer this, and just require a /32 if you really want an address. Kinda sucks, but it works.
  • The failing endpoint auth test. I haven't looked at this much yet.
  • The issue around deserializing old sled plans that don't have the allowlist. I think that requires that we add #[serde(default)] to the field, but want a second opinion.

@jgallagher
Copy link
Contributor

This test loads some previously-serialized plan that the sled-agent has saved, with the results of RSS and the resulting plan for deploying resources around the rack. A similar file exists on existing customer racks. If the sled-agent reboots, it looks like it will attempt to load an existing plan via the Plan::load() constructor. This will fail on deserialization! I'm not sure how this is ever intended to be evolved, since no other fields are marked with #[serde(default)] or similar. It seems like we must add that now (say with AllowedSourceIps::Any), so that when new sleds reboot after getting this update don't die loading this plan. @jgallagher or maybe @smklein Does this sound correct to you?

I think the only evolution of this we've done to date were either inside RackNetworkConfig, which itself did need to add #[serde(default)] to new fields (because it also lands in the bootstore, which has the same issue), or removing fields (#5018), which is also backwards compatible.

I think I argued that AllowedSourceIps shouldn't implement Default, which I still think, so maybe we can use a more explicit default in sled-agent here? Something like

--- a/sled-agent/src/bootstrap/params.rs
+++ b/sled-agent/src/bootstrap/params.rs
@@ -42,9 +42,16 @@ struct UnvalidatedRackInitializeRequest {
     external_certificates: Vec<Certificate>,
     recovery_silo: RecoverySiloConfig,
     rack_network_config: RackNetworkConfig,
+    #[serde(default = "default_allowed_source_ips")]
     allowed_source_ips: AllowedSourceIps,
 }

+// This field was added after several existing racks went through RSS. RSS plans
+// for such racks should be treated as though they allowed any source IP.
+fn default_allowed_source_ips() -> AllowedSourceIps {
+    AllowedSourceIps::Any
+}
+
 /// Configuration for the "rack setup service".
 ///
 /// The Rack Setup Service should be responsible for one-time setup actions

This will require an EXPECTORATE overwrite to pass the test (we save the new output), but I think that's fine/correct.

@jgallagher
Copy link
Contributor

jgallagher commented May 3, 2024

Ok, I'm pretty happy with this at this point. There's still a failing authz test here that I haven't been able to track down.

This fixes the test, and I think is a correct change, but please confirm:

--- a/nexus/tests/integration_tests/endpoints.rs
+++ b/nexus/tests/integration_tests/endpoints.rs
@@ -2391,7 +2391,7 @@ pub static VERIFY_ENDPOINTS: Lazy<Vec<VerifyEndpoint>> = Lazy::new(|| {
         // User-facing services IP allowlist
         VerifyEndpoint {
             url: &ALLOWED_SOURCE_IP_URL,
-            visibility: Visibility::Protected,
+            visibility: Visibility::Public,
             unprivileged_access: UnprivilegedAccess::None,
             allowed_methods: vec![
                 AllowedMethod::Get,

The visibility here is about what happens if authenticated but unauthorized user hits the endpoint. The docs on visibility say

    /// If it's [`Visibility::Public`] (like "/v1/organizations"), unauthorized
    /// users can expect to get back a 401 or 403 when they attempt to access
    /// it.  If it's [`Visibility::Protected`] (like a specific Organization),
    /// unauthorized users will get a 404.

I think the intent here is that we shouldn't leak information about the existence of resources (e.g., can I discover project names I shouldn't be able to see by hitting endpoints and getting 403 vs 404), but in this case the endpoint is a system level thing that everyone knows exists, so sending back a 403 seems correct.

@jgallagher
Copy link
Contributor

jgallagher commented May 3, 2024

I did also find one more bug -- you can't actually set a single IP address in the allowlist! This is because we're using IpNet explicitly, not an enum of that or IpAddr.

Is this a bug in the CLI and/or OpenAPI spec? You already have a test that AllowedSourceIps can handle single IPs and networks:

let parsed: AllowedSourceIps = serde_json::from_str(
r#"{"allow":"list","ips":["127.0.0.1","10.0.0.0/24","fd00::1/64"]}"#,
)

This error does look like it's coming from the CLI before it even tries to send the request:

bnaecker@shale : ~/oxide.rs $ ./target/release/oxide system networking allowed-source-ips update --json-body updates.json
thread 'tokio-runtime-worker' panicked at cli/src/generated_cli.rs:10027:82:
called `Result::unwrap()` on an `Err` value: Error("data did not match any variant of untagged enum IpNet", line: 4, column: 34)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread 'main' panicked at cli/src/main.rs:66:10:

openapi/nexus.json Outdated Show resolved Hide resolved
@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

@jgallagher Thanks for the help!

Deserialization: Ok, that all matches my understanding. I agree we should make a function for returning a default on deserialization, rather than deriving Default for the enum itself.

Authz: That's really helpful thanks. It's been ages since I wrote one of these tests, and I was trying to make the endpoint itself hidden. That's not really a thing we support in this case, since it has no child resources of any kind. I was also worried that your fix was a "fix", just changing the tests to pass, but it does make sense with your explanation. I'll incorporate it!

Single IP: This is an issue with the generated schema, not the internal type we use. The schema for IpNet requires a / and prefix length, so I think we fail a check in generated code on the client side. I'll dig into it now, and possibly just write the schema by hand to support this one.

@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

It also looks like the Ipv6Net regex might be wrong. Passing in fd00::/64 to the CLI @ahl is working on fails, so we're probably missing some other corner of the totally-easy-to-grok IPv6 address format.

@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

Hit some codegen bugs with the new default annotation on the RSS config stuff. Updating Progenitor seems to fix it, we'll see the next stumbling block :)

@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

Ok, b714c32 should have fixed the codegen issues and pass the deserialization / authz checks. There is still one thing I need to resolve, which is the IP / CIDR handling. The fastest option is to only allow CIDRs for now, so that customers need to write 1.2.3.4/32 if they want a single address. That sucks, but we might need that.

I will either do that, and update the docs accordingly, or figure out how to get the generated schema to allow both. In either case, we also need to handle the fact that the regex appears wrong.... We can't specify IPv6s that aren't in the ULA range, which kinda defeats the purpose of an external allowlist.

@@ -1356,18 +1409,7 @@ impl JsonSchema for Ipv6Net {
})),
instance_type: Some(schemars::schema::InstanceType::String.into()),
string: Some(Box::new(schemars::schema::StringValidation {
pattern: Some(
// Conforming to unique local addressing scheme,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we confident that no other locations are depending on these being local addresses?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so. The place we care is when creating VPC prefixes and VPC Subnets, both of which use Ipv6Net::is_unique_local() to verify that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just hit this in a Nexus integration test that was depending on a local address. (Me hitting it was because of a different bug, but I think Nexus is expecting these to be local in at least some cases.) The specific error I saw was

thread 'integration_tests::rack::test_sled_add' panicked at clients/sled-agent-client/src/lib.rs:451:58:
0:0:0:21::/64: doesn't match pattern "^([fF][dD])[0-9a-fA-F]{2}:(([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:)([0-9a-fA-F]{1,4})?\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$"

when I (incorrectly) tried to create a sled with that subnet (which is a legal IPv6 addr but not local).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh man, we've just been so lucky

@askfongjojo
Copy link

There is some remote possibility that a customer could lock themselves out of the rack if they have somehow misconfigured the allow list. It may not be something for this PR but may worth having a follow-up issue on this (e.g. an omdb command for Oxide technician rescue).

@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

@askfongjojo I won't say it's impossible, but there are two safeguards to locking themselves out completely. First you can't set the allowlist in such a way that would prevent access from your own IP address. Second, as @jgallagher, Nexus will still be available to Oxide folks through the proxy over the technician port. Wicketd makes this available to us, and since it uses the techport and the underlay network, it is not subject to the OPTE firewall rules we use to implement the allowlist on the external network.

@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

Ok, forget it. The IP or CIDR block thing is going to be very annoying with schemars. I'm going to simply require that folks specify only a network -- if you want a single address, that's a /32 or /128. I'm making one more commit to update the docs to reflect that, and we'll go from there. Fingers crossed we get one successful build here.

@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

@ahl @jgallagher would you mind taking one more quick pass through this? Adam, maybe double-check the CLI generation comes out the way you'd like it?

@bnaecker bnaecker requested review from ahl and jgallagher May 3, 2024 21:52
@bnaecker
Copy link
Collaborator Author

bnaecker commented May 3, 2024

There will be conflicts with #5685. I need to squash this all anyway, so @sunshowers you're welcome to merge first (if it's ready), and I can address those.

Copy link
Contributor

@ahl ahl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hit one minor hiccup; all looks good.

openapi/bootstrap-agent.json Outdated Show resolved Hide resolved
@bnaecker bnaecker changed the title WIP: Add a source IP allowlist for user-facing services Add a source IP allowlist for user-facing services May 4, 2024
@bnaecker
Copy link
Collaborator Author

bnaecker commented May 4, 2024

There's a pile of conflicts here, so I'm going to start squashing and rebasing. Apologies, but we're going to lose the context for some of the PR comments. I've addressed everything so far, so that's not a huge loss.

@bnaecker bnaecker force-pushed the external-service-allowlists branch 2 times, most recently from 9b7be39 to 9c65544 Compare May 5, 2024 00:02
@bnaecker
Copy link
Collaborator Author

bnaecker commented May 5, 2024

Rebased on main to pick up fix for https://github.com/oxidecomputer/amd-host-image-builder/issues/181 and the changes in #5649.

@ahl Could you just make one last version of the new CLI on this, to make sure I didn't mess up the OpenAPI definitions? @sunshowers and / or @rcgoodfellow, I think I addressed the conflicts with your PR correctly, but since types moved around a bit, would you mind double-checking that I've not adversely impacted your work? And @jgallagher, I'd still love one more look on the details that you've already reviewed, if you get a chance in the next day or two.

I'm going to take this for one more spin locally, and hopefully we can get this wrapped up soon. Thanks to everyone for their help and input!

@bnaecker bnaecker force-pushed the external-service-allowlists branch from 9c65544 to abfeb98 Compare May 5, 2024 00:14
@bnaecker
Copy link
Collaborator Author

bnaecker commented May 5, 2024

Just spun up everything on my PC, and it all still looks good. I can successfully view / update the rules via the modified CLI; the DB repr looks right; Nexus modifies the VPC firewall rules on update and during the background task. Modulo any comments from the reviewers I've tagged, I'm happy with this one finally.

- Add database table and updates with an allowlist of IPs. This is
  currently structured as a table with one row, and an array of INETs as
  the allowlist. Includes a CHECK constraint that the list is not empty
  -- NULL is used to indicate the lack of an allowlist.
- Add Nexus API for viewing / updating allowlist. Also does basic sanity
  checks on the list, most importantly that the source IP the request
  came from is _on_ the list. VPC firewall rules are updated after the
  database has been updated successfully.
- Add the allowlist to wicket example config file, and plumb through
  wicket UI, client, server, and into bootstrap agent.
- Add the allowlist into the rack initialization request, and insert it
  into the database during Nexus's internal server handling of that
  request.
- Read allowlist and convert to firewall rules when plumbing any service
  firewall rules to sled agents. This works by modifying existing
  firewall rules for the internal service VPC. The host filters should
  always be empty here, so this is simple and well-defined. It also lets
  us keep the right protocol and port filters on the rules.
- Add method for waiting on this plumbing _before_ starting Nexus's
  external server, to ensure the IP allowlist is set before anything can
  reach Nexus.
- Add background task in Nexus for ensuring service VPC rules only. This
  runs pretty infrequently now (5 minutes), but the allowlist should
  only be updated very rarely.
- Include allowlist default on deserialization in the sled-agent, so
  that it applies to existing customer installations that've already
  been RSS'd.
- Note: This also relaxes the regular expression we've been using for
  IPv6 networks. It was previously checking only for ULAs, while we now
  need it to represent any valid network. Adds tests for the regex too.
@bnaecker bnaecker force-pushed the external-service-allowlists branch from abfeb98 to a25140e Compare May 5, 2024 00:55
@ahl
Copy link
Contributor

ahl commented May 5, 2024

FYI merged the updated OpenAPI doc into the CLI and it all looks as we discussed oxidecomputer/oxide.rs#658

@rcgoodfellow
Copy link
Contributor

@rcgoodfellow, I think I addressed the conflicts with your PR correctly, but since types moved around a bit, would you mind double-checking that I've not adversely impacted your work?

I looked through the new BGP plumbing and things look good. Thanks for the typo fixes ;)

Copy link
Contributor

@sunshowers sunshowers left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gave the wicket bits a quick look, the merge looks good.

# Allowlist of source IPs that can make requests to user-facing services.
[allowed_source_ips]
# Any external IPs to make requests. This is the default.
allow = "any"
Copy link
Contributor

@sunshowers sunshowers May 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an algebraic bounded lattice similar to the BGP prefix allowlist below (a list of some values, plus "any" as an option). Would it be possible to align the way this is defined with the BGP prefixes below?

https://en.m.wikipedia.org/wiki/Lattice_(order)

The wicket bits aren't blocking for r8 since it's user facing config and not part of the stable API. But it would be nice for the bootstore configs to be aligned. (But again not a huge deal, we can migrate the JSON configs fine. I can also put up a follow-up on Monday.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'd prefer to do that in a follow-up, since this has already been a long road. I'd like some time to read through the use of the BGP allow-list type, too. It looks like the semantics might be slightly different, so I'd like to look it over in more detail.

@bnaecker
Copy link
Collaborator Author

bnaecker commented May 5, 2024

In the spirit of urgency, I'm going to merge this as-is. It's had a lot of eyes and several rounds of testing. We'd like to get a spin on Dogfood, and I'll be around to make another PR if we hit any issues or @jgallagher has any lingering comments from another review.

@bnaecker bnaecker merged commit f2602b5 into main May 5, 2024
22 checks passed
@bnaecker bnaecker deleted the external-service-allowlists branch May 5, 2024 16:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants