> ## Documentation Index
> Fetch the complete documentation index at: https://orbit.nckrtl.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# firewall

> Allow, deny, list, and remove named UFW rules on one Node through the Gateway.

Every managed Node runs Uncomplicated Firewall (UFW). Orbit owns the rules it needs for public SSH recovery, WireGuard member trust, and each role. Those rules carry an `orbit:` prefix. The rule `orbit:wireguard-members` admits every WireGuard member on the Node's WireGuard address, as [private network trust](/docs/reference/routes#private-network-trust) describes. The `firewall` family adds operator rules beside them, each identified by a stable name on one Node.

The Gateway applies each rule over SSH, verifies it in the UFW status output, and records the result. The Node firewall catalog is the single source of Orbit-managed rules, so role provisioning and firewall commands use the same projection. Doctor checks the `firewall` family and reports a rule that is missing or different from its record without changing the machine.

## Commands

| Command | Result |
| - | - |
| [`firewall:allow`](#orbit-firewallallow) | Create or converge one named allow rule. |
| [`firewall:deny`](#orbit-firewalldeny) | Create or converge one named deny rule. |
| [`firewall:list`](#orbit-firewalllist) | List the named rules of one Node. |
| [`firewall:remove`](#orbit-firewallremove) | Remove one named rule. |

## Read the managed and live rules

The Gateway serves three read-only lists. The CLI has no command for them. The web app's Node page and the MCP tools use them.

| Request | Result |
| - | - |
| `GET /api/v1/firewall-rules` | The operator rules of every Node the caller may address. |
| `GET /api/v1/nodes/{node}/managed-firewall-rules` | The `orbit:` rules that Orbit intends on one Node: WireGuard member trust, public SSH recovery while the Node has no active role, then each active role's rules and the Metrics rules, with the role named. It uses no SSH. |
| `GET /api/v1/nodes/{node}/live-firewall-rules` | Reads `ufw status numbered` over WireGuard and classifies each IPv4 rule against the managed rules and the operator rules. |

The first role convergence removes `orbit:public-ssh-recovery`. So a Node with an active role does not list public port 22 as intended. The managed list comes from the same catalog that convergence applies.

In the live list, each live rule has a `match` value. `exact` means it equals a managed or operator rule. `drift` means its comment names such a rule but the rule differs. `unmanaged` means its comment names no managed or operator rule, or it has no comment. A managed or operator rule that is absent from UFW is listed as missing. An inactive, absent, or unreachable UFW returns no live rows. The web app lists the live rules, shows `drift` and `unmanaged` rules in red, and flags missing rules. When it cannot read live UFW, it shows the operator rules and the managed catalog instead.

## Rules and names

Every command accepts `--json` and requires `--node` with a numeric Node ID. A rule name is 1 through 63 characters of lowercase letters, digits, dots, underscores, and hyphens, starting and ending with a letter or digit; it cannot contain a colon, so an operator name never collides with an `orbit:` rule. The Gateway tags each UFW rule with the comment `orbit:node:<node-id>:firewall:<name>` and uses that comment for every later change.

All targets and rule values are explicit; these commands do not prompt for missing arguments or options. Human requests show an indeterminate progress tree while the Gateway works, then the verified result and request ID. Piped output has no animation or escape codes. JSON contains only the existing response or error.

## orbit firewall:allow

Allow traffic to one destination port or port range, optionally from one source.

```bash theme={"dark"}
orbit firewall:allow <name> --node=ID --port=PORT [--from=SOURCE] [--protocol=PROTO]
```

| Argument | Required | Meaning |
| - | - | - |
| `name` | yes | Stable rule name, unique per Node. |

| Option | Default | Meaning |
| - | - | - |
| `--node=ID` | required | Numeric target Node ID. |
| `--port=PORT` | required | Destination port from 1 through 65535, or an ordered range such as `8000:8100`. |
| `--from=SOURCE` | `any` | Source IPv4 or IPv6 address, a CIDR such as `10.6.0.0/24`, or `any`. |
| `--protocol=PROTO` | `tcp` | `tcp` or `udp`. |

```bash theme={"dark"}
orbit firewall:allow office-postgres --node=7 --port=5432 --from=10.6.0.0/24
orbit firewall:allow media-udp --node=7 --port=6000:6100 --protocol=udp
```

Repeating the same command with the same values converges the existing rule and returns it. The same name with different values returns `firewall.name_taken`; remove the rule and create it again to change it. Human output reports `Firewall rule [name] is active.` with the request ID.

## orbit firewall:deny

Deny traffic to one destination port or port range, optionally from one source. It accepts the same argument and options as `firewall:allow`.

```bash theme={"dark"}
orbit firewall:deny <name> --node=ID --port=PORT [--from=SOURCE] [--protocol=PROTO]
```

```bash theme={"dark"}
orbit firewall:deny block-scanner --node=7 --port=22 --from=203.0.113.9
```

<Warning>
  The Gateway refuses a deny rule whose port contains the Node's public SSH port for any source that would cover it with `firewall.public_ssh_deny_forbidden`, because that port is the recovery path after a failed provisioning. A deny rule and an allow rule with the same source, protocol, and port cannot coexist on one Node; the second one returns `firewall.action_conflict`.
</Warning>

## orbit firewall:list

List the named rules recorded for one Node with their action, source, port, protocol, and lifecycle status.

```bash theme={"dark"}
orbit firewall:list --node=ID
```

| Option | Meaning |
| - | - |
| `--node=ID` | Numeric target Node ID. |

Human output uses a read-only table with NAME, ACTION, SOURCE, PORT, PROTOCOL, and STATUS columns. Values wrap inside their cells; below the minimum width, labeled records retain every field. An empty result says `No firewall rules.`.

A rule's `status` is `provisioning`, `active`, `failed`, or `removing`. A failed rule keeps `failed_step` and `error_code` so the same command can be repeated after the cause is fixed. Orbit-owned `orbit:` rules are not part of this list.

## orbit firewall:remove

Remove one named rule from UFW and delete its record. Before prompting, the CLI resolves the rule with a read-only lookup, then asks `Remove firewall rule [name] from Node #ID?` with No selected. Supply `--yes` for automation, including JSON; JSON never implies consent.

```bash theme={"dark"}
orbit firewall:remove <name> --node=ID [--yes] [--json]
```

| Argument | Required | Meaning |
| - | - | - |
| `name` | yes | Stable rule name. |

| Option | Meaning |
| - | - |
| `--node=ID` | Numeric target Node ID. |
| `--yes` | Confirm rule removal without prompting. |

```bash theme={"dark"}
orbit firewall:remove office-postgres --node=7
orbit firewall:remove office-postgres --node=7 --yes --json
```

The Gateway marks the rule `removing`, deletes every UFW rule that carries its comment, verifies that none remains, and then deletes the record. A failure leaves the rule `failed` with the step that stopped; repeat the command to retry.

Declining, Ctrl-C, or end of input before removal exits 1 with `input.cancelled`. Noninteractive removal without `--yes` exits 1 with `input.confirmation_required` and an instruction to supply `--yes`. Refused confirmations send no removal request. Existing lookup, authorization, and safety errors retain their codes.

## Error codes

The CLI validates the name, source, protocol, and port before it sends a request. The Gateway returns the remaining codes.

| Error code | Meaning |
| - | - |
| `firewall.node_id_invalid` | `--node` is not a positive integer. |
| `firewall.rule_name_required`, `firewall.rule_name_invalid` | The name is missing or outside the accepted form. |
| `firewall.source_invalid` | The source is not `any`, an IP address, or a valid CIDR. |
| `firewall.protocol_invalid` | The protocol is not `tcp` or `udp`. |
| `firewall.port_invalid` | The port is outside 1 through 65535 or the range is not ordered. |
| `firewall.name_taken` | The name exists on the Node with different values. |
| `firewall.action_conflict` | An opposite rule with the same source, protocol, and port exists. |
| `firewall.public_ssh_deny_forbidden` | The deny rule would cover the public recovery SSH port. |
| `firewall.rule_collision` | The rule name identifies conflicting UFW rules on the machine; Orbit adopts neither. |
| `firewall.backend_inactive` | UFW is inactive on the Node. |
| `firewall.platform_unsupported` | The Node is not a Linux Node. macOS is refused before SSH and before a rule is stored. |
| `firewall.apply_failed`, `firewall.verify_failed` | UFW did not accept the rule or the verification did not find it. |
| `firewall.remove_failed`, `firewall.remove_verify_failed` | UFW did not remove the rule or the verification still found it. |
| `firewall.node_unreachable` | The Gateway could not reach the Node over SSH. |

## Why it works this way

The managed list is the set that convergence keeps, not every rule Orbit can write. A Node with roles has closed public SSH, so listing it as intended would advertise a path that is gone. Hiding it only in the web app is rejected, because every other client would still read the wrong intent. Doctor alone is not enough, because operators look at the Node page, and Doctor is a separate run. The live read never changes UFW.

## Related

* [`doctor`](/docs/cli/doctor) inspects the `firewall` family with `--family=firewall`.
* [`node`](/docs/cli/node) removal refuses a Node that still owns named firewall rules.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.