Skip to content

Commit be84dcd

Browse files
committed
Fix NFS export, security and consolidate firewall rules
Replace no_root_squash with root_squash in NFS export options. With no_root_squash, any root user on K8s nodes (or root-running pods) had full root access to all NFS shares. Now squashed root maps to configurable anonuid/anongid (default 65534/nobody). Add anonuid and anongid to the exports template so the mapping is explicit in /etc/exports. Consolidate UFW firewall configuration and validation into a single block so all rules, enable, and verification run as one atomic unit gated on UFW availability. Change fsid: fsid is needed because we're exporting subdirectories of a FUSE filesystem (mergerfs). The loop.index starts at 1 and increments. This is fine as long as storage_dirs never changes order. If we reorder or insert a directory, existing mounts may break because the fsid changed. So instead, using a hash of the directory name. Exporting subdirectories instead of the root. We export /mnt/storage/Videos, /mnt/storage/Music, etc. separately. This means every app that needs access to a different subdirectory requires its own PV/PVC. Instead, export /mnt/storage once and use subPath in K8s volume mounts. This dramatically simplifies the NFS side and reduces the number of NFS connections.
1 parent 88df047 commit be84dcd

File tree

5 files changed

+55
-65
lines changed

5 files changed

+55
-65
lines changed

docs/guides/how_to_use_nfs_for_persistent_storage.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,27 @@ You can have some (or all) of your persistent volumes provisioned on your NAS se
7979
--8<--
8080
```
8181

82+
## NFS Permissions and `root_squash`
83+
84+
The NFS server is configured with `root_squash` (the NFS default). This means any process connecting as root (uid 0) from a K8s node is mapped to the anonymous user on the NFS server. Non-root users pass through unchanged.
85+
86+
The anonymous uid/gid is configured via `nfs_anon_uid`/`nfs_anon_gid` (default `1000`) and should match the `fsGroup` used by your application pods. This ensures that:
87+
88+
- **kubelet `fsGroup` chown** works correctly — the kubelet runs as root, so its chown calls are squashed to uid 1000, which matches the target group
89+
- **Root-running containers** (e.g., linuxserver.io images that start as root before dropping to `PUID`/`PGID`) create files owned by uid 1000 on the NFS server
90+
- **Non-root containers** running as uid 1000 can read/write files created by either path above
91+
92+
Your pod spec should set `fsGroup` to match:
93+
94+
```yaml
95+
spec:
96+
securityContext:
97+
fsGroup: 1000
98+
```
99+
100+
!!! tip
101+
If your applications use a different uid (e.g., 568 for some Helm charts), override `nfs_anon_uid` and `nfs_anon_gid` in your inventory to match.
102+
82103
## Reference
83104

84105
- [csi-driver-nfs/charts/README.md@v4.13.0 · kubernetes-csi/csi-driver-nfs](https://github.com/kubernetes-csi/csi-driver-nfs/blob/v4.13.0/charts/README.md)

metal/roles/storage/defaults/main.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
homelab_net_cidr: "10.10.10.0/24"
44

55
# Default NFS export options
6-
default_nfs_options: "rw,sync,no_subtree_check,no_root_squash,insecure"
6+
default_nfs_options: "rw,sync,no_subtree_check,root_squash,insecure"
7+
8+
# Anonymous uid/gid for root_squash mapping (squashed root becomes this user).
9+
# Set to match the uid/gid used by application pods (e.g. fsGroup) so that
10+
# root-running containers and kubelet fsGroup chown operations work correctly.
11+
nfs_anon_uid: 1000
12+
nfs_anon_gid: 1000
713

814
# Firewall configuration
915
manage_firewall: true # Set to false to skip firewall configuration

metal/roles/storage/tasks/nfs_firewall.yml

Lines changed: 16 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@
22
# NFS Firewall Configuration
33
# Configures UFW firewall rules for NFS services
44

5-
# =================================================================
6-
# FIREWALL DETECTION AND SETUP
7-
# =================================================================
8-
95
- name: Check if UFW is installed
106
command: which ufw
117
register: ufw_check
@@ -30,11 +26,7 @@
3026
debug:
3127
msg: "UFW is {{ 'available' if ufw_available.rc == 0 else 'not available' }}"
3228

33-
# =================================================================
34-
# UFW CONFIGURATION
35-
# =================================================================
36-
37-
- name: Configure UFW firewall rules
29+
- name: Configure and validate UFW firewall
3830
when: ufw_available.rc == 0
3931
block:
4032
- name: Check UFW status
@@ -53,18 +45,18 @@
5345
direction: outgoing
5446
policy: allow
5547

56-
- name: Configure TCP ports in firewall
48+
- name: Allow SSH access
5749
ufw:
5850
rule: allow
59-
src: "{{ item.0 }}"
60-
port: "{{ item.1 }}"
51+
src: "{{ item }}"
52+
port: "22"
6153
proto: tcp
62-
comment: "{{ item.1 }}/tcp"
63-
loop: "{{ firewall_allowed_networks | product(['22']) | list }}"
54+
comment: "SSH 22/tcp"
55+
loop: "{{ firewall_allowed_networks }}"
6456
loop_control:
65-
label: "{{ item.0 }} -> {{ item.1 }}/tcp"
57+
label: "{{ item }} -> 22/tcp"
6658

67-
- name: Configure NFS TCP ports in firewall
59+
- name: Allow NFS TCP ports
6860
ufw:
6961
rule: allow
7062
src: "{{ item.0 }}"
@@ -75,18 +67,22 @@
7567
loop_control:
7668
label: "{{ item.0 }} -> {{ item.1 }}/tcp"
7769

78-
- name: Configure NFS UDP ports in firewall
70+
- name: Allow NFS UDP ports
7971
ufw:
8072
rule: allow
8173
src: "{{ item.0 }}"
8274
port: "{{ item.1 }}"
8375
proto: udp
8476
comment: "NFS {{ item.1 }}/udp"
85-
# UDP 2049 to support potential NFSv3 clients
8677
loop: "{{ firewall_allowed_networks | product(['111', '2049']) | list }}"
8778
loop_control:
8879
label: "{{ item.0 }} -> {{ item.1 }}/udp"
8980

81+
- name: Enable UFW if not active
82+
ufw:
83+
state: enabled
84+
when: "'Status: active' not in ufw_status.stdout"
85+
9086
- name: Display firewall rules
9187
command: ufw status numbered
9288
register: ufw_rules
@@ -97,28 +93,15 @@
9793
msg: "{{ ufw_rules.stdout_lines }}"
9894
when: ufw_rules.stdout_lines is defined
9995

100-
- name: Enable UFW if not active
101-
ufw:
102-
state: enabled
103-
when: "'Status: active' not in ufw_status.stdout"
104-
register: ufw_enable
105-
106-
# =================================================================
107-
# FIREWALL VALIDATION
108-
# =================================================================
109-
110-
- name: Validate firewall allows NFS traffic
111-
when: ufw_available.rc == 0
112-
block:
113-
- name: Check if NFS ports are allowed
96+
- name: Verify NFS firewall rules
11497
shell: |
11598
set -o pipefail
11699
ufw status | grep -E '(111|2049|20048)'
117100
register: nfs_ports_check
118101
changed_when: false
119102
failed_when: false
120103

121-
- name: Verify NFS firewall rules
104+
- name: Assert NFS firewall rules are present
122105
assert:
123106
that:
124107
- nfs_ports_check.rc == 0

metal/roles/storage/tasks/nfs_validation.yml

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -38,24 +38,12 @@
3838
changed_when: false
3939
failed_when: showmount_result.rc != 0
4040

41-
- name: Parse exported directories
42-
set_fact:
43-
exported_dirs: "{{ showmount_result.stdout_lines[1:] | map('regex_replace', '^(/\\S+).*', '\\1') | list }}"
44-
when: showmount_result.stdout_lines | length > 1
45-
46-
- name: Display exported directories
47-
debug:
48-
msg: "Exported NFS directories: {{ exported_dirs | join(', ') }}"
49-
when: exported_dirs is defined
50-
51-
- name: Verify expected exports are present
41+
- name: Verify /mnt/storage export is present
5242
assert:
5343
that:
54-
- "'/mnt/storage/' + item in exported_dirs"
55-
fail_msg: "NFS export missing: /mnt/storage/{{ item }}"
56-
success_msg: "NFS export present: /mnt/storage/{{ item }}"
57-
loop: "{{ storage_dirs }}"
58-
when: exported_dirs is defined
44+
- "'/mnt/storage' in showmount_result.stdout"
45+
fail_msg: "NFS export missing: /mnt/storage"
46+
success_msg: "NFS export present: /mnt/storage"
5947

6048
- name: Check if ufw firewall is active
6149
command: ufw status
@@ -161,19 +149,13 @@
161149
fail_msg: "Cannot reach NFS exports from controller - check firewall and network connectivity"
162150
success_msg: "NFS exports visible from controller"
163151

164-
- name: Parse remote exported directories
165-
set_fact:
166-
remote_exported_dirs: "{{ remote_showmount.stdout_lines[1:] | map('regex_replace', '^(/\\S+).*', '\\1') | list }}"
167-
when: remote_showmount.stdout_lines | length > 1
168-
169-
- name: Verify expected exports visible from controller
152+
- name: Verify /mnt/storage export visible from controller
170153
assert:
171154
that:
172-
- "'/mnt/storage/' + item in remote_exported_dirs"
173-
fail_msg: "NFS export not visible from controller: /mnt/storage/{{ item }}"
174-
success_msg: "NFS export visible from controller: /mnt/storage/{{ item }}"
175-
loop: "{{ storage_dirs }}"
176-
when: remote_exported_dirs is defined
155+
- "'/mnt/storage' in remote_showmount.stdout"
156+
fail_msg: "NFS export not visible from controller: /mnt/storage"
157+
success_msg: "NFS export visible from controller: /mnt/storage"
158+
when: remote_showmount.rc == 0
177159

178160
- name: NFS validation complete
179161
debug:

metal/roles/storage/templates/exports.j2

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@
33
#
44
# Generated by Ansible - DO NOT EDIT MANUALLY
55

6-
# Default NFS exports for Kubernetes
7-
{% for dir in storage_dirs %}
8-
/mnt/storage/{{ dir }} {{ homelab_net_cidr | default('10.10.10.0/24') }}({{ default_nfs_options }},fsid={{ loop.index }})
9-
{% endfor %}
6+
# Single root export — clients mount subdirectories via path (e.g. server:/mnt/storage/Videos)
7+
/mnt/storage {{ homelab_net_cidr | default('10.10.10.0/24') }}({{ default_nfs_options }},anonuid={{ nfs_anon_uid }},anongid={{ nfs_anon_gid }},fsid=1)
108

119
# Custom exports (if defined in inventory)
1210
{% if nfs_exports is defined %}

0 commit comments

Comments
 (0)