Openwrt Starlink control panel

luci-app-starlink --- UPDATED after lots testing, solid release.

https://github.com/bigmalloy/openwrt-starlink-control/releases/tag/v2.1-r4

LuCI dashboard for Starlink dish telemetry, alignment, alerts, IPv6 connectivity, traffic, and router configuration on OpenWrt 25.x. Works with Starlink Gen3 and higher dish


Features

  • Dish Telemetry β€” state, uptime, latency, packet drop, obstruction %, throughput, SNR, GPS satellites, Ethernet speed, hardware/software version
  • Alignment β€” tilt and rotation guidance (↑↓ / ↻↢) with "well aligned" confirmation when within 0.1Β°
  • Alerts β€” 11 health indicators matching the Starlink app (heating, thermal throttle, shutdown, PSU throttle, motors, mast, slow Ethernet, software update, roaming, obstruction, disabled)
  • IPv6 Connectivity β€” WAN address, LAN address, delegated /56 prefix, default route
  • Traffic β€” WAN and LAN byte/packet counters
  • Quality β€” latency to 8.8.8.8 / 1.0.0.1, conntrack usage, router uptime
  • Configuration β€” TCP congestion control, qdisc, flow offloading, MTU fix, DHCPv6-PD lifetime settings
  • Turn Starlink Config On button β€” applies full optimal Starlink IPv6 config (DHCPv6-PD, odhcpd lifetime fix, DNS, NTP, firewall, kernel tuning) with one click; shows green "βœ“ Starlink Config Active" when all settings are verified, reverts if any setting drifts
  • Set as Default Home Page button β€” makes the Starlink dashboard the first page seen after login; click again to revert
  • DNS Servers β€” IPv4 and IPv6 DNS server list with peerdns status; DNS Mode selector dropdown to switch between Default (Cloudflare + Google), Starlink (ISP DNS), Family Filter (Cloudflare for Families β€” blocks malware + adult content), and Malware Filter (Cloudflare malware-only)
  • Connected Devices β€” scrollable device list with hostname, IP, MAC, active/stale state, plus DHCP range at the bottom
  • Router Stats β€” CPU load gauges, memory usage bar, load averages
  • Reboot Dish button with confirmation dialog

Auto-refreshes every 10 seconds.

Note: The alignment data provided is direct from the dish API and after confirming with star-link support is more accurate than the phone app that incorrectly reports over 6 degrees misalignment and should be ignored if the dish reports its aligned.

One-Click Optimal Starlink IPv6 Configuration

The Configuration card includes a Turn Starlink Config On button that applies the full recommended OpenWrt setup for Starlink residential with a single click β€” no SSH or command line needed.

Github link
https://github.com/bigmalloy/openwrt-starlink-control

8 Likes

This would be great in the OpenWrt/packages feeds
The problem is its dependency on grpcurl, which does not exist in the OpenWrt feeds...
So that would have to be submitted first.....

Looks very useful though..

[quote="bluewavenet, post:2, topic:247740"]
Thanks for the feedback You're right that grpcurl is the blocker for the packages feed.

I looked into packaging grpcurl for OpenWrt β€” it's a Go binary so the cross-compilation side is straightforward using the existing
golang feed infrastructure, but at ~23 MB it may get pushback from maintainers on size for a developer/CLI tool.

In the meantime the APK installs cleanly on OpenWrt 25.x and the grpcurl binary is a one-time manual install from the GitHub
releases page. Happy to hear if anyone has experience getting large Go binaries accepted into the packages feed.

The latest version APK includes a scipt that automates grpcurl installation to make life easier

Unless something else needs grpcurl, I think a multi-megabyte single-purpose utility is a bit too much. I don't use gRPC, but what about other alternatives like the official CLI?

I looked into this β€” there's no official SpaceX CLI binary. SpaceX only publishes .proto files and a Python demo script via
their enterprise-api repo, nothing pre-built for Linux arm64.

I meant the gRPC CLI.

Probably not worth it. The grpc_cli static binary includes gRPC + protobuf + abseil-cpp + re2 β€” all heavy
C++ libraries. It would almost certainly come out larger than grpcurl's ~23 MB, not smaller.

Instead of this general purpose monster, what about a custom replacement with pre-generated or reflection-based protobuf types tailored to Starlink's spacex.api.device ?

I'm no expert, but this looks much more promising and likely to be very significantly smaller as it would be dedicated to the job:

Single figures megabytes and easy to cross compile in the OpenWrt packages feed - Rust seems to be well supported.

Nice find. Catch is it's a library not a CLI, so you'd still need to write a wrapper binary with JSON output β€” not a
drop-in. Also only a few weeks old so I'd want to see it survive a few firmware updates first.

Worth watching though. My router has 198 MB free 23mb is really not that large and grpcurl is a proven zero-maintenance approach

Mmm, I think it is over a year old and has three releases.

Well, yes, but that is probably simple. I'm not a Rust person, but maybe something like this:

use starlink_grpc_client::client::DishClient;
use clap::Parser;
use serde_json;
use tokio;

#[derive(Parser)]
struct Args {
    /// Starlink dish gRPC address (default: http://192.168.100.1:9200)
    #[arg(default_value = "http://192.168.100.1:9200")]
    addr: String,

    /// Command: status | history | obstructions
    command: String,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = Args::parse();
    let mut client = DishClient::connect(&args.addr).await?;

    match args.command.as_str() {
        "status" => {
            let status = client.get_status().await?;
            println!("{}", serde_json::to_string_pretty(&status)?);
        }
        "history" => {
            let history = client.get_history().await?;
            println!("{}", serde_json::to_string_pretty(&history)?);
        }
        // Add obstructions, alerts, etc.
        _ => println!("blah blah blah"),
    }
    Ok(())
}

Or maybe your code could call the library direct.....

Just thinking out loud.

There are over 10 million Starlink accounts and growing (March 2026). If only a few percent of those account holders would like to have something integrated into their OpenWrt router, this could be VERY big as an official package.

1 Like

Fair correction on the age β€” 10 months and 3 releases is more credible than I implied.

The bundled-proto approach is actually the key trade-off though. grpcurl uses server reflection β€” it asks the dish what it supports at runtime, so it's immune to Starlink API changes without any rebuild. A bundled-proto binary needs updating every time Starlink renames a field or restructures a message, which happens regularly
with their weekly firmware cadence.

The wrapper also isn't trivial β€” the rpcd backend extracts ~30 fields across getStatus, getDiagnostics and dish_get_config, and they'd all need custom JSON serialization. Not impossible, just not a quick afternoon job.

That said, if the goal is getting into the official OpenWrt packages feed (and the 10M accounts point is a good reason to aim for that), a 3–5MB dedicated binary beats a 23MB general-purpose tool. Worth watching. If someone builds a proper CLI with JSON output and documents the aarch64/musl cross-compile, I'd switch.

Hmm will have to think about this one further

The OpenWrt build system makes cross compilation the easy part.

Can I see a deep dive into Rust on the horizon?......

1 Like

This is a new project that renders a simple luci Starlink dish panel and uses a new starlink-dish` a purpose-built Rust binary instead of grpcurl. unlike the one above intended to be a Luci Overview page replacement.

Tested on a GL-iNet Beryl AX (MT3000) running OpenWrt 25.12.0, with a Gen3 Starlink dish (rev4_panda_prod2, AU).

GitHub: https://github.com/bigmalloy/starlink-panel/releases/latest


What it shows

The dashboard is a single LuCI page that auto-refreshes every 10 seconds. It pulls data from two sources:

  • starlink-dish β€” a companion Rust binary (~1.4 MB, statically linked) that queries the dish gRPC API at 192.168.100.1:9200
  • rpcd shell backend β€” polls router-side state (WAN/LAN interface stats, IPv6, conntrack, ping)

Cards displayed:

  • Dish Telemetry β€” connection state, PoP latency, drop rate, obstruction %, SNR, elevation, GPS sats, Ethernet link speed, dish uptime, firmware version, service class, mobility class, speed restrictions
  • Alignment β€” tilt/rotation recommendation vs current bore/desired azimuth and elevation, attitude filter state, reboot button
  • Alerts β€” 15-item checklist: thermal throttle, PSU throttle, thermal shutdown, motors stuck, mast not vertical, slow Ethernet, software update state, obstructed, disabled, SNR low, unexpected location, install pending, roaming
  • IPv6 Connectivity β€” WAN IPv6 address, LAN prefix, delegated /56, default route presence, preferred/valid lifetime
  • Traffic β€” instantaneous up/down throughput from dish gRPC, WAN and LAN byte/packet counters
  • Quality β€” dishβ†’PoP latency, router pings to 8.8.8.8 and 1.0.0.1, conntrack usage %, router uptime
  • Ready States β€” RF, L1/L2, xPHY, SCP, AAP ready flags; signed cals status
  • Boot Stats β€” time to GPS valid, first PoP ping, stable connection, obstruction patches
  • Recent Outages β€” last 6 outages with cause and duration (OBSTRUCTED, NO_SCHEDULE, etc.)

starlink-dish replaces grpcurl

Earlier versions of this package used grpcurl, which is ~15 MB and doesn't exist as an OpenWrt package. The companion binary starlink-dish is a purpose-built Rust binary using the starlink-grpc-client crate (tonic 0.9 / prost 0.11) with the Starlink proto definitions included. It is statically linked against musl and strips to ~1.4 MB. It is downloaded automatically during APK install β€” no manual steps needed.

Source and build scripts: https://github.com/bigmalloy/starlink-panel

3 Likes

Nice :rofl:

Testing on a gl-mt6000 on recent snapshot, and piping to jq to make it readable:


{
  "al_heating": "false",
  "al_install_pending": "false",
  "al_mast": "false",
  "al_motors": "false",
  "al_psu_throttle": "false",
  "al_roaming": "false",
  "al_shutdown": "false",
  "al_slow_eth": "false",
  "al_throttle": "false",
  "al_unexpected_location": "false",
  "attitude": "FILTER_CONVERGED",
  "attitude_uncertainty_deg": 0.3464653789997101,
  "available": true,
  "avg_obstruction_dur": 0.0,
  "avg_obstruction_int": null,
  "bootcount": 74,
  "bore_azimuth_deg": -156.0312042236328,
  "bore_elevation_deg": 65.2025146484375,
  "class_of_service": "CONSUMER",
  "country_code": "AD",
  "currently_obstructed": "false",
  "desired_azimuth_deg": -155.0238800048828,
  "desired_elevation_deg": 70.01817321777344,
  "disablement": "OKAY",
  "dish_id": "ut0011868e-0522ae1d-18aba3bf",
  "dl_restrict": "NO_LIMIT",
  "downlink_bps": 40844.1328125,
  "drop_rate": 0.0,
  "elevation_deg": 65.2025146484375,
  "eth_speed_mbps": 1000,
  "fraction_obstructed": 0.10220441222190857,
  "gps_sats": 10,
  "gps_valid": "true",
  "hardware": "rev4_panda_prod1",
  "has_signed_cals": "true",
  "init_first_ping_s": 47,
  "init_gps_s": 29,
  "init_stable_s": 48,
  "latency_ms": 25.388917922973633,
  "mobility_class": "STATIONARY",
  "obst_patches_valid": 4491,
  "outages": [],
  "rs_aap": "true",
  "rs_cady": "false",
  "rs_l1l2": "true",
  "rs_rf": "true",
  "rs_scp": "true",
  "rs_xphy": "true",
  "seconds_to_slot": 1773890688.0,
  "snow_melt_mode": "AUTO",
  "snr_above_noise": "true",
  "snr_persistently_low": "false",
  "software": "2026.03.03.mr75126.1",
  "state": "CONNECTED",
  "sw_reboot_ready": "false",
  "sw_update_state": "IDLE",
  "swupdate_reboot_hour": 3,
  "tilt_angle_deg": 23.91104507446289,
  "ul_restrict": "NO_LIMIT",
  "uplink_bps": 23714.595703125,
  "uptime": 23976
}

Well done!

I would suggest actually making http://192.168.100.1:9200 a built in default, eg requiring something like -d http://192.168.100.1:9200 to override the built in. Cosmetic I know, but would be nice.

1 Like

Thanks for the test, always want to make things as easy as possible.
Probably a good idea to remove your dish_id from your post

The address is now a built-in default, so you can just run:

starlink-dish dish
starlink-dish reboot

That's a made up one :wink:

I haven't tried that. Does it give a warning?
When I've done similar I've done eg
reboot now so you really mean it

ok false dishid good, just reboots dish with no warning, users used to cli can handle it.
sends the gRPC RebootRequest and outputs
{"success":true} (or {"success":false}/error JSON

@bigmalloy Hey, great work on the Starlink OpenWrt setup script and the control panel β€” been running it on a Xiaomi AX9000 and everything works really well. Also running cake-autorate for bufferbloat management alongside your script, no conflicts so far.

One thing I noticed: the script automatically overwrites the DNS settings with Cloudflare and Google DNS. I'm running Technitium DNS locally, so after the script ran it replaced my custom DNS config without warning.

Would it be possible to make the DNS step optional? That way people running their own resolvers (Technitium, Pi-hole, AdGuard Home, etc.) won't have their config overwritten.

Thanks again for putting this together.

EDIT:

Maybe Bug: LAN Prefix shows "None" (red) when using VLANs

Running the control panel on a Xiaomi AX9000 with a VLAN setup. IPv6 is fully working β€” clients get global addresses and connectivity is fine β€” but the panel shows LAN Prefix as "None" with a red indicator.

The issue is that the delegated /56 prefix is assigned to br-lan.10 (VLAN interface) rather than br-lan. The panel appears to only check br-lan for the LAN prefix, so it misses it.

Maybe Bug: Dish state shows "UNKNOWN" when running in bridge mode

Running the control panel on a Xiaomi AX9000 with a Gen3 dish (rev4_prod3, firmware 2026.03.08.cr75503.1) in bridge mode. The dish is fully operational β€” 20.5ms PoP latency, 0% drop rate, alignment converged, 2+ days uptime β€” but the State field shows "UNKNOWN" (orange badge) and SNR OK shows "no".

thanks for he feedback on the script, yes I was thinking changing dns might be a problem. Now Checks if network.wan.dns already has entries β€” if so, skips with a message showing the current DNS config as I want script to not require interaction for other projects. FORCE_DNS=1 sh /tmp/starlink-setup.sh overrides if someone explicitly wants Cloudflare+Google

Starlink-panel updated current being compiled please test if the fix works for you once the new release uploads

1 Like