diff --git a/applications/luci-app-51ddns/Makefile b/applications/luci-app-51ddns/Makefile
new file mode 100644
index 000000000000..18cd669567df
--- /dev/null
+++ b/applications/luci-app-51ddns/Makefile
@@ -0,0 +1,14 @@
+include $(TOPDIR)/rules.mk
+
+LUCI_TITLE:=LuCI support for 51DDNS remote access
+LUCI_URL:=https://github.com/21hkcloud/51ddns-openwrt
+LUCI_DESCRIPTION:=Configure and monitor the 51DDNS remote access agent.
+LUCI_DEPENDS:=+luci-base +51ddns-agent
+LUCI_PKGARCH:=all
+
+PKG_LICENSE:=Apache-2.0
+PKG_MAINTAINER:=Jinshuan Wang <21hkcloud@gmail.com>
+
+include ../../luci.mk
+
+# call BuildPackage - OpenWrt buildroot signature
diff --git a/applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js b/applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js
new file mode 100644
index 000000000000..9d78119ae3f0
--- /dev/null
+++ b/applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js
@@ -0,0 +1,141 @@
+'use strict';
+'require view';
+'require form';
+'require rpc';
+'require uci';
+
+const callServiceList = rpc.declare({
+ object: 'service',
+ method: 'list',
+ params: [ 'name' ],
+ expect: { '': {} },
+});
+
+const callLocalStatus = rpc.declare({
+ object: 'luci.51ddns',
+ method: 'status',
+ expect: { '': {} },
+});
+
+const callAgentInfo = rpc.declare({
+ object: 'luci.51ddns',
+ method: 'info',
+ expect: { '': {} },
+});
+
+function serviceState(data) {
+ const instances = data?.['51ddns-agent']?.instances || {};
+ const running = Object.values(instances).some(instance => instance?.running);
+
+ return {
+ running,
+ label: running ? _('Running') : _('Not running'),
+ className: running ? 'alert-message success' : 'alert-message warning',
+ };
+}
+
+function formatDate(value) {
+ const date = new Date(value || '');
+
+ if (Number.isNaN(date.getTime()))
+ return _('Synchronizing');
+
+ return date.toLocaleString([], {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+function remainingState(value) {
+ const expires = new Date(value || '');
+
+ if (Number.isNaN(expires.getTime()))
+ return { label: _('Synchronizing'), className: 'alert-message notice' };
+
+ const milliseconds = expires.getTime() - Date.now();
+ if (milliseconds <= 0)
+ return { label: _('Expired'), className: 'alert-message error' };
+
+ const hours = Math.ceil(milliseconds / 3600000);
+ const days = Math.ceil(hours / 24);
+ const label = hours > 48
+ ? N_(days, 'About %d day', 'About %d days').format(days)
+ : N_(hours, '%d hour', '%d hours').format(hours);
+
+ return {
+ label,
+ className: milliseconds <= 3 * 86400000
+ ? 'alert-message warning'
+ : 'alert-message success',
+ };
+}
+
+return view.extend({
+ load() {
+ return Promise.all([
+ uci.load('51ddns'),
+ L.resolveDefault(callServiceList('51ddns-agent'), {}),
+ L.resolveDefault(callLocalStatus(), {}),
+ L.resolveDefault(callAgentInfo(), {}),
+ ]);
+ },
+
+ render(data) {
+ const state = serviceState(data[1]);
+ const local = data[2] || {};
+ const info = data[3] || {};
+ const plan = local.plan || null;
+ const remaining = remainingState(plan?.expires_at);
+ const map = new form.Map(
+ '51ddns',
+ _('51DDNS Remote Access'),
+ _('Enter the account token once to register this router automatically. Device identity and relay settings are managed by the 51DDNS control plane.'),
+ );
+ const section = map.section(form.NamedSection, 'main', 'agent', _('Quick setup'));
+ section.addremove = false;
+
+ const status = section.option(form.DummyValue, '_status', _('Service status'));
+ status.rawhtml = true;
+ status.cfgvalue = () =>
+ `${state.label}`;
+
+ const version = section.option(form.DummyValue, '_version', _('Agent version'));
+ version.cfgvalue = () => info.version || _('Unavailable');
+
+ const planName = section.option(form.DummyValue, '_plan_name', _('Current plan'));
+ planName.cfgvalue = () => plan?.product_name || _('No active plan');
+
+ const expiresAt = section.option(form.DummyValue, '_expires_at', _('Expires at'));
+ expiresAt.cfgvalue = () => plan ? formatDate(plan.expires_at) : _('Not bound');
+
+ const remainingTime = section.option(form.DummyValue, '_remaining_time', _('Time remaining'));
+ remainingTime.rawhtml = true;
+ remainingTime.cfgvalue = () => {
+ if (!plan)
+ return `${_('Bind or purchase a plan')}`;
+
+ return `${remaining.label}`;
+ };
+
+ const consoleButton = section.option(form.Button, '_console', _('Console'));
+ consoleButton.inputtitle = _('Open 51DDNS Console');
+ consoleButton.inputstyle = 'apply';
+ consoleButton.onclick = () =>
+ window.open('https://console.51ddns.com/console#/workbench', '_blank', 'noopener,noreferrer');
+
+ const enabled = section.option(form.Flag, 'enabled', _('Enable'));
+ enabled.rmempty = false;
+ enabled.default = enabled.disabled;
+
+ const token = section.option(form.Value, 'account_token', _('Account token'));
+ token.password = true;
+ token.rmempty = false;
+ token.placeholder = '51d_...';
+ token.description = _('Copy the token from the 51DDNS console. All devices in the same account share this token.');
+
+ return map.render();
+ },
+});
diff --git a/applications/luci-app-51ddns/po/templates/51ddns.pot b/applications/luci-app-51ddns/po/templates/51ddns.pot
new file mode 100644
index 000000000000..c4879cd78492
--- /dev/null
+++ b/applications/luci-app-51ddns/po/templates/51ddns.pot
@@ -0,0 +1,108 @@
+msgid ""
+msgstr "Content-Type: text/plain; charset=UTF-8"
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:66
+msgid "%d hour"
+msgid_plural "%d hours"
+msgstr[0] ""
+msgstr[1] ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:94
+#: applications/luci-app-51ddns/root/usr/share/luci/menu.d/luci-app-51ddns.json:3
+msgid "51DDNS Remote Access"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:65
+msgid "About %d day"
+msgid_plural "About %d days"
+msgstr[0] ""
+msgstr[1] ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:133
+msgid "Account token"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:105
+msgid "Agent version"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:118
+msgid "Bind or purchase a plan"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:123
+msgid "Console"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:137
+msgid ""
+"Copy the token from the 51DDNS console. All devices in the same account "
+"share this token."
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:108
+msgid "Current plan"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:129
+msgid "Enable"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:95
+msgid ""
+"Enter the account token once to register this router automatically. Device "
+"identity and relay settings are managed by the 51DDNS control plane."
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:60
+msgid "Expired"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:111
+msgid "Expires at"
+msgstr ""
+
+#: applications/luci-app-51ddns/root/usr/share/rpcd/acl.d/luci-app-51ddns.json:3
+msgid "Grant access to the 51DDNS configuration"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:109
+msgid "No active plan"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:112
+msgid "Not bound"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:32
+msgid "Not running"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:124
+msgid "Open 51DDNS Console"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:97
+msgid "Quick setup"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:32
+msgid "Running"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:100
+msgid "Service status"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:41
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:56
+msgid "Synchronizing"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:114
+msgid "Time remaining"
+msgstr ""
+
+#: applications/luci-app-51ddns/htdocs/luci-static/resources/view/51ddns/overview.js:106
+msgid "Unavailable"
+msgstr ""
diff --git a/applications/luci-app-51ddns/root/usr/libexec/rpcd/luci.51ddns b/applications/luci-app-51ddns/root/usr/libexec/rpcd/luci.51ddns
new file mode 100755
index 000000000000..da0b935d4afc
--- /dev/null
+++ b/applications/luci-app-51ddns/root/usr/libexec/rpcd/luci.51ddns
@@ -0,0 +1,59 @@
+#!/bin/sh
+
+. /usr/share/libubox/jshn.sh
+
+print_status() {
+ status_has_plan=0
+ status_product_name=''
+ status_expires_at=''
+ status_payload=''
+
+ if [ -s /var/lib/51ddns/status.json ]; then
+ status_payload="$(cat /var/lib/51ddns/status.json 2>/dev/null)"
+ json_load "$status_payload" 2>/dev/null
+
+ if json_select plan >/dev/null 2>&1; then
+ status_has_plan=1
+ json_get_var status_product_name product_name
+ json_get_var status_expires_at expires_at
+ fi
+ json_cleanup
+ fi
+
+ json_init
+ if [ "$status_has_plan" = 1 ]; then
+ json_add_object plan
+ json_add_string product_name "$status_product_name"
+ json_add_string expires_at "$status_expires_at"
+ json_close_object
+ fi
+ json_dump
+ json_cleanup
+}
+
+case "$1" in
+ list)
+ echo '{"status":{},"info":{}}'
+ ;;
+ call)
+ case "$2" in
+ status)
+ print_status
+ ;;
+ info)
+ version="$(/usr/bin/51ddns-agent --version 2>/dev/null | head -n 1)"
+ json_init
+ json_add_string version "$version"
+ json_dump
+ json_cleanup
+ ;;
+ *)
+ echo '{"error":"method not found"}'
+ exit 1
+ ;;
+ esac
+ ;;
+ *)
+ exit 1
+ ;;
+esac
diff --git a/applications/luci-app-51ddns/root/usr/share/luci/menu.d/luci-app-51ddns.json b/applications/luci-app-51ddns/root/usr/share/luci/menu.d/luci-app-51ddns.json
new file mode 100644
index 000000000000..1f7b5bedba5c
--- /dev/null
+++ b/applications/luci-app-51ddns/root/usr/share/luci/menu.d/luci-app-51ddns.json
@@ -0,0 +1,13 @@
+{
+ "admin/services/51ddns": {
+ "title": "51DDNS Remote Access",
+ "order": 35,
+ "action": {
+ "type": "view",
+ "path": "51ddns/overview"
+ },
+ "depends": {
+ "acl": [ "luci-app-51ddns" ]
+ }
+ }
+}
diff --git a/applications/luci-app-51ddns/root/usr/share/rpcd/acl.d/luci-app-51ddns.json b/applications/luci-app-51ddns/root/usr/share/rpcd/acl.d/luci-app-51ddns.json
new file mode 100644
index 000000000000..0f8581b3c44a
--- /dev/null
+++ b/applications/luci-app-51ddns/root/usr/share/rpcd/acl.d/luci-app-51ddns.json
@@ -0,0 +1,15 @@
+{
+ "luci-app-51ddns": {
+ "description": "Grant access to the 51DDNS configuration",
+ "read": {
+ "ubus": {
+ "luci.51ddns": [ "status", "info" ],
+ "service": [ "list" ]
+ },
+ "uci": [ "51ddns" ]
+ },
+ "write": {
+ "uci": [ "51ddns" ]
+ }
+ }
+}