Network Fault DiagnosisLesson 3 of 3
Firewalls, SELinux ports and proving it with tcpdump
Tell a drop from a reject, read firewalld and nftables state, and use tcpdump to show where packets stop.
tcpdumptcpdump -nn -ifirewall-cmd --list-allfirewall-cmd --get-active-zonesfirewall-cmd --permanentnft list rulesetsemanage port -lss -tlnpncausearch -m avc
After this lesson you can
- Use tcpdump filters to show a refused connection, a dropped one and a successful one.
- Read firewalld zones and the nftables ruleset behind them, and find which zone handles a given interface or source.
- Separate a firewall block from an SELinux port denial and from a service that is not listening.
The idea
When a connection fails and the routes and names are right, three more things can still be in the way on the server: nothing is listening, a firewall blocks it, or SELinux stops the service from using the port at all. They produce different symptoms, and a packet capture lets you see which one you have without guessing.
Reading a TCP connection in a capture
TCP opens with three packets, the handshake. tcpdump prints each with its flags:
| Flag text | Packet | Direction |
|---|---|---|
[S] | SYN: “I want to connect” | client to server |
[S.] | SYN-ACK: “OK, me too” | server to client |
[.] | ACK: “connected” | client to server |
[P.] | data (PSH+ACK) | either |
[F.] | finish: close | either |
[R.] | reset: “no” or “abort” | either |
What the capture on the server shows tells you the layer:
| Capture on the server | Meaning | Client sees |
|---|---|---|
[S] then [S.] | Listener present and firewall open | Connects |
[S] then [R.] | Host answered “closed”: nothing listens (or a REJECT rule with tcp-reset) | Connection refused, immediately |
[S], [S], [S]… with growing gaps and no reply | Packet arrived and was silently discarded, e.g. firewall DROP | Connection timed out |
| Nothing arrives | Lost before the server: routing, a firewall on the path, a wrong address | Timeout |
ICMP port unreachable / admin prohibited reply | REJECT (firewalld’s default for blocked traffic) | Connection refused or No route to host |
Where you capture matters: on the server you see what reached it; on the client you see what it sent and what came back.
tcpdump in practice
sudo tcpdump -nn -i any 'tcp port 8080' # one port
sudo tcpdump -nn -i eth0 'host 192.168.50.11 and tcp port 22'
sudo tcpdump -nn -i any 'icmp' # pings and ICMP errors
sudo tcpdump -nn -i any -w /tmp/web.pcap 'port 80' # write for Wireshark; -r to read it back
sudo tcpdump -nn -i any -c 10 'tcp[tcpflags] & tcp-syn != 0' # only connection attempts
-nn avoids DNS and service-name lookups (which are slow and mask what you see). -c N stops after N packets. A capture needs root, and never leave -w running unattended: files grow quickly.
firewalld on top of nftables
On RHEL 10 firewalld programs nftables. You manage it with firewall-cmd; nft list ruleset shows the result. Two states exist for every setting:
firewall-cmd --get-active-zones # which zones are in use and which interfaces/sources belong to them
firewall-cmd --list-all # the default zone, with services, ports, rich rules
firewall-cmd --zone=internal --list-all # a specific zone
firewall-cmd --get-default-zone
firewall-cmd --add-port=8080/tcp # runtime only: lost at reload/reboot
firewall-cmd --permanent --add-port=8080/tcp # stored, active after --reload
firewall-cmd --reload # apply permanent rules, drop runtime-only changes
The usual mistake is a rule added without --permanent (works now, gone after reboot) or with --permanent and no --reload (stored, not yet active). Compare firewall-cmd --list-ports with firewall-cmd --permanent --list-ports.
The zone that applies to a packet depends on the interface it arrived on or its source address. If your rule is in public and the interface is in internal, the rule never matches. Find the zone first: firewall-cmd --get-zone-of-interface=eth0.
SELinux and ports
SELinux controls which port numbers a confined service may use. Apache (httpd_t) may bind to ports labelled http_port_t (80, 81, 443, 8008, 8009, 8443, 9000, among others). To use another port, label it:
sudo semanage port -l | grep -w http_port_t
sudo semanage port -a -t http_port_t -p tcp 8500
An unlabelled port gives a service that fails to start with Permission denied/(13)Permission denied: AH00072: make_sock: could not bind to address, and an AVC with { name_bind } in ausearch -m avc. The firewall is not involved: the service never began to listen.
Try it
A refused connection, in packets
Real output: tcpdump on the loopback while nc connected to a closed port 9.
sudo tcpdump -nn -i lo -l 'tcp port 9' &
nc -zv 127.0.0.1 9
nc: connect to 127.0.0.1 port 9 (tcp) failed: Connection refused
17:30:10.899588 IP 127.0.0.1.51574 > 127.0.0.1.9: Flags [S], seq 188140946, win 65495, options [mss 65495,sackOK,TS val 142171407 ecr 0,nop,wscale 10], length 0
17:30:10.899602 IP 127.0.0.1.9 > 127.0.0.1.51574: Flags [R.], seq 0, ack 188140947, win 0, length 0
[S] from client port 51574 to port 9, and 14 microseconds later [R.]: the kernel of the destination replied “reset”. That is refused: you reached the host; the port is closed.
The same connection, dropped
An nftables rule that drops TCP port 9 was added (a real ruleset):
nft add table inet labt
nft add chain inet labt input '{ type filter hook input priority 0; policy accept; }'
nft add rule inet labt input tcp dport 9 drop
nft list ruleset
table inet labt {
chain input {
type filter hook input priority filter; policy accept;
tcp dport 9 drop
}
}
Now the client:
time nc -zv -w 3 127.0.0.1 9
nc: connect to 127.0.0.1 port 9 (tcp) timed out: Operation now in progress
real 0m3.005s
and the capture:
17:30:16.955637 IP 127.0.0.1.51586 > 127.0.0.1.9: Flags [S], seq 395230208, ...
17:30:17.972522 IP 127.0.0.1.51586 > 127.0.0.1.9: Flags [S], seq 395230208, ...
17:30:18.996507 IP 127.0.0.1.51586 > 127.0.0.1.9: Flags [S], seq 395230208, ...
The same seq number on every line means the client is retransmitting the same SYN, about a second apart, and never gets an answer. That is a drop: the packet arrived and was discarded. A firewalld zone with the default policy reject sends an ICMP error instead, so the client would give up at once with Connection refused or No route to host rather than waiting.
(The test table was removed afterwards with nft delete table inet labt.)
On RHEL: where is the rule?
sudo firewall-cmd --get-active-zones
sudo firewall-cmd --list-all
sudo nft list ruleset | grep -n 8500
public
interfaces: eth0
public (active)
target: default
services: cockpit dhcpv6-client ssh
ports: 8500/tcp
rich rules:
(Representative output.) If 8500/tcp appears in --list-all but not in --permanent --list-ports, the rule is runtime-only. If the interface is in a different zone than the one you edited, edit the zone it is in.
The service view
sudo ss -tlnp | grep -E '8500|httpd'
sudo ausearch -m avc -ts recent | tail -2
journalctl -u httpd -b --no-pager | tail -3
LISTEN 0 511 127.0.0.1:8500 0.0.0.0:* users:(("httpd",pid=7021,fd=4))
type=AVC msg=audit(1730721120.114:521): avc: denied { name_bind } for pid=7015 comm="httpd" src=8500 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:unreserved_port_t:s0 tclass=tcp_socket permissive=0
httpd[7015]: (13)Permission denied: AH00072: make_sock: could not bind to address [::]:8500
(Representative output.) Read the three together: a listener on 127.0.0.1 only (explains the remote Connection refused), a name_bind denial for port 8500 from httpd_t (SELinux; fix with the port label), and Apache’s own message of the failed bind.
The order to follow
- Is it listening, and on what?
ss -tlnpon the server. Not listening: the service, its configuration, or SELinux (journalctl -u,ausearch -m avc). - Is it listening on the right address?
127.0.0.1is not reachable from outside. - Does it answer locally?
curlfrom the server. If not, it is the application. - Does the packet arrive?
tcpdumpon the server while the client connects. - Is the firewall open for that zone, permanently?
firewall-cmd --list-alland--permanent --list-ports, for the zone the interface is in. - Is anything in between? Routers, cloud security groups, other firewalls: capture on both ends and compare.
Going further
Logging dropped packets. firewall-cmd --set-log-denied=all logs rejected packets to the kernel log (watch with journalctl -k -f); it is noisy, so switch it back to off after use. Beyond the exam: direct nftables rules should be avoided on a firewalld system, as the two can conflict; use rich rules or firewall-cmd --direct knowingly. ss -s and nstat -az | grep -i retrans show whether a path is silently losing packets.
Practice
0 of 3 done
Do these on your own system. They get harder. Check each result, then tick it off.
- Warm-up
See a handshake
Start a listener on port 8080 on node2 (
python3 -m http.server 8080). On node2, capturetcp port 8080in one terminal (sudo tcpdump -nn -i any 'tcp port 8080') and from node1 runcurl -s http://node2.lab.example:8080/ >/dev/null. Identify the three packets of the handshake by their flags.Check your result
- The capture shows
Flags [S](SYN), thenFlags [S.](SYN-ACK), thenFlags [.](ACK), followed byFlags [P.]data. - You can say which direction each packet travels.
Hint
-nnsuppresses name and port lookups,-i anylistens on all interfaces. The dot in[S.]means ACK is set.[R.]is a reset,[F.]a finish.Solution
# node2 python3 -m http.server 8080 & sudo tcpdump -nn -i any -c 12 'tcp port 8080' # node1 curl -s http://node2.lab.example:8080/ > /dev/null - The capture shows
- Practice
Refused, dropped or accepted?
On node2, with nothing listening on port 9090, connect from node1 with
nc -zv node2.lab.example 9090and capture on node2. Then add a firewalld rich rule that drops TCP 9090 and repeat. Compare: the message on node1, the time it took, and the packets on node2 in each case. Remove the rule (and keep the firewall otherwise unchanged).Check your result
- Without the rule:
Connection refusedimmediately, and the capture shows an[R.]reply to the[S]. - With the drop rule: a timeout after the full wait, and the capture shows only repeated
[S]packets with no reply. sudo firewall-cmd --list-rich-rulesis empty at the end.
Hint
sudo firewall-cmd --add-rich-rule='rule family="ipv4" port port="9090" protocol="tcp" drop'adds a runtime rule;--remove-rich-rulewith the same text deletes it. SYN packets sent repeatedly with growing gaps (1 s, 2 s, 4 s) are TCP retransmissions.Solution
# node2 sudo tcpdump -nn -i any -c 6 'tcp port 9090' & # node1 time nc -zv -w 5 node2.lab.example 9090 # node2 sudo firewall-cmd --add-rich-rule='rule family="ipv4" port port="9090" protocol="tcp" drop' # node1 time nc -zv -w 5 node2.lab.example 9090 # node2 sudo firewall-cmd --remove-rich-rule='rule family="ipv4" port port="9090" protocol="tcp" drop' sudo firewall-cmd --list-rich-rules - Without the rule:
- Challenge
Firewall, listener or SELinux?
A web service must be reachable from node1 at
http://node2.lab.example:8500/. It does not answer. Create the situation and diagnose it in order. Setup on node2:sudo semanage port -d -t http_port_t -p tcp 8500 2>/dev/null; sudo systemctl stop httpdand configure Apache withListen 8500(leave SELinux enforcing, the port unlabelled, the firewall closed). Then find and fix, one at a time and each with evidence: (1) the service does not start (ausearch -m avc,journalctl -u httpd); (2) it starts, but node1 gets a timeout (firewall-cmd); (3) it answers locally, node1 getsConnection refusedbecause Apache listens only on 127.0.0.1 (ss -tlnp).Check your result
sudo semanage port -l | grep -w http_port_tincludes8500.sudo firewall-cmd --permanent --list-portsincludes8500/tcp.sudo ss -tlnp | grep 8500shows0.0.0.0:8500or*:8500.- From node1:
curl -sI http://node2.lab.example:8500/returnsHTTP/1.1 200or403.
Hint
Three layers, three symptoms: a service that fails to start with
Permission deniedbinding the port is SELinux (name_bindAVC); a timeout from outside is the firewall;Connection refusedfrom outside while it works locally is the listen address. Fix each before looking for the next.Solution
sudo sed -i 's/^Listen .*/Listen 127.0.0.1:8500/' /etc/httpd/conf/httpd.conf sudo systemctl start httpd; journalctl -u httpd --no-pager | tail -3 sudo ausearch -m avc -ts recent | tail -3 sudo semanage port -a -t http_port_t -p tcp 8500 sudo systemctl start httpd; curl -sI http://127.0.0.1:8500/ | head -1 # from node1: timeout -> firewall sudo firewall-cmd --permanent --add-port=8500/tcp && sudo firewall-cmd --reload # from node1: refused -> listen address sudo ss -tlnp | grep 8500 sudo sed -i 's/^Listen .*/Listen 8500/' /etc/httpd/conf/httpd.conf sudo systemctl restart httpd