Poor man's virtual hosts

I wanted to use my router to serve a (semi-) static website on the LAN, next to the Luci interface. It is possible to start 2 instances of uhttpd on different ports, but that is not very convenient. uhttpd doesn't support virtual hosts, or at least I couldn't find any documentation for that. Instead found a way using cgi.
/www/index.html is basically a redirect to /cgi-bin/luci. I changed that to a redirect to /virtualhosts.sh.
Then I wrote a cgi script /www/virtualhosts.sh:

#!/bin/sh

# optional. Strip network name to handle openwrt.lan the same as openwrt
HTTP_HOST=$(echo ${HTTP_HOST} | cut -d '.' -f 1)
# Hostname can be upper or mixed case, so change it to lower
HOST=$(echo ${HOSTNAME} | awk '{print tolower($0)}')
# Default url
URL=/cgi-bin/luci

if [ "${HTTP_HOST}" != "${HOST}" ] 
then 
    case ${HTTP_HOST} in
	domain1)
	    URL=/domain1/index.html
	    ;;
	domain2)
	    URL=/domain2/index.html
	    ;;
    esac
fi

cat <<__EOF__
Content-type: text/html; charset=utf-8

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="refresh" content="0; URL=${URL}" />
</head>
<body>
</body>
</html>
__EOF__

Then /bin/sh had to be added as cgi interpreter to /etc/config/uhttpd

config uhttpd main

<snip>
	# List of extension->interpreter mappings.
	# Files with an associated interpreter can
	# be called outside of the CGI prefix and do
	# not need to be executable.
	list interpreter	".sh=/bin/sh"

and domain1 and domain2 added as static hostnames pointing to the router's lan address. Restart uhttpd and the poor man's virtual hosts work. http://domain1.lan/ resolves to /www/domain1/index.html

5 Likes

You can make a redirection just by sending 302 Status with Location header.
But your users will always see the /domain1/ part in address bar and this may be confusing for them.
Also you may not need to configure .sh files as interpreter but instead create /cgi-bin/index.sh or index.cgi file. Don't remember exactly if uhttpd supports default cgi scripts but busybox httpd works exactly like this.

Virtual Hosts is something that very needed. But two domains may have different settings like base auth while uhttpd is single threaded and wasn't developed with multiple hosts in mind and may share some global variables. So you must run two different instances of uhttpd with their own settings and put some proxy that will redirect requests depending on Host header. This is already implemented by OpenWRT authors and the proxy called tinyproxy https://openwrt.org/docs/guide-user/services/proxy/tinyproxy.

There was some proposition to implement a lightweight variant when settings are the same but www folders are switched by domain:

In lighttpd this solution called Simple Virtual Host
I think this is a good solution for embedded devices but it didn't received any attention.

If you just need to have one domain for external domain with your site and another for local network with Luci then you can start two uhttpd instances on 80 port but on different interfaces i.e. IP:

# luci for local network
config uhttpd main
   list listen_http '192.168.1.1:80'
   option home '/www/'
config uhttpd example_domain
   list listen_http 'example.com:80'
   option home '/www_example.com/'
   option rfc1918_filter '0' # allow external IPs

I did this for my homepage https://stokito.wordpress.com/2019/04/22/setup-ddns-in-openwrt/

Another possible solution may be to use firewall eBPF rule that will check for the Host header and redirect packet to another port depending on domain. This will be a best solution from performance perspective but TLS packets can't be routed in such way so no https support possible.

Other web services like Nginx and Lighttpd supports virtual hosts out of the box

1 Like

upstream... fuzzing detection...

uhttpd.main.error_page='/index.sh'
if [ "$REDIRECT_STATUS" = '404' ]; then
	if echo $REQUEST_URI | grep -q '/luci-static/'; then exit 0; fi
	if echo $REQUEST_URI | grep '/resources/' | grep -q 'loading.gif'; then exit 0; fi
	logger -t uhttpd "luci denied page $REMOTE_HOST $REQUEST_URI"
	echo "luci denied page $REMOTE_HOST $REQUEST_URI" >/dev/console
fi

on second thought... those echo's should be case... much safer...

I created my own solution based on the approach of @Mijzelf . My requirements were

  • No extra packages needed
  • Configurable from Luci (I always forget things are there if they're not all in one place).

With my approach, you can set up as many virtual hosts with as many redirects as you want, all from LuCI. There may be quicker ways to do this but this is what met my needs.

Part 1: CLI configuration (one time)

  1. SSH into the router and create a new folder: ~/vhost
  2. In that folder create a new file index.html.
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
        <head>
                <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
                <meta http-equiv="Pragma" content="no-cache" />
                <meta http-equiv="Expires" content="0" />
                <meta http-equiv="Expires" content="Thu, 01 Jan 1970 00:00:00 GMT" />
                <meta http-equiv="refresh" content="0; URL=virtualhosts.sh" />
                <style type="text/css">
                        body { background: white; font-family: arial, helvetica, sans-serif; }
                        a { color: black; }

                        @media (prefers-color-scheme: dark) {
                                body { background: black; }
                                a { color: white; }
                        }
                </style>
        </head>
        <body>
                <a href="cgi-bin/luci/">LuCI - Lua Configuration Interface</a>
        </body>
</html>
  1. Still in ~/vhost, create virtualhosts.sh
#!/bin/sh

PROTOCOL=http://

###############################


# optional. Strip network name to handle openwrt.lan the same as openwrt
HTTP_HOST=$(echo ${HTTP_HOST} | sed -r 's/(.*)\..*/\1/') #https://stackoverflow.com/a/13857951
# Hostname can be upper or mixed case, so change it to lower
HOST=$(echo ${HOSTNAME} | awk '{print tolower($0)}')
# Default url
URL=/cgi-bin/luci
# echo $HTTP_HOST $HOST $URL > /root/vhost/log.log

gethostforvhost() {
  config_get CNAME "$1" 'cname'
  config_get TARGET "$1" 'target'
  if [ "$CNAME" = "$2" ]; then
    TARGETHOST=$TARGET
  fi
}

getredirectinfo() {
  config_get CNAME "$1" 'cname'
  config_get TARGET "$1" 'target'
  HOST=$(echo $CNAME | sed -r 's/(.*)\..*/\1/' | sed -r 's/(.*)\..*/\1/' |  sed -r 's/(.*)\..*/\1/') # get only the domain portion of the cname host, stripping out the three special fields, if any
  PORT=$(echo $CNAME | awk -F. '{print $NF}' | grep -E "^[0-9]+$")  # if parsed TLD is an integer, PORT will be non-empty and is assumed to be the port of the desired service
  if [ ! -z "$PORT" ]  && [ "$HOST" = "$2" ]; then
    VHOST=$(echo $CNAME | awk -F. '{print $(NF-1)}')
    config_foreach gethostforvhost 'cname' ${VHOST}
    URL=$PROTOCOL$TARGETHOST:$PORT
  fi
}


if [ "${HTTP_HOST}" != "${HOST}" ]; then
    . /lib/functions.sh

    #search for the given host in the router's cname definitions
    #The cname definition should have two entries
    # 1. `example.lan -> openwrt.lan` where example.lan is the hostname of the service and openwrt.lan is the hostname of the router
    # 2. `example.lan.label.9000 -> openwrt.lan` where 9000 is the port number of the service and label corresponds to an existing cname entry of the form label -> domain_name to redirect to
    #Visiting example.lan will redirect to $PROTOCOL$domain_name:9000 via this script.
    config_load dhcp
    config_foreach getredirectinfo 'cname' ${HTTP_HOST}
fi

cat <<__EOF__
Content-type: text/html; charset=utf-8

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="refresh" content="0; URL=${URL}" />
</head>
<body>
</body>
</html>
__EOF__
  1. Go to the uhttpd root directory. cd /www
  2. Delete (or rename) the existing index.html
  3. Create symlinks to files we created above
ln -s ~/vhost/index.html .
ln -s ~/vhost/virtualhosts.sh .
  1. Add /bin/sh as a cgi interpreter to /etc/config/uhttpd
config uhttpd 'main'
	<other config stuff>
	list interpreter	".sh=/bin/sh"
  1. Restart uhttpd (or reboot the router). /etc/init.d/uhttpd restart

Part 2: Configuration in LuCI

  1. (optional) Add ~/vhost to the list of directories to preserve for backups during sysupgrades. Go to System -> Backup / Flash Firmware -> Configuration. Then add /root/vhost to the list. (Or just the entire /root folder).
  2. We will abuse the CNAME functionality to do some redirecting and store our configuration. Go to Network -> DHCP and DNS -> CNAME.
  3. Add a label for each host you want to redirect to. Each vhost will be associated with that label and will also have the port to redirect to. Refer to the example.

Example
Assume that the service portainer is running on a server called mx1 on port 9000. I want to access this service by typing portainer.lan into my browser's address bar which will redirect to http://mx1:9000. (Note: if you want to redirect to https addresses, modify the PROTOCOL=http:// line in virtualhosts.sh). Also, assume the router's hostname is openwrt.

  1. Add a new label for the mx1 host. Call it vhost1. This is our label identifying the host to redirect all our virtual hosts to. One label per host to redirect to is needed.
  2. Add a pair of entries for the new virtual host.
    • The first is the domain you want to type into your browser. portainer.lan. The corresponding Target MUST be the router itself, openwrt.lan.

    • The second is the configuration that tells virtualhosts.sh what to redirect to. Each part is separated by a period.

      • The first part of the string (blue underline) must match the first entry. portainer.lan
      • The second part of the string (red underline) must match whatever virtual host is running the thing you want to redirect to. vhost1 in this case.
      • The third part of the string (no underline) is the port number. 9000 in this example.

This seems a bit convoluted (and it is) but it was a good learning experience. It works for me and since I found several threads asking for something like this, maybe others will find it useful too.

2 Likes

Apparently I'm missing something cause I'm going into CNAME on a snapshot and it's not letting me add anything.

update Figured it out. I had an invalid field on the PXE page probably a result of using a tool to import static ip's from my old router. Once I deleted that and saved I was able to add entries under CNAME. Sorry for resurrecting an old thread.

1 Like

Good to know it still works on newer snapshots

Could this be modified to allow additional web pages to be displayed under /www/subhost? For example a service homepage? I would love to use organizr but looks like i would have to get docker working for it since it's not in the packages.

It seems like it's possible. Use the virtualhosts.sh file in this post. To add a redirect to /www/subhost, using the example from my previous post,

  • Add a new vhost, call it vhost2, with target openwrt.lan
  • Add a new domain, call it subhostdomain.lan, with target openwrt.lan
  • Add a new configuration, subhostdomain.lan.vhost2.80.subhost with target openwrt.lan. The 80 is the port number (required), the string after the port is the subdirectory to redirect to, i.e., /www/subhost.

The subhost folder must contain an index.html file (it's not possible to redirect to a specific file). It's also possible to redirect to more deeply nested subfolders, i.e., /www/subhost/subdir, by adding .subdir to the config string.

virtualhosts.sh
# optional. Strip network name to handle openwrt.lan the same as openwrt
HTTP_HOST=$(echo ${HTTP_HOST} | sed -r 's/(.*)\..*/\1/') #https://stackoverflow.com/a/13857951
# Hostname can be upper or mixed case, so change it to lower
HOST=$(echo ${HOSTNAME} | awk '{print tolower($0)}')
# Default url
URL=/cgi-bin/luci

gethostforvhost() {
  config_get CNAME "$1" 'cname'
  config_get TARGET "$1" 'target'
  if [ "$CNAME" = "$2" ]; then
    TARGETHOST=$TARGET
  fi
}

getredirectinfo() {
  config_get CNAME "$1" 'cname'
  config_get TARGET "$1" 'target'
  HOST=$(echo $CNAME | sed -e 's/\.[[:digit:]]\+/|&|/g' -e 's/|.*$//' | sed -r -e 's/(.*)\..*/\1/' -e 's/(.*)\..*/\1/')  # get only the domain portion of the cname host, w/o the TLD
  PORT=$(echo $CNAME | sed -e 's/\.[[:digit:]]\+/|&|/g' | grep -Eo '\|\.[[:digit:]]+\|' | sed -e 's/|//g' -e 's/\.//g')  # get the port number of the service. Must always be specified
  DIR=$(echo $CNAME | sed -e 's/\.[[:digit:]]\+/|&|/g' -e 's/^.*|//' -e 's/\./\//g')                                    # get the subdirectory to navigate to, if any
  if [ ! -z "$PORT" ]  && [ "$HOST" = "$2" ]; then
    VHOST=$(echo $CNAME | sed -e 's/\.[[:digit:]]\+/|&|/g' -e 's/|.*$//' | grep -Eo '\.\w*$' | sed -e 's/\.//')
    config_foreach gethostforvhost 'cname' ${VHOST}
    URL=$PROTOCOL$TARGETHOST:$PORT$DIR
    #echo $HTTP_HOST $HOST $CNAME $TARGET $URL > /root/vhost/log.log
  fi
}


. /lib/functions.sh

#search for the given host in the router's cname definitions
#The cname definition should have two entries
# 1. `example.lan -> openwrt.lan` where example.lan is the hostname of the service and openwrt.lan is the hostname of the router
# 2. `example.lan.label.9000 -> openwrt.lan` where 9000 is the port number of the service and label corresponds to an existing cname entry of the form label -> domain_name to redirect to
#Visiting example.lan will redirect to $PROTOCOL$domain_name:9000 via this script.
config_load dhcp
config_foreach getredirectinfo 'cname' ${HTTP_HOST}


cat <<__EOF__
Content-type: text/html; charset=utf-8

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="refresh" content="0; URL=${URL}" />
</head>
<body>
</body>
</html>
__EOF__

I guess this is a convoluted way of doing things.

I do it a different way. Assuming .lan is the local dnsmasq suffix:

  1. Network --> LAN --> Add new IPv4/6, X
  2. DHCP --> hostnames --> newservice.lan --> X IP
  3. Firewall --> Port Forward --> "incoming from LAN to this device IP X, port whatever, Forward to Y

Everything is available to tunnelled traffic into the LAN.

At least this way traffic remains anchored to the router, so you can firewall and filter (that's a +/- depending on how you look at it from a resource perspective).

It's also a good workaround if you have a NAS hosting docker - each port can have its own IP and 'domain name'.