@@ -186,66 +186,82 @@ impl SystemResource for NFTable {
186186 }
187187}
188188
189- /// /etc/netns/ resolv.conf resource
189+ /// /etc/netns/ resolv.conf resource for network namespace DNS configuration
190+ ///
191+ /// # How it works
190192///
191193/// Uses Linux kernel's built-in /etc/netns/ mechanism. When a process enters
192- /// a network namespace, the kernel automatically bind-mounts files from
193- /// /etc/netns/<namespace>/ over their corresponding paths.
194+ /// a network namespace via `ip netns exec`, the kernel automatically bind-mounts
195+ /// files from /etc/netns/<namespace>/ over their corresponding paths.
196+ ///
197+ /// # Symlinked /etc/resolv.conf handling
198+ ///
199+ /// When /etc/resolv.conf is a symlink (common with systemd-resolved pointing to
200+ /// /run/systemd/resolve/stub-resolv.conf), ip netns exec follows the symlink and
201+ /// bind-mounts onto the target file. This requires:
202+ ///
203+ /// 1. **Creation**: Ensure the symlink target exists on the host (we create an empty
204+ /// placeholder in /run/systemd/resolve/ if needed - safe since /run is tmpfs)
205+ /// 2. **Cleanup**: Explicitly unmount the bind-mount at the symlink target during
206+ /// cleanup to prevent stale mounts from accumulating
194207///
195- /// This approach:
196- /// - Leverages standard Linux kernel feature
197- /// - No manual mount commands needed
198- /// - Works with symlinked /etc/resolv.conf
199- /// - Simple and robust
208+ /// # Safety
209+ ///
210+ /// - Host's /etc/resolv.conf is never modified directly
211+ /// - Placeholder creation is best-effort and won't affect systemd-resolved
212+ /// - Cleanup unmounts are idempotent and won't fail if already unmounted
200213pub struct NetnsResolv {
201214 netns_dir : PathBuf ,
202215 created : bool ,
203216}
204217
205218impl NetnsResolv {
206- /// Create /etc/netns/httpjail_<id>/ resolv.conf with specified nameserver
219+ /// Resolve /etc/resolv.conf to its canonical path, handling symlinks
207220 ///
208- /// The kernel will automatically bind-mount this over /etc/resolv.conf
209- /// when entering the namespace.
221+ /// Returns None if /etc/resolv.conf is not a symlink or cannot be resolved
222+ fn resolve_resolv_conf_target ( ) -> Option < PathBuf > {
223+ let symlink_target = fs:: read_link ( "/etc/resolv.conf" ) . ok ( ) ?;
224+
225+ // Convert relative path to absolute (e.g., "../run/systemd/resolve/stub-resolv.conf")
226+ let absolute_path = if symlink_target. is_absolute ( ) {
227+ symlink_target
228+ } else {
229+ PathBuf :: from ( "/etc" ) . join ( symlink_target)
230+ } ;
231+
232+ fs:: canonicalize ( absolute_path) . ok ( )
233+ }
234+
235+ /// Create /etc/netns/httpjail_<id>/resolv.conf with specified nameserver
210236 pub fn create_with_nameserver ( jail_id : & str , nameserver_ip : & str ) -> Result < Self > {
211237 let netns_dir = PathBuf :: from ( format ! ( "/etc/netns/httpjail_{}" , jail_id) ) ;
212238
213- // Create /etc/netns/<namespace>/ directory
239+ // Create directory and write resolv.conf
214240 fs:: create_dir_all ( & netns_dir)
215241 . with_context ( || format ! ( "Failed to create {}" , netns_dir. display( ) ) ) ?;
216242
217- // Write resolv.conf as a regular file (not symlink)
218243 let resolv_path = netns_dir. join ( "resolv.conf" ) ;
219- let content = format ! ( "# httpjail managed\n nameserver {}\n " , nameserver_ip) ;
220- fs:: write ( & resolv_path, content)
221- . with_context ( || format ! ( "Failed to write {}" , resolv_path. display( ) ) ) ?;
244+ fs:: write (
245+ & resolv_path,
246+ format ! ( "# httpjail managed\n nameserver {}\n " , nameserver_ip) ,
247+ )
248+ . with_context ( || format ! ( "Failed to write {}" , resolv_path. display( ) ) ) ?;
222249
223250 info ! (
224251 "Created {} with nameserver {}" ,
225252 resolv_path. display( ) ,
226253 nameserver_ip
227254 ) ;
228255
229- // CRITICAL FALLBACK: Create placeholder for symlink target on the HOST
230- //
231- // When /etc/resolv.conf is a symlink (e.g., to /run/systemd/resolve/stub-resolv.conf),
232- // ip netns exec needs the symlink target to exist for bind-mounting to work.
233- //
234- // We create an empty placeholder file on the HOST at /run/systemd/resolve/stub-resolv.conf.
235- // This is safe because:
236- // 1. /run is a tmpfs (RAM-based, cleared on reboot)
237- // 2. systemd-resolved manages this file and will overwrite it if needed
238- // 3. Our file is empty (0 bytes), won't affect anything
239- // 4. This is only a fallback for systems where the file doesn't already exist
240- //
241- // We silently ignore errors if the directory doesn't exist or we don't have permissions.
242- let _ = std:: fs:: create_dir_all ( "/run/systemd/resolve" ) ;
243- let _ = std:: fs:: OpenOptions :: new ( )
256+ // Ensure symlink target exists (see struct documentation for details)
257+ // Best-effort: ignore errors since the file might already exist or we lack permissions
258+ let _ = fs:: create_dir_all ( "/run/systemd/resolve" ) ;
259+ let _ = fs:: OpenOptions :: new ( )
244260 . create_new ( true )
245261 . write ( true )
246262 . open ( "/run/systemd/resolve/stub-resolv.conf" ) ;
247263
248- debug ! ( "Created placeholder /run/systemd/resolve/stub-resolv.conf if needed " ) ;
264+ debug ! ( "Ensured /run/systemd/resolve/stub-resolv.conf exists " ) ;
249265
250266 Ok ( Self {
251267 netns_dir,
@@ -266,44 +282,21 @@ impl SystemResource for NetnsResolv {
266282 return Ok ( ( ) ) ;
267283 }
268284
269- // CRITICAL: Unmount bind-mount created by ip netns exec
270- //
271- // When /etc/resolv.conf is a symlink (e.g., to /run/systemd/resolve/stub-resolv.conf),
272- // ip netns exec bind-mounts our custom resolv.conf onto the symlink target.
273- // This creates a bind-mount on the HOST that must be explicitly unmounted.
274- //
275- // We try to resolve /etc/resolv.conf and unmount it if it's a symlink target.
276- // This is safe because:
277- // - We only unmount if the file exists (best-effort)
278- // - Failed unmounts are logged but don't fail cleanup
279- // - Multiple unmounts are idempotent (second unmount will fail silently)
280- if let Ok ( resolved_path) = fs:: read_link ( "/etc/resolv.conf" ) {
281- // resolved_path might be relative like "../run/systemd/resolve/stub-resolv.conf"
282- // Convert to absolute path
283- let absolute_path = if resolved_path. is_absolute ( ) {
284- resolved_path
285- } else {
286- PathBuf :: from ( "/etc" ) . join ( & resolved_path)
287- } ;
288-
289- // Canonicalize to get the real path
290- if let Ok ( canonical_path) = fs:: canonicalize ( & absolute_path) {
291- // Try to unmount the bind-mount (best effort)
292- let _ = Command :: new ( "umount" ) . arg ( & canonical_path) . output ( ) ;
293- debug ! (
294- "Attempted to unmount bind-mount at {}" ,
295- canonical_path. display( )
296- ) ;
297- }
285+ // Unmount bind-mount at symlink target (see struct documentation for why)
286+ // Best-effort: ignore failures since mount might already be cleaned up
287+ if let Some ( target_path) = Self :: resolve_resolv_conf_target ( ) {
288+ let _ = Command :: new ( "umount" ) . arg ( & target_path) . output ( ) ;
289+ debug ! (
290+ "Attempted to unmount bind-mount at {}" ,
291+ target_path. display( )
292+ ) ;
298293 }
299294
300- // Remove /etc/netns/<namespace>/ directory and all contents
301- if let Err ( e) = fs:: remove_dir_all ( & self . netns_dir ) {
302- if e. kind ( ) != std:: io:: ErrorKind :: NotFound {
303- warn ! ( "Failed to remove {}: {}" , self . netns_dir. display( ) , e) ;
304- }
305- } else {
306- debug ! ( "Removed {}" , self . netns_dir. display( ) ) ;
295+ // Remove /etc/netns/<namespace>/ directory
296+ match fs:: remove_dir_all ( & self . netns_dir ) {
297+ Ok ( ( ) ) => debug ! ( "Removed {}" , self . netns_dir. display( ) ) ,
298+ Err ( e) if e. kind ( ) == std:: io:: ErrorKind :: NotFound => { }
299+ Err ( e) => warn ! ( "Failed to remove {}: {}" , self . netns_dir. display( ) , e) ,
307300 }
308301
309302 self . created = false ;
0 commit comments