David J McLaughlin

Sr Fullstack Developer

Running .NET Core on OpenWRT & the Raspberry Pi 4 B

about 25 minutes

Read the TLDR

1 minute

Can we use markdown here?

TEST

and also test

I’ve recently been playing with the idea of building my own router. Grabbing a Mini-ITX motherboard, a cheap 4 port Intel NIC, slapping something like DD-WRT/OpenWRT/Tomato in and off I go. My motivations are simple, I want a high performance router with the latest features (WPA3 for instance), and I want it to be serviceable and upgradable. I also want a fun small project to setup, I’ve been feeling kind of experimental lately, and I think this will do nicely. But it seems like a bit much, building a whole custom PC to run a simple home router. I’ve played with some configurations on PCPartPicker, and for a simple Mini-ITX PC, Motherboard, CPU, RAM, NIC, PSU, Case, SSD, Fans, CPU Cooler, it’s not a small price tag. It ends up being in the range of twice as much as a high-end home router. Here’s a build I setup on PCPartPicker: router build

So why not install DD-WRT/OpenWRT/Tomato on a high-end home router? Well these projects are setup exactly for that purpose. It certainly seems like the most economic option, but there are a few things that make it less appealing to myself. I like the idea of being able to upgrade the machine without having the toss the whole thing out and buy a new one, there’s also something satisfying about installing upgrades for a system. I might upgrade the 10/100/1000 4 port NIC to 10Gbps in the future, or throw in more RAM, or an upgraded CPU, maybe only if something breaks, or maybe I’ll find some non-router jobs to use the spare capacity. But in my case, I’m not going to be picking up a consumer router and flashing it with a one of the popular firmwares because I already have a common piece of hardware that I’d like to test to see if it can cover my needs.

Enter the Raspberry Pi 4. The Pi 4 offers a good boost in performance over previous Raspberry Pi models. Larger memory size, big increases in performance across a variety of metrics (CPU speed, Ethernet, USB bandwidth, etc). Overall it seems a Pi should be a good choice for running a 5 port router, the Ethernet interface has been give its own 1Gbps connection to the SoC. The USB connection has also been upgraded, in total offering a theoretical 4Gbps connection to the SoC over a PCI2 Gen 2 lane. That means the Pi 4 should be able to support three 1Gbps + two 10/100 Ethernet connections (using adapters) and Wifi connections.

 

Choosing a router project

Good enough for a trial, at least for me. Lets get started, which software should we choose? I don’t know the landscape well, lets have a quick look around before deciding. The requirements and nice to haves are:

  1. It needs to run on the Raspberry PI 4
  2. There should be support for USB Ethernet adapters
  3. Ideally there should be support for the Pi’s wifi chipset (although for me, I have a dedicated hotspot, so this is just a nice to have)
  4. .NET Core support would be nice, I’ve been working with .NET for a while and it’s definitely my preferred development platform.

Lets take a look at Tomato, it seems the development has been on and off for a while with various people/groups picking up and stopping development. It seems AdvancedTomato is the go to as of today. Unfortunately the last build of AdvancedTomato is over 2 years old as of the time of writing, and the device support is limited, no first party Raspberry Pi support from what I can tell.

Ok lets take a look at DD-WRT, a quick check of their supported devices list doesn’t look promising. A search of the forums doesn’t reveal anything promising either.

OpenWRT appears to have support for all the Raspberry Pi models, except the Pi 4 B, at least in the current release (18.06.5). The Pi 4 does have support in the latest snapshot builds however.

OpenWRT supported versions table OpenWRT supported versions table

Doing a little reading on the forums and wiki, it seems the Pi 4’s wifi card works, ethernet is working, and there is support for USB ethernet adapters, but I’ve only seen that in reference to the Pi Zero, and it requires building a custom image. Seems promising, lets grab a build, flash it to an SD card, and get started.

 

OpenWRT initial setup

The Raspberry Pi wiki page linked above doesn’t give a download link for the snapshots that support the Pi 4. That page can be found here, it seems these builds are automatic, and change nightly. There are a few options to choose from on that page, we get a choice of filesystem, ext4 and squashfs, and two types of builds, factory and sysupgrade. The difference in the filesystems is squashfs is a compressed filesystem that is readonly by default, I’m guessing this is targeted at devices with more limited storage. ext4 is a more common filesystem you see with a lot of linux distros, it’s my choice since I’m more familiar with it. The factory build is targeted for systems that don’t have an existing OpenWRT install, sysupgrade is for upgrading existing OpenWRT installs. I tried the factory image and was able to boot, but I ran into trouble installing packages, I wiped the SD card and flashed the sysupgrade file and didn’t have the same issues. Maybe there was some problem in that particular nightly build that only affected the factory image.

So, grab your image, if you’re on windows, 7zip is a good choice for extracting a gziped files, if you’re on ubuntu or similar you can use something like the gzip utility. I’ve never had issues with 7zip in the past, but I ran into this error when trying to extract the openWRT image file "There are some data after the end of the payload data : openwrt-bcm2708-bcm2711-rpi-4-ext4-sysupgrade.img".

7zip image extraction error message 7zip image extraction error message

I tried running 7zip in parser mode, which lets you view the archive(s) split from their metadata.

7zip program window in parser mode 7zip program window in parser mode

The 1.gz file is the gzip file with our image in it and the 2 file appears to be some text metadata and some unprintable bytes. Opening the 1.gz and extracting the image file succeeds, but the burned image doesn’t seem to boot.

I don’t know of any other good free windows tools for extracting gzip files, I’m sure there are some out there, but for me the easiest next thing to try was dropping into the Windows Subsystem for Linux (WSL) and using the gzip utility to extract the archive. If you don’t already have WSL enabled, you can do it quickly from a powershell Administrator prompt:


$downloadLocation = "~\debian.zip"
$installLocation = "C:\WindowsSubsystemForLinux\Debian\"

Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux

Invoke-WebRequest -UseBasicParsing -Uri https://aka.ms/wsl-debian-gnulinux -OutFile $downloadLocation

New-Item -Path $installLocation

Expand-Archive $downloadLocation $installLocation

Remove-Item -Path $downloadLocation

# runs the debian WSL installer, setup a user, then you'll be dropped into the debian shell. Type 'exit' to drop back to powershell
& ($installLocation + "debian.exe")

From here you can type gzip -V, this should print you version information for the gzip utility, if you don’t get the version then gzip will need to be installed, sudo apt-get install gzip should do it. Your windows drives should be automatically mounted to /mnt/{DRIVE LETTER}/, this can be verified with the ls command, ls /mnt/. To decompress the gzip archive use gzip -d PATH_TO_ARCHIVE_FILE, in my case I ran gzip -d /mnt/c/Users/David/Downloads/openwrt-brcm2708-bcm2711-rpi-4-ext4-sysupgrade.img.gz, which printed the following: "gzip: /mnt/c/Users/David/Downloads/openwrt-brcm2708-bcm2711-rpi-4-ext4-sysupgrade.img.gz: decompression OK, trailing garbage ignored". The image file was extracted to the same location the archive was in, my Downloads folder.

extracting a gzipped OpenWRT image with 'gzip -d' in the WSL extracting a gzipped OpenWRT image with 'gzip -d' in the WSL

I used Etcher to burn the image file to my SD card, and I was finally able to boot my Pi 4 into the OpenWRT shell.

Balena Etcher program window, flashing complete Balena Etcher program window, flashing complete

I attached an HDMI cable, keyboard, and ethernet cable to my Pi, hitting enter a few times displayed the OpenWRT logo and terminal cursor.

OpenWRT shell prompt OpenWRT shell prompt

The first thing I did was setup my Pi to have access to my existing home network so I could start installing packages and doing the rest of the setup over ssh.

#sets the Pi's ethernet interface to a static Ip address on an existing network
uci set network.lan.ipaddr=192.168.1.15

#the gateway address of the network the pi is connecting to
uci set network.lan.gateway=192.168.1.1

#any DNS servers
uci set network.lan.dns=1.1.1.1

#write changes to the filesystem
uci commit network

reboot

UCI is the Unified Configuration Interface, it’s a command line tool for editing the OpenWRT configuration files. set allows us to set the value of a variable in a config file, in the above commands we’re setting values in the /etc/config/network file. UCI doesn’t write changes until you issue the commit command. A reboot probably isn’t necessary, you could probably just restart the network service or bring the ethernet interface down then up again. You should now be able to SSH into the Pi, use the name root to login, no password needed. Packages can be installed with opkg install, you’ll need to run opkg update on every boot.

Before we get carried away, take note that OpenWRT has a fairly small root partition.

viewing the OpenWRT root partition size via ssh viewing the OpenWRT root partition size via ssh

Ok, maybe not that small, for a lot of uses this will be plenty of space. However we’re going to need to deploy at least one self-contained dotnet-core binary, and even a simple Hello World console application runs around 70 MB in size. That means we’ll want to expand the root partition to make some room.

As a note, starting with .NET Core 3.0 Microsoft ships an IL Linking tool with the SDK, running it trims out some of the un-used runtime stuff that your program doesn’t use, making your executable a bit leaner. You can read a bit more about that here: What’s new in dotnet-core 3.0: assembly-linking.

I’ve also come across a neat tool, dotnet-warp, its a wrapper around Warp, basically it integrates warp into the dotnet build tools, while warp itself allows combining .NET and other language binaries into a single self contained executable. It also comes with its own method(s) of trimming out unused code to reduce total file size.

Resizing the OpenWRT root filesystem

Before resizing the OpenWRT root partition, take note, if you plan on using the built-in OpenWRT upgrade functionality you will lose any data stored in the expanded space, as OpenWrt resets the root partition during an upgrade. So depending on your use case it probably makes more sense to create a new partition or use an external drive to store your dotnet binary files.

Since we’re working with an ext4 filesystem we’ll want to use a linux OS to do the resizing as there are plenty of good free tools. If you don’t already have a linux setup to work with, the quickest way to get going would be to grab something like Ubuntu and boot into the live environment. If you’re familiar with gparted you can go ahead and use that tool to expand the OpenWRT filesystem on your SD card.

Or if you prefer, you can use fdisk and resize2fs to do the resizing from the command line.

resizing the OpenWRT root partition via ssh resizing the OpenWRT root partition via ssh

Now that we have some space to work with, lets build our dotnet core program. Here’s a small console app that will print some basic information about the system, code here. Running the program on windows produces this output.

Current date and time: 3/15/2020 7:01:54 PM
Timezone Information
    Id: Eastern Standard Time
    DisplayName: (UTC-05:00) Eastern Time (US & Canada)
    StandardName: Eastern Standard Time
    DaylightName: Eastern Daylight Time
    BaseUtcOffset: -05:00:00
    SupportsDaylightSavingTime: True
    Local: (UTC-05:00) Eastern Time (US & Canada)
    Utc: (UTC) Coordinated Universal Time

Environment Information
    CurrentManagedThreadId: 1
    ExitCode: 0
    HasShutdownStarted: False
    ProcessorCount: 8
    StackTrace:    at System.Environment.get_StackTrace()
   at System.RuntimeMethodHandle.InvokeMethod(Object target, Object[] arguments, Signature sig, Boolean constructor, Boolean wrapExceptions)
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
   at System.Reflection.RuntimePropertyInfo.GetValue(Object obj, BindingFlags invokeAttr, Binder binder, Object[] index, CultureInfo culture)
   at System.Reflection.RuntimePropertyInfo.GetValue(Object obj, Object[] index)
   at System.Reflection.PropertyInfo.GetValue(Object obj)
   at OpenWrtTestApp.Program.WriteObjectProperties(Type targetType, Object targetInstance, String prefix) in C:\Users\David\source\repos\OpenWrtTestApp\OpenWrtTestApp\Program.cs:line 51
   at OpenWrtTestApp.Program.WriteEnvironmentInfo() in C:\Users\David\source\repos\OpenWrtTestApp\OpenWrtTestApp\Program.cs:line 32
   at OpenWrtTestApp.Program.Main(String[] args) in C:\Users\David\source\repos\OpenWrtTestApp\OpenWrtTestApp\Program.cs:line 14
    TickCount: 241912656
    TickCount64: 241912656
    CommandLine: C:\Users\David\source\repos\OpenWrtTestApp\OpenWrtTestApp\bin\Debug\netcoreapp3.1\OpenWrtTestApp.dll
    CurrentDirectory: C:\Users\David\source\repos\OpenWrtTestApp\OpenWrtTestApp\bin\Debug\netcoreapp3.1
    Is64BitProcess: True
    Is64BitOperatingSystem: True
    OSVersion: Microsoft Windows NT 6.2.9200.0
    UserInteractive: True
    Version: 3.1.1
    NewLine:

    SystemPageSize: 4096
    MachineName: DESKTOP-0NL72HO
    SystemDirectory: C:\Windows\system32
    WorkingSet: 20586496
    UserName: David
    UserDomainName: DESKTOP-0NL72HO

Path Information
    DirectorySeparatorChar: '\'
    AltDirectorySeparatorChar: '/'
    PathSeparator: ';'
    VolumeSeparatorChar: ':'

Press any key to exit...

Building for OpenWRT

Since OpenWRT is designed to run on home routers and other embedded devices the system uses a lightweight C standard library musl libc (pronounced like ‘muscle’). That means building our app against the linux-arm runtime wont work, but lucky for us the dotnet core SDK has built-in support for linux musl on arm processors. Unfortunately the visual studio publish screen doesn’t list many of the supported runtimes.

The Visual Studio publish screen The Visual Studio publish screen

Publishing from the CLI tools is nice and easy in dotnet core; for a full list of supported runtime identifiers check the runtime.json file in the dotnet runtime repository.

dotnet publish --configuration Release --runtime linux-musl-arm64 --self-contained

Running the dotnet publish command from Powershell Running the dotnet publish command from Powershell

Now we just copy the files in the publish folder over to the raspberry pi, I used WinSCP to SCP the files over. Set the executable flag via chmod +x ./OpenWrtTestApp and if we run the executable we get the following error:

Error output from running our program on OpenWRT Error output from running our program on OpenWRT

If we run opkg update then opkg list | grep -e libstdc we see the libstdcpp6 package is available for install.

root@OpenWrt:~/publish# opkg list | grep -e libstdc
libstdcpp6 - 8.3.0-2 - GNU Standard C++ Library v3
root@OpenWrt:~/publish#

Install it like this opkg install libstdcpp6 and try to run our app again.

root@OpenWrt:~/publish# /root/publish/OpenWrtTestApp
Failed to load  %▒, error: Error loading shared library libintl.so.8: No such file or directory (needed by /root/publish/libcoreclr.so)
Failed to bind to CoreCLR at '/root/publish/'
Failed to create CoreCLR, HRESULT: 0x80008088
root@OpenWrt:~/publish#

Same process as before:

root@OpenWrt:~/publish# opkg list | grep -e libintl
libintl - 2 - Stub header for the GNU Internationalization library
libintl-full8 - 0.19.8.1-2 - GNU Internationalization library

root@OpenWrt:~/publish# opkg install libintl-full8
Installing libintl-full8 (0.19.8.1-2) to root...
Downloading http://downloads.openwrt.org/snapshots/packages/aarch64_cortex-a72/base/libintl-full8_0.19.8.1-2_aarch64_cortex-a72.ipk
Configuring libintl-full8.
root@OpenWrt:~/publish#

Now running the program now gives us this dotnet exception:

Different error output from running our program on OpenWRT Different error output from running our program on OpenWRT

That’s a good sign, it means the system is loading and executing the dotnet runtime, but the runtime is unhappy about something. A little googling gives us some clues, it seems ICU likely stands for International Components for Unicode searching for icu in the opkg package list gives a lot of unrelated results as opkg includes multi-line descriptions of the packages in its output. Trying a few search variations, here’s what we are looking for:

root@OpenWrt:~/publish# opkg list | grep -e "icu "
icu - 65.1-2 - ICU is a mature, widely used set of C/C++ and Java libraries providing Unicode and Globalization support for software applications. ICU is widely portable and gives applications the same results on all platforms and between C/C++ and Java software. This package supports C/C++.
root@OpenWrt:~/publish#

Now finally running our app gives us the expected output:

root@OpenWrt:~# /root/publish/OpenWrtTestApp
Current date and time: 03/15/2020 23:22:18
Timezone Information
    Id: UTC
    DisplayName: (UTC) Coordinated Universal Time
    StandardName: Coordinated Universal Time
    DaylightName: Coordinated Universal Time
    BaseUtcOffset: 00:00:00
    SupportsDaylightSavingTime: False
    Local: (UTC) Coordinated Universal Time
    Utc: (UTC) Coordinated Universal Time

Environment Information
    CurrentManagedThreadId: 1
    ExitCode: 0
    HasShutdownStarted: False
    ProcessorCount: 4
    StackTrace:    at System.Environment.get_StackTrace()
   at System.RuntimeMethodHandle.InvokeMethod(Object target, Object[] arguments, Signature sig, Boolean constructor, Boolean wrapExceptions)
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
   at System.Reflection.RuntimePropertyInfo.GetValue(Object obj, BindingFlags invokeAttr, Binder binder, Object[] index, CultureInfo culture)
   at System.Reflection.RuntimePropertyInfo.GetValue(Object obj, Object[] index)
   at OpenWrtTestApp.Program.WriteObjectProperties(Type targetType, Object targetInstance, String prefix) in C:\Users\David\source\repos\OpenWrtTestApp\OpenWrtTestApp\Program.cs:line 51
   at OpenWrtTestApp.Program.WriteEnvironmentInfo() in C:\Users\David\source\repos\OpenWrtTestApp\OpenWrtTestApp\Program.cs:line 33
   at OpenWrtTestApp.Program.Main(String[] args) in C:\Users\David\source\repos\OpenWrtTestApp\OpenWrtTestApp\Program.cs:line 15
    TickCount: 469687
    TickCount64: 469687
    CommandLine: /root/publish/OpenWrtTestApp.dll
    CurrentDirectory: /root
    Is64BitProcess: True
    Is64BitOperatingSystem: True
    OSVersion: Unix 4.19.91.0
    UserInteractive: True
    Version: 3.1.1
    MachineName: OpenWrt
    NewLine:

    SystemDirectory:
    SystemPageSize: 4096
    UserName: root
    UserDomainName: OpenWrt
    WorkingSet: 29749248

Path Information
    DirectorySeparatorChar: '/'
    AltDirectorySeparatorChar: '/'
    PathSeparator: ':'
    VolumeSeparatorChar: '/'

Press any key to exit...

Setting the timezone

The default timezone is UTC, I’m going to change it to my local zone, EST/EDT. Running the date command reaffirms this:

root@OpenWrt:~# date
Sun Mar 15 23:51:23 UTC 2020
root@OpenWrt:~#

Running uci show system shows us there is a timezone config option.

root@OpenWrt:~# uci show system
system.@system[0]=system
system.@system[0].hostname='OpenWrt'
system.@system[0].timezone='UTC'
system.@system[0].ttylogin='0'
system.@system[0].log_size='64'
system.@system[0].urandom_seed='0'
system.ntp=timeserver
system.ntp.enabled='1'
system.ntp.enable_server='0'
system.ntp.server='0.openwrt.pool.ntp.org' '1.openwrt.pool.ntp.org' '2.openwrt.pool.ntp.org' '3.openwrt.pool.ntp.org'
root@OpenWrt:~#

Lets change that timezone field to the proper string for EST.

root@OpenWrt:~# uci set system.@system[0].timezone='EST5EDT,M3.2.0,M11.1.0'
root@OpenWrt:~# uci commit system
root@OpenWrt:~# uci show system
system.@system[0]=system
system.@system[0].hostname='OpenWrt'
system.@system[0].ttylogin='0'
system.@system[0].log_size='64'
system.@system[0].urandom_seed='0'
system.@system[0].timezone='EST5EDT,M3.2.0,M11.1.0'
system.ntp=timeserver
system.ntp.enabled='1'
system.ntp.enable_server='0'
system.ntp.server='0.openwrt.pool.ntp.org' '1.openwrt.pool.ntp.org' '2.openwrt.pool.ntp.org' '3.openwrt.pool.ntp.org'
root@OpenWrt:~#

And a quick reload of the system service /etc/init.d/system reload, and the date command now gives us our local time. However running our app again does not reflect these changes:

root@OpenWrt:~# /etc/init.d/system reload
root@OpenWrt:~# date
Sun Mar 15 20:01:25 EDT 2020

root@OpenWrt:~# /root/publish/OpenWrtTestApp
Current date and time: 03/15/2020 12:01:44
Timezone Information
    Id: UTC
    DisplayName: (UTC) Coordinated Universal Time
    StandardName: Coordinated Universal Time
    DaylightName: Coordinated Universal Time
    BaseUtcOffset: 00:00:00
    SupportsDaylightSavingTime: False
    Local: (UTC) Coordinated Universal Time
    Utc: (UTC) Coordinated Universal Time

Environment Information
    CurrentManagedThreadId: 1
    ExitCode: 0
    HasShutdownStarted: False
    ProcessorCount: 4
    StackTrace:    at System.Environment.get_StackTrace()
   at System.RuntimeMethodHandle.InvokeMethod(Object target, Object[] arguments, Signature sig, Boolean constructor, Boolean wrapExceptions)
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
   at System.Reflection.RuntimePropertyInfo.GetValue(Object obj, BindingFlags invokeAttr, Binder binder, Object[] index, CultureInfo culture)
   at System.Reflection.RuntimePropertyInfo.GetValue(Object obj, Object[] index)
   at OpenWrtTestApp.Program.WriteObjectProperties(Type targetType, Object targetInstance, String prefix) in C:\Users\David\source\repos\OpenWrtTestApp\OpenWrtTestApp\Program.cs:line 51
   at OpenWrtTestApp.Program.WriteEnvironmentInfo() in C:\Users\David\source\repos\OpenWrtTestApp\OpenWrtTestApp\Program.cs:line 33
   at OpenWrtTestApp.Program.Main(String[] args) in C:\Users\David\source\repos\OpenWrtTestApp\OpenWrtTestApp\Program.cs:line 15
    TickCount: 469687
    TickCount64: 469687
    CommandLine: /root/publish/OpenWrtTestApp.dll
    CurrentDirectory: /root
    Is64BitProcess: True
    Is64BitOperatingSystem: True
    OSVersion: Unix 4.19.91.0
    UserInteractive: True
    Version: 3.1.1
    MachineName: OpenWrt
    NewLine:

    SystemDirectory:
    SystemPageSize: 4096
    UserName: root
    UserDomainName: OpenWrt
    WorkingSet: 29749248

Path Information
    DirectorySeparatorChar: '/'
    AltDirectorySeparatorChar: '/'
    PathSeparator: ':'
    VolumeSeparatorChar: '/'

Press any key to exit...

Looking at the OpenWRT documentation shows us another system option called zonename, setting it to system.@system[0].zoneinfo='America/New York' and installing the zoneinfo-northamerica package doesn’t seem to make any difference, even after a reboot.

Lets take a look at how the DateTime class is getting the system timezone, we could use ILSpy (or another decompiler) for this but since dotnet core is open source, lets just view the source on GitHub. The DateTime class is a large one but we can see the Now property on line 1041, it calls into the TimeZoneInfo.GetDateTimeNowUtcOffsetFromUtc(...) function. There appears to be 3 TimeZoneInfo classes in this share directory, TimeZoneInfo.Unix.cs, TimeZoneInfo.Win32.cs, and TimeZoneInfo.cs, Looking at the .Unix.cs file we find the GetDateTimeNowUtcOffsetFromUtc(...) on line 725, this function is defined in the TimeZoneInfo.cs file, however the Local variable passed into this function looks interesting, we can find that in the TimeZoneInfo.cs file on line 883. Following this value back, we find the CachedData class and back in the TimeZoneInfo.Unix.cs we find this function FindSystemTimeZoneById(...) which leads to TryGetTimeZone(...), then to GetLocalTimeZone(...), then GetLocalTimeZoneFromTzFile(...), and to GetLocalTimeZoneFromTzFile(...) at line 312. This function trys to load an environment variable TZ (or :TZ) which appears should be a path to a tz file containing the system timezone info.

Checking our OpenWRT Pi shows no such variable

root@OpenWrt:~# env
USER=root
SHLVL=1
HOME=/root
SSH_TTY=/dev/pts/0
PS1=\[\e]0;\u@\h: \w\a\]\u@\h:\w\$
LOGNAME=root
TERM=xterm
PATH=/usr/sbin:/usr/bin:/sbin:/bin
SHELL=/bin/ash
PWD=/root
root@OpenWrt:~#

If the TryGetLocalTzFile(...) function fails to find the TZ variable it will check a hardcoded path ("/etc/localtime") then another environment variable TZDIR. TryLoadTzFile checks if the previously found path to the TZ file is a symlink and trys to extract the timezone id from the path contained in the syslink, if that fails it calls FindTimeZoneId(...). This function compares the TZ file found previously to one located at "/usr/share/zoneinfo/" (unless the TZDIR environment variable is set, then it will check the directory set in that variable), if it finds a matching file, that path is returned up the stack and the next function to run is GetTimeZoneFromTzData, this function calls the TimeZoneInfo constructor on line 28. It looks like this constructor reads the data from the TZ file and uses that information to set the timezone.

So it seems all we should need to do is create a symlink at /etc/localtime and point it to our zoneinfo file in the /usr/share/zoneinfo/ directory. I already installed the zoneinfo package previously, if you’re following along make sure you do too.

root@OpenWrt:~# opkg list | grep -e ^zoneinfo
zoneinfo-africa - 2019c-1 - Zone Information (Africa)
zoneinfo-asia - 2019c-1 - Zone Information (Asia)
zoneinfo-atlantic - 2019c-1 - Zone Information (Atlantic)
zoneinfo-australia-nz - 2019c-1 - Zone Information (Australia-NZ)
zoneinfo-core - 2019c-1 - Zone Information (core)
zoneinfo-europe - 2019c-1 - Zone Information (Europe)
zoneinfo-india - 2019c-1 - Zone Information (India)
zoneinfo-northamerica - 2019c-1 - Zone Information (NorthAmerica)
zoneinfo-pacific - 2019c-1 - Zone Information (Pacific)
zoneinfo-poles - 2019c-1 - Zone Information (Arctic, Antarctic)
zoneinfo-simple - 2019c-1 - Zone Information (simple)
zoneinfo-southamerica - 2019c-1 - Zone Information (SouthAmerica)
root@OpenWrt:~#

Once we have the zoneinfo package we just need to create the symlink.

root@OpenWrt:~# ln -sf /usr/share/zoneinfo/EST5EDT /etc/localtime
root@OpenWrt:~# ls -l /etc

...
drwxr-xr-x    2 root     root          4096 Jan  3 18:05 init.d
-rw-r--r--    1 root     root           143 Jan  3 18:05 inittab
drwxr-xr-x    2 root     root          4096 Jan  3 18:05 iproute2
lrwxrwxrwx    1 root     root            36 Mar 15 18:43 localtime -> /usr/share/zoneinfo/EST5EDT
drwxr-xr-x    2 root     root          4096 Jan  3 18:05 modules-boot.d
drwxr-xr-x    2 root     root          4096 Jan  3 18:05 modules.d
...
root@OpenWrt:~#

And now we get the correct time from calling DateTime.Now:

Current date and time: 03/15/2020 20:25:52
Timezone Information
    Id: EST5EDT
    DisplayName: (UTC-05:00)
    StandardName:
    DaylightName:
    BaseUtcOffset: -05:00:00
    SupportsDaylightSavingTime: True
    Local: (UTC-05:00)
    Utc: (UTC) Coordinated Universal Time

Environment Information
    CurrentManagedThreadId: 1
    ExitCode: 0
    HasShutdownStarted: False
    ProcessorCount: 4
    StackTrace:    at System.Environment.get_StackTrace()
   at System.RuntimeMethodHandle.InvokeMethod(Object target, Object[] arguments, Signature sig, Boolean constructor, Boolean wrapExceptions)
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
   at System.Reflection.RuntimePropertyInfo.GetValue(Object obj, BindingFlags invokeAttr, Binder binder, Object[] index, CultureInfo culture)
   at System.Reflection.RuntimePropertyInfo.GetValue(Object obj, Object[] index)
   at OpenWrtTestApp.Program.WriteObjectProperties(Type targetType, Object targetInstance, String prefix) in C:\Users\David\source\repos\OpenWrtTestApp\OpenWrtTestApp\Program.cs:line 51
   at OpenWrtTestApp.Program.WriteEnvironmentInfo() in C:\Users\David\source\repos\OpenWrtTestApp\OpenWrtTestApp\Program.cs:line 33
   at OpenWrtTestApp.Program.Main(String[] args) in C:\Users\David\source\repos\OpenWrtTestApp\OpenWrtTestApp\Program.cs:line 15
    TickCount: 683557
    TickCount64: 683557
    CommandLine: /root/publish/OpenWrtTestApp.dll
    CurrentDirectory: /root
    Is64BitProcess: True
    Is64BitOperatingSystem: True
    OSVersion: Unix 4.19.91.0
    UserInteractive: True
    Version: 3.1.1
    MachineName: OpenWrt
    NewLine:

    SystemDirectory:
    SystemPageSize: 4096
    UserName: root
    UserDomainName: OpenWrt
    WorkingSet: 29417472

Path Information
    DirectorySeparatorChar: '/'
    AltDirectorySeparatorChar: '/'
    PathSeparator: ':'
    VolumeSeparatorChar: '/'

Press any key to exit...

The TimeZoneInfo.StandardName and TimeZoneInfo.DaylightName properties don’t get populated, but for me this is okay, my main concern was to get the time displaying correctly for logging purposes. I took a quick look at the code anyways to see if it was an easy fix, following the code through, we find a function GetDisplayName(...) that is responsible for getting the StandardName and DaylightName values, it calls into Interop.Globalization.GetTimeZoneDisplayName(…). That function is defined at Interop.TimeZoneInfo.cs line 20, it calls into a c function GlobalizationNative_GetTimeZoneDisplayName, which is defined in at pal_timeZoneInfo.c, which calls into another function ucal_getTimeZoneDisplayName which appears to be part of the libicui18n library. That’s where I abandoned the rabbit hole, if you’re interested in getting the correct timezone name to appear in your dotnet code that should give you a good direction to start looking.