cloud_discovery: add new daemon

Signed-off-by: John Crispin <john@phrozen.org>
This commit is contained in:
John Crispin
2025-12-11 07:27:55 +01:00
parent ee8c869a8e
commit 711eaa8355
7 changed files with 788 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=cloud_discovery
PKG_RELEASE:=1
PKG_LICENSE:=BSD-3-Clause
PKG_MAINTAINER:=John Crispin <john@phrozen.org>
include $(INCLUDE_DIR)/package.mk
define Package/cloud_discovery
SECTION:=ucentral
CATEGORY:=uCentral
TITLE:=TIP cloud_discovery
DEPENDS:=+certificates +ucode-mod-log
endef
Build/Compile=
define Package/cloud_discovery/install
$(CP) ./files/* $(1)
endef
$(eval $(call BuildPackage,cloud_discovery))

View File

@@ -0,0 +1 @@
touch /tmp/ntp.set

View File

@@ -0,0 +1,42 @@
#!/bin/sh /etc/rc.common
START=98
USE_PROCD=1
PROG=/usr/bin/cloud_discovery
service_triggers() {
procd_add_reload_trigger ucentral
}
reload_service() {
ubus call cloud reload
}
start_service() {
[ -f /etc/ucentral/capabilities.json ] || {
mkdir -p /etc/ucentral/
/usr/share/ucentral/capabilities.uc
}
local valid=$(cat /etc/ucentral/gateway.json | jsonfilter -e '@["valid"]')
[ "$valid" == "true" ] ||
/usr/share/ucentral/ucentral.uc /etc/ucentral/ucentral.cfg.0000000001 > /dev/null
est_client check
[ $? -eq 1 ] && {
logger ERROR
logger ERROR
logger ERROR
logger The certificate used has a CN that does not match the serial of the device
echo The certificate used has a CN that does not match the serial of the device
logger ERROR
logger ERROR
logger ERROR
return
}
procd_open_instance
procd_set_param command "$PROG"
procd_set_param respawn
procd_close_instance
}

View File

@@ -0,0 +1,3 @@
#!/bin/sh
/usr/share/ucentral/cloud_discovery.uc $1

View File

@@ -0,0 +1,445 @@
#!/usr/bin/ucode
'use strict';
import { ulog_open, ulog, ULOG_SYSLOG, ULOG_STDIO, LOG_DAEMON, LOG_INFO } from 'log';
import * as libubus from 'ubus';
import * as uloop from 'uloop';
import * as libuci from 'uci';
import * as math from 'math';
import * as fs from 'fs';
const DISCOVER = 0;
const VALIDATING = 1;
const ONLINE = 2;
const OFFLINE = 3;
const ORPHAN = 4;
const DISCOVER_DHCP = "DHCP";
const DISCOVER_FLASH = "FLASH";
const DISCOVER_LOOKUP = "OpenLAN";
let ubus = libubus.connect();
let uci = libuci.cursor();
let state = DISCOVER;
let discovery_method = "";
let discovery_block_list = [];
let validate_time;
let offline_time;
let orphan_time;
let interval;
let timeouts = {
'offline': 4 * 60 * 60,
'validate': 120,
'orphan': 2 * 60 * 60,
interval: 10000,
expiry_interval: 60 * 60 * 1000,
expiry_threshold: 1 * 365 * 24 * 60 * 60,
};
ulog_open(ULOG_SYSLOG | ULOG_STDIO, LOG_DAEMON, "cloud_discover");
ulog(LOG_INFO, 'Start\n');
uloop.init();
let cds_server = 'discovery.open-lan.org';
function detect_certificate_type() {
let pipe = fs.popen(`openssl x509 -in /etc/ucentral/cert.pem -noout -issuer`);
let issuer = pipe.read("all");
pipe.close();
if (match(issuer, /OpenLAN Demo Birth CA/)) {
ulog(LOG_INFO, 'Certificate type is "Demo" \n');
cds_server = 'discovery-qa.open-lan.org';
timeouts.expiry_threshold = 3 * 24 * 60 * 60;
} else if (match(issuer, /OpenLAN Birth Issuing CA/)) {
ulog(LOG_INFO, 'Certificate type is "Production"\n');
} else {
ulog(LOG_INFO, 'Certificate type is "TIP"\n');
}
}
function readjsonfile(path) {
let file = fs.readfile(path);
if (file)
file = json(file);
return file;
}
function timeouts_load(){
let data = uci.get_all('ucentral', 'timeouts');
for (let key in [ 'offline', 'validate', 'orphan' ])
if (data && data[key])
timeouts[key] = +data[key];
let time_skew = timeouts.offline / 50 * (math.rand() % 50);
timeouts.offline_skew = timeouts.offline + time_skew;
ulog(LOG_INFO, 'Randomizing offline time from %d->%d \n', timeouts.offline, timeouts.offline_skew);
time_skew = timeouts.orphan / 50 * (math.rand() % 50);
timeouts.orphan_skew = timeouts.orphan + time_skew;
ulog(LOG_INFO, 'Randomizing orphan time from %d->%d \n', timeouts.orphan, timeouts.orphan_skew);
}
function client_start() {
ulog(LOG_INFO, '(re)starting client\n');
system('/etc/init.d/ucentral restart');
}
function dhcp_restart() {
ulog(LOG_INFO, 'restarting dhcp\n');
system('killall -USR1 udhcpc');
}
function ntp_restart() {
ulog(LOG_INFO, 'restarting ntp\n');
system('/etc/init.d/sysntpd restart');
}
function gateway_load() {
return readjsonfile('/etc/ucentral/gateway.json');
}
function discovery_state_write() {
if (length(discovery_method) == 0)
return;
let discovery_state = {
"type": discovery_method,
"updated": time()
};
fs.writefile('/etc/ucentral/discovery.state.json', discovery_state);
}
function gateway_write(data) {
let gateway = gateway_load();
gateway ??= {};
let new = {};
let changed = false;
for (let key in [ 'server', 'port', 'valid', 'hostname_validate' ]) {
if (exists(data, key))
new[key] = data[key];
else if (exists(gateway, key))
new[key] = gateway[key];
if (new[key] != gateway[key])
changed = true;
}
if (changed) {
fs.writefile('/etc/ucentral/gateway.json', new);
system('sync');
}
return changed;
}
function gateway_available() {
let gateway = gateway_load();
if (!gateway || !gateway.server || !gateway.port)
return false;
return true;
}
function set_state(set) {
if (state == set)
return;
let prev = state;
state = set;
switch(state) {
case DISCOVER:
ulog(LOG_INFO, 'Setting cloud to undiscovered\n');
fs.unlink('/tmp/cloud.json');
fs.unlink('/etc/ucentral/gateway.json');
gateway_write({ valid: false });
dhcp_restart();
break;
case VALIDATING:
ulog(LOG_INFO, 'Wait for validation\n');
validate_time = time();
state = VALIDATING;
push(discovery_block_list, discovery_method);
break;
case ONLINE:
ulog(LOG_INFO, 'Connected to cloud\n');
if (prev == VALIDATING) {
ulog(LOG_INFO, 'Setting cloud controller to validated\n');
gateway_write({ valid: true });
discovery_state_write();
discovery_block_list = [];
}
break;
case OFFLINE:
ulog(LOG_INFO, 'Lost connection to cloud\n');
offline_time = time();
break;
case ORPHAN:
ulog(LOG_INFO, 'Device is an orphan\n');
orphan_time = time();
break;
}
}
function discover_dhcp() {
let dhcp = readjsonfile('/tmp/cloud.json');
if (dhcp?.dhcp_server && dhcp?.dhcp_port) {
if (gateway_write({ server: dhcp.dhcp_server, port:dhcp.dhcp_port, valid: false, hostname_validate: dhcp.no_validation ? 0 : 1 })) {
ulog(LOG_INFO, `Discovered cloud via DHCP ${dhcp.dhcp_server}:${dhcp.dhcp_port}\n`);
client_start();
set_state(VALIDATING);
}
return true;
}
return !dhcp?.lease;
}
function redirector_lookup() {
const path = '/tmp/ucentral.redirector';
ulog(LOG_INFO, 'Contact redirector service\n');
let serial = uci.get('ucentral', 'config', 'serial');
fs.unlink(path);
system(`curl -k --cert /etc/ucentral/operational.pem --key /etc/ucentral/key.pem --cacert /etc/ucentral/operational.ca https://${cds_server}/v1/devices/${serial} --output /tmp/ucentral.redirector`);
if (!fs.stat(path))
return;
let redir = readjsonfile(path);
if (redir?.controller_endpoint) {
let controller_endpoint = split(redir.controller_endpoint, ':');
if (gateway_write({ server: controller_endpoint[0], port: controller_endpoint[1] || 15002, valid: false, hostname_validate: 1 })) {
ulog(LOG_INFO, `Discovered cloud via lookup service ${controller_endpoint[0]}:${controller_endpoint[1] || 15002}\n`);
client_start();
set_state(VALIDATING);
}
} else {
ulog(LOG_INFO, 'Failed to discover cloud endpoint\n');
}
}
function discover_flash() {
if (!fs.stat('/etc/ucentral/gateway.flash'))
return 1;
ulog(LOG_INFO, 'Using pre-populated cloud information\n');
fs.writefile('/etc/ucentral/gateway.json', fs.readfile('/etc/ucentral/gateway.flash'));
client_start();
set_state(VALIDATING);
return 0;
}
function time_is_valid() {
let valid = !!fs.stat('/tmp/ntp.set');
if (!valid)
ntp_restart();
ulog(LOG_INFO, `Time is ${valid ? '': 'not '}valid\n`);
return valid;
}
function is_discover_method_blacked() {
if (discovery_method in discovery_block_list)
return true;
return false;
}
function interval_handler() {
printf(`State ${state}\n`);
switch(state) {
case DISCOVER:
if (timeouts.interval < 60000)
timeouts.interval += 10000;
break;
default:
timeouts.interval = 10000;
break;
}
printf('setting interval to %d\n', timeouts.interval);
interval.set(timeouts.interval);
switch(state) {
case ORPHAN:
if (time() - orphan_time <= timeouts.orphan_skew)
break;
orphan_time = time();
/* fall through */
case DISCOVER:
ulog(LOG_INFO, 'Starting discover\n');
if (!time_is_valid())
return;
if (system('/usr/bin/est_client enroll'))
return;
discovery_method = DISCOVER_DHCP;
if (!is_discover_method_blacked() && discover_dhcp())
return;
discovery_method = DISCOVER_FLASH;
if (!is_discover_method_blacked() && !discover_flash())
return;
discovery_method = DISCOVER_LOOKUP;
redirector_lookup();
discovery_block_list = [];
break;
case VALIDATING:
if (time() - validate_time <= timeouts.validate)
break;
ulog(LOG_INFO, 'validation failed, restarting discovery process\n');
set_state(DISCOVER);
break;
case OFFLINE:
if (time() - offline_time <= timeouts.offline_skew)
break;
ulog(LOG_INFO, 'offline for too long, setting device as orphaned\n');
set_state(ORPHAN);
break;
}
}
function trigger_reenroll() {
ulog(LOG_INFO, 'triggering reenroll\n');
if (system('/usr/bin/est_client reenroll')) {
ulog(LOG_INFO, 'reenroll failed\n');
return;
}
ulog(LOG_INFO, 'reenroll succeeded\n');
ulog(LOG_INFO, 'stopping client\n');
system('/etc/init.d/ucentral stop');
set_state(DISCOVER);
}
function expiry_handler() {
let stat = fs.stat('/etc/ucentral/operational.ca');
if (!stat)
return;
let ret = system(`openssl x509 -checkend ${timeouts.expiry_threshold} -noout -in /certificates/operational.pem`);
if (!ret) {
ulog(LOG_INFO, 'checked certificate expiry - all ok\n');
return;
}
ulog(LOG_INFO, 'certificate will expire soon\n');
trigger_reenroll();
}
let ubus_methods = {
discover: {
call: function(req) {
set_state(DISCOVER);
return 0;
},
args: {
}
},
renew: {
call: function(req) {
if (state != ONLINE)
return;
ulog(LOG_INFO, 'Validate cloud due to DHCP renew event\n');
let gateway = gateway_load();
let cloud = readjsonfile('/tmp/cloud.json');
if (!cloud?.dhcp_server || !cloud?.dhcp_port)
return 0;
if (cloud.dhcp_server != gateway?.server || cloud.dhcp_port != gateway?.port)
set_state(DISCOVER);
else
ulog(LOG_INFO, 'Cloud has not changed\n');
},
args: {
}
},
online: {
call: function(req) {
set_state(ONLINE);
return 0;
},
args: {
}
},
offline: {
call: function(req) {
if (state == ONLINE)
set_state(OFFLINE);
return 0;
},
args: {
}
},
reload: {
call: function(req) {
timeouts_load();
return 0;
},
args: {
}
},
status: {
call: function(req) {
const names = [ 'discover', 'validate', 'online', 'offline', 'orphan' ];
let ret = { state: names[state] };
switch(state){
case OFFLINE:
ret.since = time() - offline_time;
break;
case ORPHAN:
ret.since = time() - orphan_time;
break;
case VALIDATING:
ret.since = time() - validate_time;;
break;
}
return ret;
},
args: {},
},
reenroll: {
call: function(req) {
trigger_reenroll();
return 0;
},
args: {},
},
};
detect_certificate_type();
if (gateway_available()) {
let status = ubus.call('ucentral', 'status');
ulog(LOG_INFO, 'cloud is known\n');
if (status?.connected) {
state = ONLINE;
} else {
client_start();
set_state(VALIDATING);
}
} else {
dhcp_restart();
}
timeouts_load();
interval = uloop.interval(timeouts.interval, interval_handler);
uloop.interval(timeouts.expiry_interval, expiry_handler);
ubus.publish('cloud', ubus_methods);
uloop.run();
uloop.done();

View File

@@ -0,0 +1,228 @@
#!/usr/bin/ucode
'use strict';
import { ulog_open, ulog, ULOG_SYSLOG, ULOG_STDIO, LOG_DAEMON, LOG_INFO } from 'log';
import * as fs from 'fs';
import * as libuci from 'uci';
let store_operational_pem = false;
let store_operational_ca = false;
let est_server = 'est.certificates.open-lan.org';
let cert_prefix = 'operational';
function set_est_server() {
let pipe = fs.popen(`openssl x509 -in /etc/ucentral/cert.pem -noout -issuer`);
let issuer = pipe.read("all");
pipe.close();
if (match(issuer, /OpenLAN Demo Birth CA/)) {
ulog(LOG_INFO, 'Certificate type is "Demo" \n');
est_server = 'qaest.certificates.open-lan.org:8001';
} else if (match(issuer, /OpenLAN Birth Issuing CA/)) {
ulog(LOG_INFO, 'Certificate type is "Production"\n');
} else {
ulog(LOG_INFO, 'Certificate type is "TIP"\n');
}
}
if (getenv('EST_SERVER'))
est_server = getenv('EST_SERVER');
if (getenv('CERT_PREFIX'))
cert_prefix = getenv('CERT_PREFIX');
ulog_open(ULOG_SYSLOG | ULOG_STDIO, LOG_DAEMON, "est_client");
function generate_csr(cert) {
if (!fs.stat('/tmp/csr.nohdr.p10')) {
let pipe = fs.popen(`openssl x509 -in ${cert} -noout -subject`);
let subject = pipe.read("all");
pipe.close();
subject = rtrim(subject);
subject = replace(subject, 'subject=', '/');
subject = replace(subject, ' = ', '=');
subject = replace(subject, ', ', '/');
let ret = system(`openssl req -subj "${subject}" -new -key /etc/ucentral/key.pem -out /tmp/csr.p10`);
if (ret) {
ulog(LOG_INFO, 'Failed to generate CSR\n');
return 1;
}
let input = fs.open('/tmp/csr.p10', 'r');
let output = fs.open('/tmp/csr.nohdr.p10', 'w');
let line;
while (line = input.read('line')) {
if (substr(line, 0, 4) == '----')
continue;
output.write(line);
}
input.close();
output.close();
ulog(LOG_INFO, 'Generated CSR\n');
}
return 0;
}
function store_operational_cert(path, target) {
system('mount_certs');
system(`cp ${path} /certificates/${target}`);
ulog(LOG_INFO, `Persistently stored ${target}\n`);
}
function p7_too_pem(src, dst) {
let input = fs.readfile(src);
let output = fs.open('/tmp/convert.p7', 'w');
output.write('-----BEGIN PKCS #7 SIGNED DATA-----\n');
output.write(`${input}\n-----END PKCS #7 SIGNED DATA-----`);
output.close();
let ret = system(`openssl pkcs7 -outform PEM -print_certs -in /tmp/convert.p7 -out ${dst}`);
if (ret) {
ulog(LOG_INFO, 'Failed to convert P7 to PEM\n');
return 1;
}
ulog(LOG_INFO, 'Converted P7 to PEM\n');
return 0;
}
function call_est_server(path, cert, target) {
if (generate_csr(cert))
return 1;
set_est_server();
let ret = system('curl -m 10 -X POST https://' + est_server + '/.well-known/est/' + path + ' -d @/tmp/csr.nohdr.p10 -H "Content-Type: application/pkcs10" --cert ' + cert + ' --key /etc/ucentral/key.pem --cacert /etc/ucentral/insta.pem -o /tmp/operational.nohdr.p7');
if (ret) {
ulog(LOG_INFO, 'Failed to request operational certificate\n');
return 1;
}
ulog(LOG_INFO, 'EST succeeded\n');
return p7_too_pem('/tmp/operational.nohdr.p7', target);
}
function simpleenroll() {
if (fs.stat('/etc/ucentral/' + cert_prefix + '.pem')) {
ulog(LOG_INFO, 'Operational certificate is present\n');
return 0;
}
if (call_est_server('simpleenroll', '/etc/ucentral/cert.pem', '/etc/ucentral/' + cert_prefix + '.pem'))
return 1;
ulog(LOG_INFO, 'Operational cert acquired\n');
store_operational_pem = true;
return 0;
}
function simplereenroll() {
if (!fs.stat('/etc/ucentral/' + cert_prefix + '.pem')) {
ulog(LOG_INFO, 'Operational certificate was not found\n');
return 0;
}
if (call_est_server('simplereenroll', '/etc/ucentral/' + cert_prefix + '.pem', '/tmp/' + cert_prefix + '.pem'))
return 1;
ulog(LOG_INFO, 'Operational cert updated\n');
store_operational_cert('/tmp/' + cert_prefix + '.pem', cert_prefix + '.pem');
system('cp /tmp/' + cert_prefix + '.pem /etc/ucentral/');
system('store_certs');
return 0;
}
function load_operational_ca() {
if (fs.stat('/etc/ucentral/' + cert_prefix + '.ca')) {
ulog(LOG_INFO, 'Operational CA is present\n');
return 0;
}
set_est_server();
let ret = system('curl -m 10 -X GET https://' + est_server + '/.well-known/est/cacerts --cert /etc/ucentral/' + cert_prefix + '.pem --key /etc/ucentral/key.pem --cacert /etc/ucentral/insta.pem -o /tmp/' + cert_prefix + '.ca.nohdr.p7');
if (!ret)
ret = p7_too_pem('/tmp/' + cert_prefix + '.ca.nohdr.p7', '/etc/ucentral/' + cert_prefix + '.ca');
if (ret) {
ulog(LOG_INFO, 'Failed to load CA\n');
return 1;
}
system('cat /etc/ucentral/openlan.pem >> /etc/ucentral/' + cert_prefix + '.ca');
ulog(LOG_INFO, 'Acquired CA\n');
store_operational_ca = true;
return 0;
}
function fwtool() {
if (!fs.stat('/etc/ucentral/cert.pem'))
return 0;
let pipe = fs.popen(`openssl x509 -in /etc/ucentral/cert.pem -noout -issuer`);
let issuer = pipe.read("all");
pipe.close();
if (!(match(issuer, /OpenLAN/) && match(issuer, /Birth/)))
return 0;
ulog(LOG_INFO, 'The issuer is insta\n');
let metadata = fs.readfile('/tmp/sysupgrade.meta');
if (metadata)
metadata = json(metadata);
if (!metadata)
return 0;
if (!metadata.est_supported) {
ulog(LOG_INFO, 'The image does not support EST\n');
return 1;
}
ulog(LOG_INFO, 'The image supports EST\n');
return 0;
}
function check_cert() {
if (!fs.stat('/etc/ucentral/cert.pem'))
return 0;
let pipe = fs.popen("openssl x509 -in /etc/ucentral/cert.pem -noout -subject -nameopt multiline | grep commonName | awk '{ print $3 }'");
let cn = pipe.read("all");
pipe.close();
if (!cn)
return 0;
cn = lc(trim(cn));
let uci = libuci.cursor();
let serial = uci.get('ucentral', 'config', 'serial');
return cn != serial;
}
switch(ARGV[0]) {
case 'enroll':
let ret = simpleenroll();
if (!ret)
ret = load_operational_ca();
if (store_operational_pem)
store_operational_cert('/etc/ucentral/' + cert_prefix + '.pem', cert_prefix + '.pem');
if (store_operational_ca)
store_operational_cert('/etc/ucentral/' + cert_prefix + '.ca', cert_prefix + '.ca');
if (store_operational_pem || store_operational_ca)
system('store_certs');
exit(ret);
case 'reenroll':
if (simplereenroll())
exit(1);
exit(0);
case 'fwtool':
exit(fwtool());
case 'check':
exit(check_cert());
}

View File

@@ -0,0 +1,45 @@
#!/usr/bin/ucode
import * as libubus from 'ubus';
import * as fs from 'fs';
let cmd = ARGV[0];
let ifname = getenv("interface");
let opt138 = fs.readfile('/tmp/dhcp-option-138');
let opt224 = fs.readfile('/tmp/dhcp-option-224');
if (cmd != 'bound' && cmd != 'renew')
exit(0);
/*let file = fs.readfile('/etc/ucentral/gateway.json');
if (file)
file = json(file);
file ??= {};
if (file.server && file.port && file.valid)
exit(0);
*/
let cloud = {
lease: true,
};
if (opt138) {
let dhcp = opt138;
dhcp = split(dhcp, ':');
cloud.dhcp_server = dhcp[0];
cloud.dhcp_port = dhcp[1] ?? 15002;
cloud.no_validation = true;
}
if (opt224) {
let dhcp = opt224;
dhcp = split(dhcp, ':');
cloud.dhcp_server = dhcp[0];
cloud.dhcp_port = dhcp[1] ?? 15002;
}
fs.writefile('/tmp/cloud.json', cloud);
if ((opt138 || opt224) && cmd == 'renew') {
let ubus = libubus.connect();
ubus.call('cloud', 'renew');
}
exit(0);