{% /* * Part of AREDN® -- Used for creating Amateur Radio Emergency Data Networks * Copyright (C) 2024 Tim Wilkinson * See Contributors file for additional contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation version 3 of the License. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * * Additional Terms: * * Additional use restrictions exist on the AREDN® trademark and logo. * See AREDNLicense.txt for more info. * * Attributions to the AREDN® Project must be retained in the source code. * If importing this code into a new or existing project attribution * to the AREDN® project must be added to the source code. * * You must not misrepresent the origin of the material contained within. * * Modified versions must be modified to attribute to the original source * and be marked in reasonable ways as differentiate it from the original * version */ %} {% import * as config from "./config.uc"; import * as fs from "fs"; import * as math from "math"; import * as uci from "uci"; import * as ubus from "ubus"; import * as log from "log"; import * as zlib from "zlib"; import * as lucihttp from "lucihttp"; import * as configuration from "aredn.configuration"; import * as hardware from "aredn.hardware"; import * as lqm from "aredn.lqm"; import * as network from "aredn.network"; import * as olsr from "aredn.olsr"; import * as units from "aredn.units"; import * as radios from "aredn.radios"; import * as messages from "aredn.messages"; import * as mesh from "aredn.mesh"; import * as constants from "./constants.uc"; const pageCache = {}; const resourceVersions = {}; const lockfile = "/tmp/ui.lock"; log.openlog("uhttpd.aredn", log.LOG_PID, log.LOG_USER); fs.writefile(`${config.application}/resource/css/themes/default.css`, `${fs.readfile(`${config.application}/resource/css/themes/light.css`)}@media (prefers-color-scheme: dark) {\n${fs.readfile(`${config.application}/resource/css/themes/dark.css`)}\n}`); if (!fs.access(`${config.application}/resource/css/theme.css`)) { fs.symlink("themes/default.css", `${config.application}/resource/css/theme.css`); } if (config.preload) { function cp(path) { const dir = fs.opendir(`${config.application}${path}`); for (;;) { const entry = dir.read(); if (!entry) { break; } if (match(entry, /\.ut$/)) { const tpath = `${config.application}${path}${entry}`; pageCache[tpath] = loadfile(tpath, { raw_mode: false }); } } } cp("/main/"); cp("/partial/"); } if (config.resourcehash) { function prepareResource(id, resource) { const path = `${config.application}/resource/${resource}`; const pathgz = `${config.application}/resource/${resource}.gz`; if (fs.access(path)) { fs.unlink(pathgz); system(`/bin/gzip -k ${path}`); } const md = fs.popen(`/usr/bin/md5sum ${pathgz}`); resourceVersions[id] = match(md.read("all"), /^([0-9a-f]+)/)[1]; md.close(); const m = match(path, /^(.+)\.([a-z]+)$/); fs.symlink(pathgz, `${m[1]}.${resourceVersions[id]}.${m[2]}.gz`); } prepareResource("usercss", "css/user.css"); prepareResource("admincss", "css/admin.css"); prepareResource("mobilecss", "css/mobile.css"); prepareResource("themecss", "css/theme.css"); prepareResource("htmx", "js/htmx.min.js"); prepareResource("meshpage", "js/meshpage.js"); let cthemeversion = null; const ctheme = fs.readlink(`${config.application}/resource/css/theme.css`); const themes = fs.lsdir(`${config.application}/resource/css/themes`); for (let i = 0; i < length(themes); i++) { const theme = themes[i]; if (match(theme, /^.*\.css$/)) { prepareResource("themecss", `css/themes/${theme}`); if (ctheme === `themes/${theme}`) { cthemeversion = resourceVersions.themecss; } fs.unlink(`${config.application}/resource/css/theme.${resourceVersions.themecss}.css.gz`); fs.symlink(`themes/${replace(theme, /\.css$/, "")}.${resourceVersions.themecss}.css.gz`, `${config.application}/resource/css/theme.${resourceVersions.themecss}.css.gz`); } } resourceVersions.themecss = cthemeversion; fs.unlink(`${config.application}/resource/css/theme.version`); fs.symlink(cthemeversion, `${config.application}/resource/css/theme.version`); } else { let dir = fs.lsdir(`${config.application}/resource/css`); for (let i = 0; i < length(dir); i++) { if (match(dir[i], /\.gz$/)) { fs.unlink(`${config.application}/resource/css/${dir[i]}`); } } dir = fs.lsdir(`${config.application}/resource/css/themes`); for (let i = 0; i < length(dir); i++) { if (match(dir[i], /\.gz$/)) { fs.unlink(`${config.application}/resource/css/themes/${dir[i]}`); } } dir = fs.lsdir(`${config.application}/resource/js`); for (let i = 0; i < length(dir); i++) { if (match(dir[i], /\.gz$/) && dir[i] !== "htmx.min.js.gz") { fs.unlink(`${config.application}/resource/js/${dir[i]}`); } } fs.unlink(`${config.application}/resource/css/theme.version`); } fs.writefile(lockfile, ""); global._R = function(path, arg) { const tpath = `${config.application}/partial/${path}.ut`; const fn = pageCache[tpath] || loadfile(tpath, { raw_mode: false }); let old = inner; let r = ""; try { inner = arg; r = render(fn); } catch (_) { } inner = old; return r; }; global._H = function(str) { return includeHelp ? `
${str}
` : ""; }; const uciMethods = { init: function() { if (!cursor) { cursor = uci.cursor(); } }, add: function(a, b) { this.init(); cursor.load(a); return cursor.add(a, b); }, get: function(a, b, c) { this.init(); return cursor.get(a, b, c); }, set: function(a, b, c, d) { this.init(); if (d === undefined) { cursor.set(a, b, c); } else { if (type(d) === "array") { for (let i = 0; i < length(d); i++) { d[i] = replace(replace(`${d[i]}`, /['<>]/g, ""), /[\r\n]/g, " "); } } else { d = replace(replace(`${d}`, /['<>]/g, ""), /[\r\n]/g, " "); } cursor.set(a, b, c, d); } }, foreach: function(a, b, fn) { this.init(); cursor.foreach(a, b, fn); }, commit: function(a) { if (cursor) { cursor.commit(a); } }, "delete": function(a, b) { this.init(); cursor.delete(a, b); }, error: function() { if (cursor) { return cursor.error(); } } }; const uciMeshMethods = { init: function() { if (!cursorm) { cursorm = uci.cursor("/etc/config.mesh"); } }, load: function(a) { this.init(); cursorm.load(a); }, add: function(a, b) { this.init(); cursorm.load(a); return cursorm.add(a, b); }, get: function(a, b, c) { this.init(); return cursorm.get(a, b, c); }, set: function(a, b, c, d) { this.init(); if (d === undefined) { cursorm.set(a, b, c); } else { if (type(d) === "array") { for (let i = 0; i < length(d); i++) { d[i] = replace(replace(`${d[i]}`, /['<>]/g, ""), /[\r\n]/g, " "); } } else { d = replace(replace(`${d}`, /['<>]/g, ""), /[\r\n]/g, " "); } cursorm.set(a, b, c, d); } }, foreach: function(a, b, fn) { this.init(); cursorm.foreach(a, b, fn); }, commit: function(a) { if (cursorm) { cursorm.commit(a); } }, "delete": function(a, b) { this.init(); cursorm.delete(a, b); }, error: function() { if (cursorm) { return cursorm.error(); } } }; const auth = { isAdmin: false, key: null, age: 315360000, // 10 years DAYS: [ "", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" ], MONTHS: [ "", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ], initKey: function() { if (!this.key) { const f = fs.open("/etc/shadow"); if (f) { for (let l = f.read("line"); length(l); l = f.read("line")) { if (index(l, "root:") === 0) { this.key = trim(l); break; } } f.close(); } } }, runAuthentication: function(env) { const cookieheader = env.headers?.cookie; if (cookieheader) { const ca = split(cookieheader, ";"); for (let i = 0; i < length(ca); i++) { const cookie = trim(ca[i]); if (index(cookie, "authV1=") === 0) { this.initKey(); if (this.key == b64dec(substr(cookie, 7))) { this.isAdmin = true; } else { this.isAdmin = false; } break; } } } if (config.forceauth) { this.isAdmin = true; } }, authenticate: function(password) { if (!this.isAdmin) { this.initKey(); const s = split(this.key, /[:$]/); // s[3] = salt, s[4] = hashed const f = fs.popen(`exec /usr/bin/mkpasswd -S '${s[3]}' '${password}'`); if (f) { const pwd = rtrim(f.read("all")); f.close(); if (index(this.key, `root:${pwd}:`) === 0) { const time = clock(); const gm = gmtime(time[0] + this.age); const tm = `${this.DAYS[gm.wday]}, ${gm.mday} ${this.MONTHS[gm.mon]} ${gm.year} 00:00:00 GMT`; response.headers["Set-Cookie"] = `authV1=${b64enc(this.key)}; Path=/; Domain=${replace(request.headers.host, /:\d+$/, "")}; Expires=${tm}; SameSite=Strict`; this.isAdmin = true; } } } return this.isAdmin; }, deauthenticate: function() { if (this.isAdmin) { response.headers["Set-Cookie"] = `authV1=; Path=/; Domain=${replace(request.headers.host, /:\d+$/, "")}; Max-Age=0;`; this.isAdmin = false; } } }; const ubusMethods = { call: function(path, method) { if (!connection) { connection = ubus.connect(); } return connection.call(path, method); } }; const rePath = /^\/([-a-z]*)(.*)$/; global.handle_request = function(env) { const path = match(env.PATH_INFO, rePath); const page = path[1] || "status"; const secured = index(path[2], "/e/") === 0; if (path[2] == "" || secured) { let tpath; if (!configuration.isConfigured()) { tpath = `${config.application}/main/firstuse-ram.ut`; const f = fs.open("/proc/mounts"); if (f) { for (let l = f.read("line"); length(l); l = f.read("line")) { if (index(l, "overlay") !== -1 || index(l, "ext4") !== -1) { tpath = `${config.application}/main/firstuse.ut`; const mac2 = uci.cursor("/etc/local/uci").get("hsmmmesh", "settings", "mac2"); if (!match(mac2, /^\d+\.\d+\.\d+$/)) { tpath = `${config.application}/main/firstuse-start.ut`; } break; } } f.close(); } } else if (secured) { tpath = `${config.application}/main${env.PATH_INFO}.ut`; } else if ((env.HTTP_HOST === "localnode" || env.HTTP_HOST === "localnode.local.mesh") && env.REQUEST_URI == "/a/status" && !env.headers["hx-boosted"]) { uhttpd.send(`Status: 302 Found\r\nLocation: http://${configuration.getName()}.local.mesh\r\n\r\n`); return; } else { tpath = `${config.application}/main/${page}.ut`; if (!pageCache[tpath] && !fs.access(tpath)) { tpath = `${config.application}/main/app.ut`; } } if (pageCache[tpath] || fs.access(tpath)) { auth.runAuthentication(env); if (config.authenable && !auth.isAdmin) { if (secured) { uhttpd.send("Status: 401 Unauthorized\r\n\r\n"); return; } if (env.REQUEST_METHOD !== "GET" && configuration.isConfigured()) { uhttpd.send("Status: 401 Unauthorized\r\n\r\n"); return; } } let changelock = null; if (env.REQUEST_METHOD !== "GET") { changelock = fs.open(lockfile); if (!(changelock && changelock.lock("x"))) { uhttpd.send("Status: 500 Internal server error\r\n\r\n"); return; } } const args = {}; if (env.CONTENT_TYPE === "application/x-www-form-urlencoded") { let b = ""; for (;;) { const v = uhttpd.recv(10240); if (!length(v)) { break; } b += v; } const v = split(b, "&"); for (let i = 0; i < length(v); i++) { const kv = split(v[i], "="); const k = uhttpd.urldecode(kv[0]); if (!(k in args)) { args[k] = uhttpd.urldecode(kv[1]); } } } if (index(env.CONTENT_TYPE, "multipart/form-data") === 0) { let key; let val; let header; let file; let parser; parser = lucihttp.multipart_parser(env.CONTENT_TYPE, (what, buffer, length) => { switch (what) { case parser.PART_INIT: key = null; val = null; break; case parser.HEADER_NAME: header = lc(buffer); break; case parser.HEADER_VALUE: if (header === "content-disposition") { const filename = lucihttp.header_attribute(buffer, "filename"); key = lucihttp.header_attribute(buffer, "name"); file = { name: `/tmp/${key}`, filename: filename }; val = filename; } break; case parser.PART_BEGIN: if (file) { fs.writefile("/proc/sys/vm/drop_caches", "3"); file.fd = fs.open(file.name, "w"); return false } break; case parser.PART_DATA: if (file) { file.fd.write(buffer); } else { val = buffer; } break; case parser.PART_END: if (file) { file.fd.close(); file.fd = null; args[key] = file.name; } else if (key) { args[key] = val; } key = null; val = null; file = null; break; case parser.ERROR: log.syslog(log.LOG_ERR, `multipart error: ${buffer}`); break; } return true; }); for (;;) { const v = uhttpd.recv(10240); if (!length(v)) { parser.parse(null); break; } parser.parse(v); } } const response = { statusCode: 200, headers: { "Content-Type": "text/html", "Cache-Control": "no-store", "Access-Control-Allow-Origin": "*" } }; const fn = pageCache[tpath] || loadfile(tpath, { raw_mode: false }); const isMobile = !!(config.forcemobile || match(env.HTTP_USER_AGENT, /iphone/i) || match(env.HTTP_USER_AGENT, /android.*mobile/i)); let res = ""; try { res = render(call, fn, null, { config: config, constants: constants, versions: resourceVersions, request: { env: env, headers: env.headers, args: args, page: page, mobile: isMobile }, response: response, uci: uciMethods, uciMesh: uciMeshMethods, ubus: ubusMethods, auth: auth, includeHelp: (env.headers || {})["include-help"] === "1", includeAdvanced: (env.headers || {})["include-advanced"] === "1", fs: fs, configuration: configuration, hardware: hardware, lqm: lqm, network: network, olsr: olsr, units: units, radios: radios, math: math, log: log, messages: messages, mesh: mesh }); } catch (e) { log.syslog(log.LOG_ERR, `${e.message}\n${e.stacktrace[0].context}`); res = `
ERROR: ${e.message}
${e.stacktrace[0].context}
`; } if (!response.override) { let gzip = false; if (env.HTTP_ACCEPT_ENCODING && index(env.HTTP_ACCEPT_ENCODING, "gzip") !== -1 && config.compress) { gzip = true; res = zlib.deflate(res, true); } response.headers["Content-Length"] = `${length(res)}`; uhttpd.send( `Status: ${response.statusCode} OK\r\n`, (gzip ? `Content-Encoding: gzip\r\n` : ``), join("", map(keys(response.headers), k => k + ": " + response.headers[k] + "\r\n")), "\r\n", res ); } if (changelock) { changelock.close(); } if (response.reboot) { system(`(sleep 2; sync; ${response.reboot})&`); } return; } uhttpd.send("Status: 404 Not Found\r\n\r\n"); return; } const rpath = `${config.application}/resource/${env.PATH_INFO}`; const gzrpath = `${rpath}.gz`; const localnode = env.HTTP_HOST === "localnode" || env.HTTP_HOST === "localnode.local.mesh"; const cachecontrol = localnode || !config.resourcehash ? "no-store" : "max-age=604800"; if (localnode && config.resourcehash && fs.access(gzrpath)) { const themecss = fs.readlink(`${config.application}/resource/css/theme.version`); if (env.PATH_INFO !== `/css/theme.${themecss}.css` && index(env.PATH_INFO, "/css/theme.") === 0 && uciMethods.get("aredn", "@theme[0]", "portable") === "1") { uhttpd.send(`Status: 302 Found\r\nContent-Type: text/css\r\nCache-Control: no-store\r\nLocation: /a/css/theme.${themecss}.css\r\n\r\n`); return; } } if (fs.access(gzrpath)) { uhttpd.send("Status: 200 OK\r\nContent-Encoding: gzip\r\n"); if (substr(rpath, -3) === ".js") { uhttpd.send("Content-Type: application/javascript\r\n"); } else if (substr(rpath, -4) === ".css") { uhttpd.send("Content-Type: text/css\r\n"); } else if (substr(rpath, -4) === ".svg") { uhttpd.send("Content-Type: image/svg+xml\r\n"); } const res = fs.readfile(gzrpath); uhttpd.send(`Cache-Control: ${cachecontrol}\r\nContent-Length: ${length(res)}\n`); uhttpd.send("\r\n", res); return; } if (fs.access(rpath)) { uhttpd.send("Status: 200 OK\r\n"); if (substr(rpath, -3) === ".js") { uhttpd.send("Content-Type: application/javascript\r\n"); } else if (substr(rpath, -4) === ".png") { uhttpd.send("Content-Type: image/png\r\n"); } else if (substr(rpath, -4) === ".jpg") { uhttpd.send("Content-Type: image/jpeg\r\n"); } else if (substr(rpath, -4) === ".svg") { uhttpd.send("Content-Type: image/svg+xml\r\n"); } else if (substr(rpath, -4) === ".css") { uhttpd.send("Content-Type: text/css\r\n"); } const res = fs.readfile(rpath); uhttpd.send(`Cache-Control: ${cachecontrol}\r\nContent-Length: ${length(res)}\n`); uhttpd.send("\r\n", res); return; } uhttpd.send(`Status: 404 Not Found\r\nCache-Control: ${cachecontrol}\r\n\r\n`); }; %}