{"record":{"id":"cfc5fce98c0983c9","repo":"jdx/mise","slug":"refusing-firewall-default-incoming-from-an-ssh","errorCode":null,"errorMessage":"refusing firewall default incoming {} from an SSH-derived process without SSH_CONNECTION: mise cannot verify that remote access will survive; preserve SSH_CONNECTION or set allow_lockout = true","messagePattern":"refusing firewall default incoming (.+?) from an SSH-derived process without SSH_CONNECTION: mise cannot verify that remote access will survive; preserve SSH_CONNECTION or set allow_lockout = true","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"critical","filePath":"src/system/firewall.rs","lineNumber":473,"sourceCode":"        )\n    }\n\n    fn validate_safety_with_rules(\n        &self,\n        rules: &[FirewallRule],\n        backend: Option<FirewallBackend>,\n        ssh_ancestor: Option<bool>,\n    ) -> Result<()> {\n        if self.state != FirewallState::Enabled\n            || self.default_incoming == FirewallPolicy::Allow\n            || self.allow_lockout\n        {\n            return Ok(());\n        }\n        let Some(connection) = &self.ssh_connection else {\n            return match ssh_ancestor {\n                Some(false) => Ok(()),\n                Some(true) => bail!(\n                    \"refusing firewall default incoming {} from an SSH-derived process without SSH_CONNECTION: mise cannot verify that remote access will survive; preserve SSH_CONNECTION or set allow_lockout = true\",\n                    self.default_incoming.ufw()\n                ),\n                None => bail!(\n                    \"refusing firewall default incoming {} without SSH_CONNECTION because process ancestry could not be inspected; set allow_lockout = true to acknowledge the lockout risk\",\n                    self.default_incoming.ufw()\n                ),\n            };\n        };\n        let mut covered = false;\n        for rule in rules.iter().filter(|rule| {\n            rule.state == FirewallRuleState::Present\n                && rule.direction == FirewallDirection::Incoming\n                && rule\n                    .protocol\n                    .is_none_or(|protocol| protocol == FirewallProtocol::Tcp)\n                && rule\n                    .port","sourceCodeStart":455,"sourceCodeEnd":491,"githubUrl":"https://github.com/jdx/mise/blob/9dcfcaa0dc8747a2577d3270b69bb9d8313b2807/src/system/firewall.rs#L455-L491","documentation":"A lockout guard: enabling a firewall with a deny-ish `default_incoming` (not Allow) drops unsolicited packets, and mise detected an SSH ancestor process but no `SSH_CONNECTION` environment variable, so it cannot learn the peer/port that must stay reachable. Rather than risk severing the session being used to run the command, it refuses. Preserve the variable or explicitly accept the risk with `allow_lockout = true`.","triggerScenarios":"`mise bootstrap firewall apply` (or `mise bootstrap`) with `state` enabled and `default_incoming` deny/reject, where process-ancestry inspection says an SSH session is an ancestor but the environment lacks `SSH_CONNECTION` — e.g. sudo stripping env, a cron/systemd unit spawned from an SSH session, or re-exec wrappers that sanitize the environment. Skipped entirely when `default_incoming = \"allow\"` or `allow_lockout = true` is set.","commonSituations":"Running mise through sudo in hardening playbooks (`sudo mise bootstrap` drops the var); CI/CD agents running over SSH with scrubbed env; sessions inside automation tools (Ansible, Terraform provisioners) that clear the environment; tmux sessions resurrected without the original env.","solutions":["Preserve SSH_CONNECTION across escalation: `sudo -E mise bootstrap firewall apply`, or add `Defaults env_keep += \"SSH_CONNECTION\"` to sudoers.","Run the command from the direct SSH login shell (not through cron/sudo wrappers) so the guard can instead verify a covering allow rule.","If you have out-of-band console access (cloud serial console, IPMI, physical) and accept the risk, set `allow_lockout = true` under `[bootstrap.linux.firewall]`.","Pre-add an incoming TCP allow rule for your management IP and port before converging to deny-by-default."],"exampleFix":"# before\nsudo mise bootstrap firewall apply\n# error: refusing firewall default incoming deny from an SSH-derived process without SSH_CONNECTION ...\n\n# after — keep the proof of reachability through sudo\nsudo -E mise bootstrap firewall apply\n# or in /etc/sudoers:\n# Defaults env_keep += \"SSH_CONNECTION\"","handlingStrategy":"try-catch","validationCode":"# pre-flight: never converge a deny-by-default firewall without reachability proof\n[ -n \"$SSH_CONNECTION\" ] || { echo 'SSH_CONNECTION missing — apply from a direct SSH shell or set allow_lockout'; exit 1; }","typeGuard":null,"tryCatchPattern":"# bash — never auto-override the lockout guard\nif ! mise bootstrap firewall apply 2>fw.err; then\n  if grep -q \"allow_lockout\" fw.err; then\n    echo \"lockout guard tripped — get console access before overriding\"; exit 1\n  fi\n  cat fw.err; exit 1\nfi","preventionTips":["Apply firewall changes from a direct SSH login shell, not through sudo/cron wrappers that scrub env.","If you must escalate, use `sudo -E` or sudoers `Defaults env_keep += \"SSH_CONNECTION\"`.","Verify out-of-band console access (cloud serial console, IPMI) before any deny-by-default change.","Only set allow_lockout = true with console access in hand."],"tags":["mise","firewall","ssh","sudo","lockout","bootstrap","environment"],"backgroundTag":"firewall-lockout-protection","analyzedSha":"9dcfcaa0dc8747a2577d3270b69bb9d8313b2807","analyzedAt":"2026-08-17T14:28:50.624Z","schemaVersion":2},"datasetVersion":"2026-08-21T13:17:26.733Z"}