luci-app-liteportal
A static HTML navigation dashboard for OpenWrt / ImmortalWrt, served by the
router's existing uhttpd under /liteportal/ and configured entirely from LuCI.
| LuCI — Services → Portal | The portal page |
|---|---|
![]() |
![]() |
What it does
The router keeps serving its normal LuCI exactly as before. This app adds a
deliberately minimal page next to it — a grid of bookmarks to the services
running on the router (or anywhere else) — on a path of the same web server,
so there is no second daemon to run and no extra port to open in the firewall.
Bookmarks, icons and the background are stored in uci and baked into a static
links.json, so the portal page itself never needs a backend.
┌──────────────────────────────────────┐
:80 / :443 ────►│ uhttpd (the router's web server) │
│ / → LuCI │
│ /liteportal/ → the portal page │
└──────────────────┬───────────────────┘
│ served straight from disk
▼
┌──────────────────────────────────────┐
│ /www/liteportal/ │
│ index.html liteportal.js │
│ liteportal.css │
│ links.json ◄── baked from uci │
│ bg/ icons/ ◄── uploads land here│
└──────────────────────────────────────┘
▲
LuCI ── /admin/services/liteportal ────────────────────┘ publishes / withdraws the page
Features
| On/off switch | Publish the page or take it off the web server; the uploaded icons and backgrounds survive either way |
| No second daemon | Served by the uhttpd the router already runs, under /liteportal/ — nothing extra to start, supervise or open in the firewall |
| Bookmarks | Add, enable/disable and delete; emoji, remote URL or an image from the asset library (a thumbnail is shown in the row when an asset image is used) |
| Asset library | Upload (2 MiB per file), preview, rename, list and delete icons and background images, and pick the page background |
| Appearance | Page title, CSS background colour or a background image, plus 0-100 sliders for background dimming, frosted blur and bookmark-card transparency |
| Search box | A search field that stays hidden until you press the space bar, submits straight to the chosen engine from the browser, and needs no backend at all |
| Discovery | Scans /proc/net/tcp[6] for locally reachable listening services, adds the known web ones as disabled bookmarks, and asks about the rest |
| Status panel | Reports whether the page is reachable and, when it is not, which of the two conditions behind that is missing — plus the address and the baked bookmark count |
| Static output | Everything is persisted in uci and rendered into links.json; the page is plain HTML/JS |
No luci-compat |
Pure JavaScript views on the modern LuCI stack — no Lua runtime required |
Using the search box
Enable it under General → Search box and the portal page gains a search field that stays out of the way until you call for it. That is the whole point: the page is a grid of links, and a permanently visible input would compete with them.
| Action | Result |
|---|---|
Press Space anywhere on the page |
The box appears and takes focus |
Press Space again while the box is empty, or Esc |
The box is put away |
Type, then Enter |
The results open in a new tab; the box clears and hides |
Two behaviours worth knowing before they surprise you:
- A space typed into a non-empty box is a space. Only an empty box hides on
Space, so a two-word query needs no modifier. - An input method keeps its own space bar. While a composition is in progress the key belongs to the IME, and any other text field on the page keeps its space bar as well.
The query is submitted by the browser straight to the engine, so the router needs
no backend method for it — nothing to grant in the ACL. The engine is picked in
the same General page: Google, Bing, DuckDuckGo, Baidu, or Custom with your
own results URL (%s marks where the keyword goes).
Requirements
| Component | Minimum |
|---|---|
| OpenWrt / ImmortalWrt | 23.05 (JS LuCI + ucode) |
| Packages | luci-base, uhttpd, ucode, rpcd-mod-ucode (pulled in automatically) |
Install
# OpenWrt 25.12 and newer (apk)
apk add --allow-untrusted ./luci-app-liteportal-4.0.1-r1.apk
# OpenWrt 24.10 and older (opkg), built from the matching SDK
opkg install ./luci-app-liteportal_4.0.1-r1_all.ipk
Then open Services → Portal in LuCI, or go straight to
http://<router>/liteportal/.
Layout
| Path | Purpose |
|---|---|
htdocs/luci-static/resources/liteportal/common.js |
Shared helpers: RPC wrappers, status panel, error reporting |
htdocs/luci-static/resources/view/liteportal/general.js |
LuCI view — switch, title, background, appearance, maintenance |
htdocs/luci-static/resources/view/liteportal/bookmarks.js |
LuCI view — the bookmark table |
htdocs/luci-static/resources/view/liteportal/assets.js |
LuCI view — the icon and background image library |
root/usr/share/luci/menu.d/luci-app-liteportal.json |
Menu registration |
root/usr/share/rpcd/acl.d/luci-app-liteportal.json |
ubus / uci / file permissions for the views |
root/usr/share/rpcd/ucode/liteportal.uc |
Backend: the luci.liteportal ubus object (plugin only, no CLI mode) |
root/etc/init.d/liteportal |
Service script: publishes or withdraws the page and bakes links.json; runs no daemon of its own |
root/usr/share/liteportal/www/ |
Portal frontend template (index.html, liteportal.js, liteportal.css), copied into /www/liteportal/ |
root/etc/config/liteportal |
Default uci configuration |
scripts/check.sh |
The entire local/CI gate — read this before the rest |
docs/ |
Architecture, DoD, configuration and the ADR log |
Backend API
/usr/share/rpcd/ucode/liteportal.uc is loaded by rpcd-mod-ucode and exposed as
the luci.liteportal ubus object:
ubus call luci.liteportal generate
# { "ok": true, "title": "My Portal", "links": 2 }
ubus call luci.liteportal scan
# { "lan_ip": "192.168.1.1",
# "ports": [ { "port": 80, "svc": "http", "web": true },
# { "port": 22, "svc": "ssh", "web": false },
# { "port": 7890, "svc": "", "web": "unknown" } ] }
ubus call luci.liteportal status
# { "ok": true, "enabled": true, "path": "/liteportal/", "published": true,
# "server_listening": true, "reachable": true, "links": 2,
# "generated": true, "generated_at": 1791124783 }
# asset management - the browser may only write /tmp/liteportal_upload.tmp,
# the backend moves the file to its final name
ubus call luci.liteportal upload '{ "dir": "bg", "name": "wallpaper.jpg" }'
ubus call luci.liteportal rename '{ "dir": "bg", "name": "wallpaper.jpg", "new_name": "home.jpg" }'
ubus call luci.liteportal remove '{ "dir": "bg", "name": "home.jpg" }'
Every method above must be listed in root/usr/share/rpcd/acl.d/luci-app-liteportal.json,
otherwise the view gets Access denied in the browser. scripts/check.sh verifies
this correspondence on every run.
The web field of a scanned port is one of:
| Value | Meaning |
|---|---|
true |
A known web service — added as a disabled bookmark |
false |
Definitely not a browser target (ssh, dns, mdns, dhcp, ntp, openvpn, wireguard, …) — never offered |
"unknown" |
No /etc/services entry says what speaks on it — listed in a dialog, your call |
A port number carries no protocol information, so 7890 may be a proxy or an
arbitrary TCP service and 1053 is mDNS. Rather than guess in either direction,
unrecognised ports are shown and can be added as disabled bookmarks in one go.
The script is a plugin only — rpcd does not define ARGV while loading it, so it
cannot double as a command line tool. Call it over ubus as shown above.
status answers one question — can the page be reached — and reports the two
conditions behind it separately, because each has a different fix: published
(the entry point and links.json are in place) and server_listening (the main
uhttpd owns one of the ports it was configured with). reachable is the two of
them together. The result carries no url: the browser composes the address from
its own origin plus path, which is the one address guaranteed to be the server
that is actually serving the page.
uci reference
config liteportal
option enabled '1' # master switch; '0' takes the page off the web server
option title 'My Portal' # page heading and <title>
option background '#0e1116' # CSS colour
option background_file '' # absolute path below /www/liteportal
option bg_veil '78' # 0-100: how strongly a background image is dimmed
option bg_blur '0' # 0-100: frosted blur, 100 = the 20 px maximum
option card_transparency '45' # 0-100: 100 = bookmark cards fully see-through
option search_enabled '0' # '1' enables the space-bar search box
option search_engine 'https://www.google.com/search?q=%s' # results URL, %s = keyword
config link
option name 'AdGuard Home'
option url 'http://192.168.1.1:3000'
option icon_url '🛡️' # or: option icon '/www/liteportal/icons/x.png'
option enabled '1'
The page carries no port of its own: it is reachable on whatever port the main
uhttpd listens on, so there is nothing to open in the firewall.
enabled means "publish or withdraw the page", not "start or stop a daemon".
Switching it off removes the entry point and links.json from
/www/liteportal/, so /liteportal/ stops resolving; the uploaded icons and
backgrounds are left alone and come back when it is switched on again.
links.json carries the title, the background and the three appearance
percentages as they are written in uci; liteportal.js turns the percentages into
the CSS custom properties liteportal.css consumes, so the served page never has to
read uci itself. A missing percentage falls back to the same default value in the
backend, so a partially written configuration still renders.
The file is re-read on every page load: the request bypasses the browser cache on
purpose. A static file reaches the browser with Last-Modified but no
Cache-Control, which makes a browser cache it heuristically for a fraction of
its age — a portal untouched for a day was considered fresh for hours, so a saved
change to any appearance slider only showed up after a hard reload.
search_enabled switches the search box on; an absent value means off.
search_engine is the engine's results URL with %s standing for the keyword,
and choosing Custom in the form sends the page to the template in search_url
instead — an option written only in that case. The engine list lives in the LuCI
form and nowhere else: links.json carries just the resolved template, so the
portal page holds no engine knowledge of its own. See Using the search box
above for the keyboard interaction.
It is regenerated automatically whenever /etc/config/liteportal changes (LuCI
Save & Apply, or uci commit plus /etc/init.d/liteportal reload).
Troubleshooting
The General page has a Service status panel; the same numbers come from ubus:
ubus call luci.liteportal status
| Symptom | Cause |
|---|---|
enabled: false |
The switch in General is off — tick it and press Save & Apply |
published: false |
The page is not on disk: the service has not run yet, or the switch is off. sh /etc/init.d/liteportal start publishes it |
server_listening: false |
The main uhttpd is not listening on any port it was configured with — check /etc/config/uhttpd |
/liteportal/ answers 404 although the panel says reachable |
The main instance's option home is not /www, so it is serving a different tree |
| Page loads, but no bookmarks | links.json missing or empty — press Save & Apply to re-bake it, or run ubus call luci.liteportal generate |
| Access denied in the browser console | A ubus method is missing from the ACL — run sh scripts/check.sh |
The page is served by the main uhttpd, so it inherits that instance's listen
addresses and firewall rules — there is no separate port to unblock. The flip
side is worth knowing before you enable it: /liteportal/ is reachable wherever LuCI
is, including from the WAN if the admin interface is exposed there.
Known limitations
| Limitation | Why |
|---|---|
| Uploads are capped at 2 MiB | Enforced in the UI and in the backend, so a hand crafted request cannot bypass it. Multi-megabyte backgrounds are served fine — only the upload is bounded |
File names are ASCII only: letters, digits, ., -, _ |
The name becomes a device path. Rejecting is safer than silently rewriting what the user typed, and it rules out path traversal by construction. Chinese names and spaces are refused |
| The page is reachable wherever LuCI is | It is served by the same uhttpd and therefore inherits the admin interface's exposure — there is no separate port left closed. Putting it out of reach would mean putting LuCI out of reach |
The location is fixed at /liteportal/ |
It has to live inside the main instance's option home (default /www), and the LuCI-side file ACL is a static list — a custom home needs the package adjusted |
| Unrecognised ports are not probed | Deciding whether a port speaks HTTP would need an active GET / with a timeout inside a synchronous ubus method, which blocks the whole rpcd — and therefore every other LuCI page. They are listed for you to decide instead |
| Setting a background image is staged, not applied | Like every other setting, it waits for Save & Apply. generate() runs in the backend process with its own uci cursor and would otherwise bake the old value |
Building
Building needs a Linux SDK matching the target firmware — WSL2 + Ubuntu works
fine, but the SDK and the source tree must live on the Linux filesystem, never
under /mnt/.
tar --zstd -xf immortalwrt-sdk-<version>-<arch>_gcc-*.tar.zst
cd immortalwrt-sdk-*/
./scripts/feeds update -a
./scripts/feeds install -a
cp -r /path/to/luci-app-liteportal package/luci-app-liteportal
make defconfig
make package/luci-app-liteportal/compile V=s
The artefact lands in bin/packages/<arch>/luci/. Note that the SDK ships a
.config describing a whole firmware image; strip its CONFIG_PACKAGE_*=m
lines before compiling unless you actually want to build the entire package
tree.
Development
sh scripts/check.sh
That is the same gate CI runs: JS syntax, JSON validity, shellcheck,
msgfmt --check, the ubus↔ACL correspondence, line endings and leftover template
placeholders. It exits non-zero on the first failure. Individual commands are
listed in AGENTS.md §2, and the code conventions in
docs/architecture.md.
License
GPL-2.0-only. See LICENSE.

