无特权 LXC 中的分级Kernel TUN:没有用户空间联网的直接SSH

2026年8月22日1 次浏览来源:Dev.to阅读原文

正文保留英文原文(机翻易破坏代码与排版),标题/摘要已提供中文

gets you a green dot in the admin console and almost nothing else.

The node appears in your tailnet, looks healthy, and then you try to SSH into that container from your laptop and the connection hangs until TCP gives up.

Two lines in the LXC config file fix it, and the container stays unprivileged.

That's the whole post, really.

But those two lines only make sense once you understand why every guide pushes you toward userspace mode in the first place, and what you're giving up by staying there.

Who should care Anyone running services in unprivileged LXC containers on Proxmox who wants those containers to be real tailnet members with their own address.

Not reachable through something else.

Reachable directly, over WireGuard, with a kernel network interface that can see.

If you're already routing everything through a subnet router, you have a working setup and this is an optional upgrade.

I covered that pattern in Tailscale Subnet Routers.

Treat this as the next rung on the ladder: instead of one node advertising routes on behalf of everyone else, each container carries its own identity, its own ACL surface, and its own direct path to peers.

What userspace networking actually costs you Every LXC-and-Tailscale guide I've read lands on the same instruction: pass and move on.

It works because it sidesteps the problem entirely.

Rather than asking the kernel for a TUN device, tailscaled runs a userspace TCP/IP stack (gVisor's netstack) inside its own process and never opens .

Those costs stay invisible until you trip over one.

Outbound traffic needs a proxy.

In userspace mode, tailscaled exposes SOCKS5 and HTTP proxies on a local port.

Nothing on the system routes to automatically, because there is no interface and no route.

Every client has to be told about the proxy: Miss that env var in a systemd unit, a cron job, or a nested container, and the traffic silently takes the normal default route instead.

No error.

It just goes somewhere else, which is the worst failure mode a network can have.

Inbound to a normal daemon doesn't happen.

Your binds on the container's LAN interface.

Packets arriving over the tailnet terminate inside tailscaled's netstack, and there's no path from netstack to a socket the kernel owns unless you build one explicitly with , or you switch to Tailscale SSH where tailscaled itself is the SSH server.

Tailscale SSH genuinely works in userspace mode, which is why plenty of people never notice the limitation.

But that's tailscaled's SSH implementation, not OpenSSH.

If you care about host key pinning, command restrictions, blocks, or an you've tuned, you now maintain two parallel SSH stories on the same box.

UDP is a mess.

Netstack's UDP support has been partial for years.

Concrete symptom: mosh does not work. starts fine, prints its port and key, and the client sits there forever because the datagrams never arrive.

If you SSH over flaky mobile links and lean on mosh to survive roaming, userspace mode is a dead end.

None of this is a bug.

It's a documented tradeoff, and for a container that only needs to call out to a couple of HTTP endpoints, it's a perfectly reasonable one.

The problem is that most guides present it as the answer rather than the fallback it actually is.

The false starts First instinct, recommended constantly on forums: make the container privileged.

Set , restore, done, TUN works.

It also hands the container a root that maps to real host root, which throws away the single most valuable property of an unprivileged container.

For a machine that's about to sit on a VPN and accept inbound connections from anywhere, that trade is exactly backwards.

Second instinct: .

That flag shows up in nearly every Proxmox thread about containers doing unusual things, and it's genuinely required for Docker-in-LXC and systemd cgroup delegation.

It does nothing for TUN.

Nesting controls whether the container can see and mount its own cgroup hierarchy and its own procfs/sysfs views.

Device access is a completely separate mechanism.

Set it, restart, observe zero change, spend twenty minutes wondering what you got wrong.

AppArmor is the third dead end.

There's a pile of advice suggesting to get device access working.

That's a sledgehammer for a problem AppArmor isn't causing.

The default profile does not block usage; it blocks a set of mount, ptrace, and write operations, none of which sit in the path here.

Dropping confinement to fix a device permission issue is the kind of change that looks like it worked and quietly widens the blast radius.

I've seen this pattern bite people in other contexts too, which is the same theme as the runc sysctl trap: the fix that "works" is usually the one that removed a boundary you wanted.

What's actually stopping you is much narrower than any of that.

The actual fix Three conditions have to hold: the host has the module loaded, the container's device cgroup allows that specific char device, and the device node is bind-mounted into the container's filesystem.

1.

Load on the host and persist it On the Proxmox host: Persist it so a host reboot doesn't quietly undo everything: Do this before the next step.

The bind mount below has a source path on the host.

If doesn't exist when the container starts, the mount fails and the container refuses to start, which is a confusing way to learn that a kernel module wasn't loaded.

Repeat this on every node in the cluster.

A container that migrates to a host without loaded will fail to start there, and you'll be debugging a mount error at the worst possible moment.

2.

Add two lines to the container config Edit on the host that currently owns the container: That's it.

No , no AppArmor changes, no nesting.

Two details matter in the second line.

The target path has no leading slash, because LXC resolves mount targets relative to the container rootfs.

And tells LXC to create the target inode if it doesn't already exist, which it won't on a fresh container.

The first line uses cgroup v2 syntax.

Anything running Proxmox 8.x is on the unified hierarchy, so is what you want.

Older setups on cgroup v1 used with the same argument format; if you're copying a snippet from a 2019 forum post, check which one it uses.

3.

Stop and start the container from the host This part catches people.

Rebooting from inside the container does not re-read the config file, because the config is consumed by LXC on container creation, not by the init system inside it.

You need a full stop/start cycle driven from the host: Until you do that, simply won't exist inside the container, and you'll assume the config was wrong.

4.

Drop the userspace flag and bring Tailscale up If tailscaled was previously running in userspace mode, that flag lives in : Then restart and authenticate: I set on servers by habit.

MagicDNS rewriting on a box that already has opinions about DNS is a fight you don't need, especially if you're running your own resolver like AdGuard Home.

Turn it on deliberately if you want it.

Verifying it actually worked Four checks, in order.

Each one fails differently, which is what makes them useful.

That fourth line is the one worth reading carefully. means a direct WireGuard tunnel between the two hosts.

If it says instead, you're relaying through Tailscale's infrastructure, which still works but adds latency and depends on someone else's servers. will tell you whether UDP is reachable and whether you're behind a NAT that's blocking direct paths.

Then test the thing you came for: Why it works Three separate mechanisms have to cooperate, and the reason so much bad advice exists is that people conflate them.

Device cgroup allowlist.

An unprivileged LXC container starts with a deny-by-default device policy and a short allowlist covering the basics: , , , , , , and the pty devices.

The TUN driver registers as char device major 10, minor

200.

It's not on that list, so any on it returns regardless of file permissions.

Adding grants read, write, and mknod for exactly one device node and nothing else.

Tha

分享