OpenWrt on a Raspberry PI 4B: A Windows File Share with ksmbd

Written with AI assistance

The setup, the measurements and the conclusions in this article are my own. An AI assistant helped me with research, drafting and editing the text.

OpenWrt on a Raspberry PI 4B: A Windows File Share with ksmbd #

Work in progress, October/November 2026. Built on OpenWrt 25.12.5 with kernel 6.12.94, ksmbd-server 3.5.6, avahi-nodbus-daemon 0.9_rc5 and minidlna 1.3.3. The share is set up as described below, the test from Windows is still outstanding.

In OpenWrt on a Raspberry PI 4B: BitTorrent through a VPN I set up a torrent client in Docker which saves its downloads on the SSD of the router, in /opt/torrent/downloads. This article describes how I make this directory available to the Windows PCs in my LAN as a network drive, so that the files can be opened, copied, renamed and deleted directly in the Windows Explorer.

The protocol Windows speaks natively for this is SMB. There is no additional software needed on the PC, the share is simply opened as \\router\downloads. Although the download directory is the reason for this article, the same steps work for any other directory on the router.

Why ksmbd #

OpenWrt offers two SMB servers:

  • Samba (samba4-server) is the classic one. It is complete and very compatible, but also large and comparatively slow on a small CPU. Most of what it can do, such as acting as an Active Directory domain controller, I do not need for a single directory.
  • ksmbd (ksmbd-server) is an SMB server which runs inside the Linux kernel. Only the user management and the configuration run in user space. It needs little memory and CPU, supports the current SMB versions 2 and 3, and the OpenWrt package comes with a page in the web interface.

I chose ksmbd. I also considered running Samba in a Docker container, but a file share is a service of the router itself, not an application. It would have needed port 445 published on the LAN address, and gained nothing.

Installing #

root@OpenWrt:~# apk update
root@OpenWrt:~# apk add ksmbd-server luci-app-ksmbd

ksmbd-server pulls in the kernel module kmod-fs-ksmbd, and luci-app-ksmbd adds the page Services → Network Shares to the web interface. The configuration ends up in /etc/config/ksmbd, from which the init script generates the actual configuration file /etc/ksmbd/ksmbd.conf on every start.

A user who owns the files #

Before the share itself, it is worth thinking about who will own the files. ksmbd maps every SMB user to the Linux user of the same name, and that user owns everything which is created or changed through the share. At the same time, qBittorrent writes all downloads as the user with the ID 1000 (PUID=1000 in its compose file).

The simplest solution is a Linux user on the router with exactly this ID. Then the files of qBittorrent and the files written from Windows have the same owner, and both sides can change each other’s files without any force user option. (Note: the uci configuration of ksmbd does not offer force user anyway, only force_root, which would make root the owner of everything)

I called the user media. OpenWrt has no useradd or adduser, so I added the user to the three files by hand, after a backup copy of each:

root@OpenWrt:~# for f in passwd group shadow; do \
  cp /etc/$f /etc/$f.bak-ksmbd; done
root@OpenWrt:~# echo "media:x:1000:1000:media:/var/empty:/bin/false" \
  >> /etc/passwd
root@OpenWrt:~# echo "media:x:1000:" >> /etc/group
root@OpenWrt:~# echo "media:*:0:0:99999:7:::" >> /etc/shadow
  • /bin/false as shell and * as password mean that media cannot log in to the router, neither on the console nor via SSH. The user only exists to own files.
  • The SMB password is separate. ksmbd keeps its own password database in /etc/ksmbd/ksmbdpwd.db, which is set further below.

The share #

The share is a section in /etc/config/ksmbd:

root@OpenWrt:~# uci batch <<EOF
set ksmbd.downloads=share
set ksmbd.downloads.name=downloads
set ksmbd.downloads.path=/opt/torrent/downloads
set ksmbd.downloads.users=media
set ksmbd.downloads.read_only=no
set ksmbd.downloads.browseable=yes
set ksmbd.downloads.guest_ok=no
set ksmbd.downloads.create_mask=0664
set ksmbd.downloads.dir_mask=0775
EOF
root@OpenWrt:~# uci commit ksmbd
root@OpenWrt:~# service ksmbd enable
root@OpenWrt:~# service ksmbd restart
OptionMeaning
users=mediaOnly media may open the share
read_only=noRead and write. I want to delete and rename downloads from Windows
guest_ok=noNo access without a password
create_mask, dir_maskNew files can be written by the owner and the group, and read by everybody on the router

The configuration file generated from this shows two more details from the defaults of the package:

root@router:~# cat /etc/ksmbd/ksmbd.conf
[global]
  netbios name = router
  server string = Ksmbd on OpenWrt
  workgroup = WORKGROUP
  interfaces = br-lan 
  bind interfaces only = yes
  ipc timeout = 20
  deadtime = 15
  map to guest = Bad User
  server signing = auto

######### Dynamic written config options #########

[downloads]
  path = /opt/torrent/downloads
  valid users = media
  create mask = 0664
  directory mask = 0775
  browseable = yes
  read only = no
  guest ok = no
  • netbios name = router is the host name of the router, which the init script reads on every start. I renamed the router from OpenWrt to router, as described in the Essentials article. ksmbd does not run a NetBIOS name service, so the name is only used inside the SMB protocol.
  • interfaces = br-lan comes from the option interface, which defaults to lan. ksmbd only serves the LAN. For the phone in the WireGuard tunnel, I added wg0 later, as described in Access through WireGuard.
  • server signing = auto matters for current versions of Windows 11, which require signed SMB connections by default. ksmbd signs them when the client asks for it.
  • Old protocol versions are off. SMB 1 is only enabled with the option allow_legacy_protocols, which is off by default, and it should stay that way.

The SMB login #

The login consists of a user name and a password. The user name is the Linux user, media, and it has to be listed in the users option of the share. The password is separate from the Linux password and lives in the ksmbd password database /etc/ksmbd/ksmbdpwd.db. Everything around this database is done with ksmbd.adduser. The web interface does not manage SMB users.

In order to find out how the commands behave, I tested them with a throwaway user before writing this section, so that the password of media stayed untouched.

1. Setting the password #

-a adds the user to the database and prompts for the password. That way, it does not end up in the shell history:

root@router:~# ksmbd.adduser -a media

It is worth mentioning that ksmbd.adduser also has a -p option which takes the password on the command line. I do not use it for real passwords, because the password would then be in the shell history.

2. Changing the password #

-u updates an existing user and prompts for the new password. This is the output for my test user smbtest:

root@router:~# ksmbd.adduser -u smbtest
...
[ksmbd.adduser/12408]: INFO: Wrote `/etc/ksmbd/ksmbdpwd.db'
[ksmbd.adduser/12408]: INFO: Updated user `smbtest'
[ksmbd.adduser/12408]: INFO: Notified mountd

No restart is needed, ksmbd.adduser tells the running daemon about the change (“Notified mountd”). However, the change is not instant. In my test, the old password was still accepted right after the command, and the new one was refused. A few seconds later, it was the other way round, as it should be.

On the Windows side, a saved password stops working, and the login dialog appears again. The old entry can also be removed by hand with cmdkey /delete:router in a command prompt.

3. Listing and deleting users #

The database is a small text file with one line per user, which starts with the user name:

root@router:~# cut -d: -f1 /etc/ksmbd/ksmbdpwd.db
media

-d deletes a user, but only if no share requires it anymore. As long as the user is still listed in users, the command refuses, so the share has to be changed first:

root@router:~# ksmbd.adduser -d smbtest
[ksmbd.adduser/12643]: ERROR: Share `downloads' requires user `smbtest'

After the deletion, a login with this user name is refused with “Access is denied” instead of “The user name or password is incorrect”. The reason is map to guest = Bad User: an unknown user is treated as a guest, and the share does not allow guests.

4. A different user name #

To use a different name than media, all three places must match: the Linux user, the users option of the share and the entry in the password database. Noticeably, ksmbd does not insist on the Linux user. My test user had no Linux account at all, and it could still log in and read the share. Writing, however, failed with “Access is denied”. So the Linux user, with the ID 1000 as described above, is what makes the share writable.

The firewall #

There is no firewall rule to add. The OpenWrt firewall decides per zone whether connections to the router itself are accepted, and port 445 is accepted only where the zone’s input policy allows it:

ZoneInputSMB
lanacceptReachable, as intended
wanrejectNot reachable from the internet. Port 445 must never be open to the internet
torrentrejectNot reachable from the torrent client
dockeracceptAllowed by the firewall, but ksmbd does not listen there (see below)
vpnacceptReachable from the WireGuard clients, once ksmbd listens on wg0 (see below)

It is worth mentioning that netstat shows ksmbd listening on all addresses (:::445), despite bind interfaces only. That is misleading. ksmbd starts one kernel thread per configured interface, and only accepts connections which arrive on one of them:

root@router:~# ps | grep "ksmbd-" | grep -v grep
14317 root         0 SW   [ksmbd-br-lan]

I tested this from a container on docker0, whose zone accepts input. Port 53 of the router answered, port 445 did not:

root@router:~# docker run --rm --entrypoint sh gitea/gitea:28.3.0-rootless \
  -c "nc -zv -w 3 10.0.0.1 445; nc -zv -w 3 10.0.0.1 53"
10.0.0.1 (10.0.0.1:53) open

So there are two lines of defense: the interface list of ksmbd and the firewall. The docker zone accepts input because it was set up that way by the Docker package. If I ever add docker0 to ksmbd, the containers can reach the share, and a rule which rejects port 445 from the docker zone would close it again.

Connecting from Windows #

In the Windows Explorer, \\router\downloads in the address bar opens the share, after asking for the user media and its password. The name is resolved by dnsmasq on the router, which also hands out lan as the search domain, so \\router.lan\downloads and \\10.0.0.1\downloads work as well. To keep it as a drive letter, “Map network drive” in the context menu of “This PC” does the same permanently.

Keep in mind that Windows stores credentials per server name. router, router.lan and 10.0.0.1 count as three different servers, so it is best to settle on one of them. Ticking “Remember my credentials” in the login dialog saves the password for that name, and the prompt does not come back.

Please note, the router does not show up under “Network” in the Explorer. Windows finds devices there through WS-Discovery, which ksmbd does not provide. The package wsdd2 would add it, but typing the name once is enough for me.

The test from Windows, including the transfer rate over Gigabit, is still outstanding and follows here.

Access through WireGuard #

When I am away from home, my phone reaches the LAN through the WireGuard server described in OpenWrt on a Raspberry PI 4B: WireGuard Server. I wanted to watch downloads in VLC from there as well. The firewall was not the problem, since the vpn zone accepts input to the router. But the connections from the phone arrive on the interface wg0, and ksmbd only listened on br-lan.

The option interface takes a list of logical interfaces, separated by spaces:

root@router:~# uci set ksmbd.@globals[0].interface='lan wg0'
root@router:~# uci commit ksmbd
root@router:~# service ksmbd restart
root@router:~# grep interfaces /etc/ksmbd/ksmbd.conf
  interfaces = br-lan wg0
root@router:~# ps | grep "ksmbd-" | grep -v grep
14317 root         0 SW   [ksmbd-br-lan]
14319 root         0 SW   [ksmbd-wg0]

The init script only lists interfaces which are up when ksmbd starts. However, it also registers a trigger for every interface in the list, so ksmbd is reloaded as soon as wg0 comes up, for example during boot.

In VLC on the phone, the share has to be added by hand as an SMB server, with router.lan as the server, downloads as the share and the user media. Two details are different from the LAN:

  • The full name. The phone uses the router as its DNS server through the tunnel, so router.lan resolves to 10.0.0.1. The short router does not, because the search domain lan only reaches devices which get their address via DHCP. 10.0.0.1 works as well.
  • No discovery. The share does not show up under “Local Network” in VLC. Avahi announces it via mDNS, which is multicast, and multicast does not cross the WireGuard tunnel.

With this, VLC on the phone lists the downloads and plays them over the tunnel.

Being found by other devices #

A PC is happy with an address, but a TV or a phone wants to find the server by itself. ksmbd announces nothing, and unfortunately there is not one protocol for this, but three, and every kind of device listens to a different one:

ProtocolWho uses itPackage on OpenWrt
mDNS/DNS-SD (Bonjour)macOS, iOS, Linux file managers, VLCavahi-nodbus-daemon, ksmbd-avahi-service
WS-Discovery“Network” in the Windows Explorerwsdd2 (not installed)
UPnP/DLNA (SSDP)TVs, for example my LG webOS TVminidlna

I first set up Avahi, because I assumed the TV would find an SMB share announced via mDNS. It does not. The LG TV only looks for DLNA media servers, which is why it showed another media server in my LAN, but not the router. So I added minidlna as well. Both are described below.

1. Announcing the SMB share with Avahi #

Avahi is the mDNS daemon on Linux. The package ksmbd-avahi-service contains nothing but a service file which tells Avahi to announce the SMB server, and pulls in the daemon itself:

root@OpenWrt:~# apk add avahi-nodbus-daemon ksmbd-avahi-service
...
(4/5) Installing avahi-nodbus-daemon (0.9_rc5-r1)
(5/5) Installing ksmbd-avahi-service (3.5.6-r1)
OK: 266.5 MiB in 313 packages
root@OpenWrt:~# cat /etc/avahi/services/smb.service
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
  <name replace-wildcards="yes">%h</name>
  <service>
    <type>_smb._tcp</type>
    <port>445</port>
  </service>
</service-group>

%h is replaced by the host name, so the share appears as router. I used the nodbus variant because nothing else on the router talks to Avahi via D-Bus, and it saves the D-Bus daemon.

Out of the box, /etc/avahi/avahi-daemon.conf does not restrict the interfaces, so Avahi would answer on every interface of the router, including the WAN and the Docker networks. One line in the [server] section limits it to the LAN:

root@OpenWrt:~# sed -i "s/^\[server\]$/[server]\nallow-interfaces=br-lan/" \
  /etc/avahi/avahi-daemon.conf
root@OpenWrt:~# sed -n 1,4p /etc/avahi/avahi-daemon.conf
[server]
allow-interfaces=br-lan
#host-name=foo
#domain-name=local
root@OpenWrt:~# service avahi-daemon restart
Command failed: ubus call service delete { "name": "avahi-daemon" } (Not found)

(Note: the “Command failed” only means that the daemon was not running yet, so there was nothing to stop)

The log confirms that Avahi joined only br-lan and announces the service:

root@router:~# logread | grep avahi
...
daemon.info avahi-daemon[10024]: Loading service file /etc/avahi/services/smb.service.
...
daemon.info avahi-daemon[10024]: Joining mDNS multicast group on interface br-lan.IPv4 with address 10.0.0.1.
daemon.info avahi-daemon[10024]: New relevant interface br-lan.IPv4 for mDNS.
daemon.info avahi-daemon[10024]: Server startup complete. Host name is router.local. Local service cookie is ...
daemon.info avahi-daemon[10024]: Service "router" (/etc/avahi/services/smb.service) successfully established.

From the PC, an mDNS query for _smb._tcp.local was answered by 10.0.0.1, and VLC lists the share under “Local Network” since then. (Note: I ran that query before I renamed the router, so the answer still carried the name OpenWrt) The package enables the service at boot, there is nothing else to do.

2. A media server for the TV with minidlna #

minidlna (also known as ReadyMedia) is a small DLNA server. It scans a directory, keeps an index of the media files in an SQLite database, announces itself via SSDP and streams the files over HTTP. It does not use SMB at all, it only reads the same directory. luci-app-minidlna adds the page Services → miniDLNA to the web interface.

root@OpenWrt:~# apk add minidlna luci-app-minidlna
root@OpenWrt:~# mkdir -p /opt/minidlna
root@OpenWrt:~# uci batch <<EOF
set minidlna.config.enabled='1'
set minidlna.config.interface='br-lan'
set minidlna.config.friendly_name='OpenWrt Media'
set minidlna.config.db_dir='/opt/minidlna'
set minidlna.config.inotify='1'
delete minidlna.config.media_dir
add_list minidlna.config.media_dir='V,/opt/torrent/downloads'
EOF
root@OpenWrt:~# uci commit minidlna
root@OpenWrt:~# service minidlna enable
root@OpenWrt:~# service minidlna restart
OptionMeaning
interface=br-lanAnnounce and serve only in the LAN
friendly_nameThe name the TV shows in its list of sources
db_dir=/opt/minidlnaThe index lives on the SSD. The default is /var/run/minidlna, which is in RAM, so every reboot would mean a full rescan
inotify=1New downloads are added to the index as soon as they appear, without a rescan
media_dir=V,…Only video files. Without the V, minidlna would also index music and pictures

It is worth mentioning that minidlna runs as its own user minidlna, not as media. It can still read all downloads, because the create_mask and dir_mask of the share make files and directories readable for everybody on the router. The init script creates /opt/minidlna for this user if it does not exist yet.

The firewall needs no rule here either. SSDP is UDP port 1900 (multicast), the streams run over TCP port 8200, and both are accepted in the lan zone and rejected everywhere else:

root@OpenWrt:~# netstat -lnup | grep 1900
udp        0      0 239.255.255.250:1900    0.0.0.0:*                           22095/minidlnad
root@OpenWrt:~# netstat -lntp | grep 8200
tcp        0      0 0.0.0.0:8200            0.0.0.0:*               LISTEN      22095/minidlnad

minidlna also has a small status page at http://10.0.0.1:8200, which shows the size of the index and the clients which have connected. After the first scan of my download directory:

MiniDLNA status

Media library
  Audio files    0
  Video files    177
  Image files    0

Please note, DLNA is read only. The TV can browse and play the files, but deleting or renaming is still done through the SMB share.

(Note: whether the LG TV now lists “OpenWrt Media” and plays the files is still to be confirmed and follows here)

Files to keep across upgrades #

Nothing to add. /etc/config/ksmbd and /etc/config/minidlna are part of the uci configuration, and /etc/passwd, /etc/group, /etc/shadow and the ksmbd password database /etc/ksmbd/ksmbdpwd.db are kept by OpenWrt anyway. The changed /etc/avahi/avahi-daemon.conf is kept as well, because the package marks it as a configuration file (sysupgrade -l lists it). The minidlna index is on the SSD in /opt/minidlna and is not touched by an upgrade at all. owut keeps the installed packages when it builds the image for the next upgrade.

Summary and next steps #

The download directory of the torrent client is available as \\router\downloads in the LAN, readable and writable for the user media. Since media has the same ID as the user qBittorrent runs as, files from both sides have the same owner. Through WireGuard, the phone reaches the same share as router.lan. Avahi announces the share via mDNS, so VLC in the LAN finds it by itself, and minidlna offers the videos as a DLNA media server for the TV. Next, I will test the share from Windows and measure what the PI manages over Gigabit.

Support

If you found this content valuable, please support my work here

License

This text is available under the Creative Commons Attribution-ShareAlike License