diff --git a/src/configs/bigbox-v1-cm-2.json b/src/configs/bigbox-v1-cm-2.json index 874545880..293ee7a66 100644 --- a/src/configs/bigbox-v1-cm-2.json +++ b/src/configs/bigbox-v1-cm-2.json @@ -111,6 +111,10 @@ { "name": "update", "root": "/data/devicecode/control/update" + }, + { + "name": "gsm", + "root": "/data/devicecode/control/gsm" } ], "time": {}, @@ -1267,6 +1271,11 @@ "metrics_interval": 60 } ] + }, + "apn_store": { + "kind": "control-store", + "id": "gsm", + "key": "custom-apns-v1" } } }, diff --git a/src/services/gsm.lua b/src/services/gsm.lua index fa0136ec6..cc6c765b5 100644 --- a/src/services/gsm.lua +++ b/src/services/gsm.lua @@ -27,7 +27,14 @@ local perform = fibers.perform local base = require 'devicecode.service_base' local cap_sdk = require 'services.hal.sdk.cap' +local bus_cleanup = require 'devicecode.support.bus_cleanup' local apns = require "services.gsm.apn" +local gsm_topics = require 'services.gsm.topics' +local apn_model = require 'services.gsm.apn_model' +local apn_store_control = require 'services.gsm.apn_store_control_store' +local tablex = require 'shared.table' + +local copy = tablex.deep_copy local REQUEST_TIMEOUT = 10 local DEFAULT_RETRY_TIMEOUT = 20 @@ -299,6 +306,10 @@ local function normalize_config(cfg) if modems_known ~= nil and type(modems_known) ~= 'table' then return {}, "config.modems.known must be a list" end + local apn_store = rawget(cfg, 'apn_store') + if apn_store ~= nil and not is_plain_table(apn_store) then + return {}, "config.apn_store must be a table" + end local out = shallow_copy(cfg) out.schema = nil return out, "" @@ -673,7 +684,7 @@ function GsmModem:_apn_connect() -- Get ranked APNs local rank_cutoff = tonumber(self.cfg.apn_rank_cutoff) or 4 - local ranked_apns, rankings = apns.get_ranked_apns(mcc, mnc, imsi, nil, gid1) + local ranked_apns, rankings = apns.get_ranked_apns(mcc, mnc, imsi, nil, gid1, self.svc and self.svc.custom_apns) -- Iterate through ranked APNs for _, ranking in ipairs(rankings) do @@ -921,12 +932,171 @@ function GsmService.start(conn, opts) local current_cfg = {} local config_ready = false + local custom_apns = {} + local apn_store = nil + + -- Transitional APN bridge. GSM is still on its pre-modern service + -- architecture; keep this surface deliberately small until GSM is migrated. + -- UI calls GSM-owned APN RPCs, GSM persists custom APNs through the HAL + -- control-store, and cfg/gsm only points at the store rather than being edited + -- by UI. The later GSM migration should move this to config_watch, capability + -- dependency gating and request_owner/scoped_work handling. ---@type table local modems = {} local parent_scope = fibers.current_scope() + local function apn_store_opts_from_config(cfg) + local spec = cfg and cfg.apn_store or nil + if spec ~= nil and type(spec) ~= 'table' then return nil, 'invalid_apn_store_config' end + spec = spec or {} + local kind = spec.kind or 'control-store' + if kind ~= 'control-store' then return nil, 'unsupported_apn_store_kind:' .. tostring(kind) end + return { + id = spec.id or spec.store_id or apn_store_control.DEFAULT_ID, + key = spec.key or apn_store_control.DEFAULT_KEY, + }, nil + end + + local function publish_apn_state(extra) + extra = extra or {} + local store_desc = apn_store and apn_store:describe() or nil + local payload = { + schema = 'devicecode.gsm.apns.custom/1', + count = #custom_apns, + records = apn_model.redact_list(custom_apns), + store = store_desc, + prototype_admin_network_access = true, + at = svc:wall(), + } + svc:_retain(gsm_topics.custom_apns_state(), payload) + svc:_retain(gsm_topics.apns_status_state(), { + schema = 'devicecode.gsm.apns.status/1', + state = extra.state or 'ready', + ready = extra.ready ~= false, + reason = extra.reason, + store = store_desc, + count = #custom_apns, + at = svc:wall(), + }) + end + + local function publish_gsm_capability() + svc:_retain(gsm_topics.cap_status('main'), { + schema = 'devicecode.cap.status/1', + state = 'available', + available = true, + methods = gsm_topics.apn_methods(), + }) + svc:_retain(gsm_topics.cap_meta('main'), { + schema = 'devicecode.cap.meta/1', + class = 'gsm', + id = 'main', + methods = gsm_topics.apn_methods(), + }) + end + + local function open_apn_store_for_config(cfg) + local opts, err = apn_store_opts_from_config(cfg) + if not opts then + apn_store = nil + custom_apns = {} + svc.custom_apns = custom_apns + publish_apn_state({ state = 'unavailable', ready = false, reason = err }) + return nil, err + end + apn_store = apn_store_control.new(conn, opts) + local records, lerr = perform(apn_store:load_op()) + if not records then + custom_apns = {} + svc.custom_apns = custom_apns + publish_apn_state({ state = 'degraded', ready = false, reason = lerr or 'apn_store_load_failed' }) + return nil, lerr or 'apn_store_load_failed' + end + custom_apns = records + svc.custom_apns = custom_apns + publish_apn_state({ state = 'ready', ready = true }) + return true, nil + end + + local function signal_all_modems() + for _, modem in pairs(modems) do + modem:_signal_config_change() + end + end + + local function replace_custom_apns(records) + if not apn_store then return nil, 'apn_store_unavailable' end + local list, lerr = apn_model.normalise_list(records) + if not list then return nil, lerr end + local saved, serr = perform(apn_store:save_op(list)) + if not saved then + publish_apn_state({ state = 'degraded', ready = false, reason = serr or 'apn_store_save_failed' }) + return nil, serr or 'apn_store_save_failed' + end + custom_apns = saved + svc.custom_apns = custom_apns + publish_apn_state({ state = 'ready', ready = true }) + signal_all_modems() + return copy(custom_apns), nil + end + + local function handle_apn_request(method, payload) + if method == 'list-custom-apns' then + return true, copy(custom_apns) + elseif method == 'replace-custom-apns' then + local records, perr = apn_model.list_from_payload(payload or {}) + if not records then return false, perr end + local saved, err = replace_custom_apns(records) + if not saved then return false, err end + return true, saved + elseif method == 'add-custom-apn' then + local rec, rerr = apn_model.normalise_record((payload and (payload.record or payload)) or {}) + if not rec then return false, rerr end + local next_list = copy(custom_apns) + next_list[#next_list + 1] = rec + local saved, err = replace_custom_apns(next_list) + if not saved then return false, err end + return true, saved + elseif method == 'delete-custom-apn' then + local idx = tonumber(payload and payload.index) + if not idx or idx < 1 or idx % 1 ~= 0 or idx > #custom_apns then return false, 'invalid_index' end + local next_list = copy(custom_apns) + table.remove(next_list, idx) + local saved, err = replace_custom_apns(next_list) + if not saved then return false, err end + return true, saved + end + return false, 'unsupported_apn_method:' .. tostring(method) + end + + local function bind_apn_endpoints() + local endpoints = {} + local function cleanup() + for _, ep in pairs(endpoints) do bus_cleanup.unbind(conn, ep) end + end + parent_scope:finally(cleanup) + for _, method in ipairs(gsm_topics.apn_methods()) do + local method_name = method + local ep, err = bus_cleanup.bind(conn, gsm_topics.rpc(method_name, 'main'), { queue_len = 16 }) + if not ep then return nil, err or ('bind_failed:' .. method_name) end + endpoints[method_name] = ep + local ok, spawn_err = parent_scope:spawn(function () + while true do + local req = perform(ep:recv_op()) + if req == nil then return end + local ok_req, value = handle_apn_request(method_name, req.payload) + if type(req.reply) == 'function' then + req:reply({ ok = ok_req == true, reason = value }) + end + end + end) + if not ok then return nil, spawn_err or ('apn_endpoint_spawn_failed:' .. method_name) end + end + return true, nil + end + parent_scope:finally(function() for _, modem in pairs(modems) do modem:stop(nil, true) @@ -1003,6 +1173,18 @@ function GsmService.start(conn, opts) end end + local store_ok, store_err = open_apn_store_for_config(current_cfg) + if not store_ok then + svc:obs_log('warn', { what = 'apn_store_unavailable', err = tostring(store_err) }) + end + + local endpoints_ok, endpoints_err = bind_apn_endpoints() + if not endpoints_ok then + svc:obs_log('error', { what = 'apn_endpoint_bind_failed', err = tostring(endpoints_err) }) + error(endpoints_err or 'apn_endpoint_bind_failed', 0) + end + publish_gsm_capability() + local modem_cap_sub = conn:subscribe({ 'cap', 'modem', '+', 'state' }) svc:obs_event('config_applied', {}) @@ -1060,6 +1242,10 @@ function GsmService.start(conn, opts) svc:obs_log('debug', { what = 'invalid_config', err = cfg_err }) else current_cfg = updated_cfg + local apn_reload_ok, apn_reload_err = open_apn_store_for_config(current_cfg) + if not apn_reload_ok then + svc:obs_log('warn', { what = 'apn_store_reload_failed', err = tostring(apn_reload_err) }) + end for id, modem in pairs(modems) do local modem_cfg, modem_name, _ = get_modem_config(current_cfg, id, modem.device) modem:apply_config(modem_cfg, modem_name) diff --git a/src/services/gsm/apn.lua b/src/services/gsm/apn.lua index d2036f52a..c1d9b241f 100644 --- a/src/services/gsm/apn.lua +++ b/src/services/gsm/apn.lua @@ -1,17 +1,30 @@ local binser = require "shared.binser" +local tablex = require "shared.table" + +local copy = tablex.deep_copy --- ask rich what these fellas do local g_authtypes = {} g_authtypes["0"] = "none" g_authtypes["1"] = "pap" g_authtypes["2"] = "chap" g_authtypes["3"] = "pap|chap" --- deserialise the apn database and return the apn for the sim +local function normalise_mnc(mnc) + if mnc == nil then return nil end + mnc = tostring(mnc) + if #mnc == 1 then return "0" .. mnc end + return mnc +end + +-- deserialise the bundled APN database and return APNs for the SIM. local function get_apns(mcc, mnc) - local apndb = binser.r("etc/apns")[1] - local apns = apndb[mcc][mnc] - return apns + local ok, apndb = pcall(function () return binser.r("etc/apns")[1] end) + if not ok or type(apndb) ~= 'table' then return {} end + local by_mcc = apndb[tostring(mcc or '')] + if type(by_mcc) ~= 'table' then return {} end + local apns = by_mcc[normalise_mnc(mnc) or tostring(mnc or '')] + if type(apns) ~= 'table' then return {} end + return copy(apns) end local function build_connection_string(apn, roaming_allow) @@ -21,7 +34,7 @@ local function build_connection_string(apn, roaming_allow) if k == "apn" then table.insert(a, "apn="..v) elseif k == "user" then table.insert(a, "user="..v) elseif k == "password" then table.insert(a, "password="..v) - elseif k == "authtype" then table.insert(a, "allowed-auth="..g_authtypes[v]) + elseif k == "authtype" and g_authtypes[tostring(v)] then table.insert(a, "allowed-auth="..g_authtypes[tostring(v)]) end end if roaming_allow then table.insert(a, "allow-roaming=true") end @@ -29,45 +42,96 @@ local function build_connection_string(apn, roaming_allow) return conn_string, nil end --- the connect function takes a list of apns and applies -local function rank(apns, imsi, spn, gid1) - -- first comes MVNO matches, next general MNO APNs, then generic "apn=internet", finally non-match MVNO - local rankings = {} - for k, v in pairs(apns) do - -- print("k is: ", k) - if v.mvno_type then - -- print("v is: ", v.mvno_type) - if v.mvno_type == "spn" and spn and string.find(spn, v.mvno_match_data) then - table.insert(rankings, {name=k, rank=1}) - elseif v.mvno_type == "gid" and gid1 and string.find(gid1, v.mvno_match_data) then - table.insert(rankings, {name=k, rank=1}) - elseif v.mvno_type == "imsi" and string.find(imsi, v.mvno_match_data) then - table.insert(rankings, {name=k, rank=1}) - else - table.insert(rankings, {name=k, rank=4}) - end +local function mvno_rank(apn, imsi, spn, gid1, ranks) + ranks = ranks or { match = 1, plain = 2, mismatch = 4 } + if apn.mvno_type then + local match_data = tostring(apn.mvno_match_data or '') + if apn.mvno_type == "spn" and spn and string.find(tostring(spn), match_data, 1, true) then + return ranks.match + elseif apn.mvno_type == "gid" and gid1 and string.find(tostring(gid1), match_data, 1, true) then + return ranks.match + elseif apn.mvno_type == "imsi" and imsi and string.find(tostring(imsi), match_data, 1, true) then + return ranks.match else - table.insert(rankings, {name=k, rank=2}) + return ranks.mismatch + end + end + return ranks.plain +end + +local function rank(apns, imsi, spn, gid1, prefix, ranks) + local out_apns = {} + local rankings = {} + prefix = prefix or '' + for k, v in pairs(apns or {}) do + if type(v) == 'table' then + local name = prefix .. tostring(k) + out_apns[name] = copy(v) + table.insert(rankings, { name = name, rank = mvno_rank(v, imsi, spn, gid1, ranks) }) end end - table.insert(apns, {default={apn='internet'}}) - table.insert(rankings, {name='default', rank=3}) - table.sort(rankings, function (k1, k2) return k1.rank < k2.rank end ) - return apns, rankings + return out_apns, rankings end -local function get_ranked_apns(mcc, mnc, imsi, spn, gid1) +local function custom_matches(apn, mcc, mnc) + if type(apn) ~= 'table' then return false end + return tostring(apn.mcc or '') == tostring(mcc or '') + and normalise_mnc(apn.mnc) == normalise_mnc(mnc) +end + +local function custom_apns_for(custom_records, mcc, mnc) + local out = {} + if type(custom_records) ~= 'table' then return out end + for i, apn in ipairs(custom_records) do + if custom_matches(apn, mcc, mnc) then + out['custom-' .. tostring(i)] = apn + end + end + return out +end + +local function add_default(apns, rankings) + apns.default = { apn = 'internet' } + table.insert(rankings, { name = 'default', rank = 4 }) +end + +local function merge_into(dst_apns, dst_rankings, src_apns, src_rankings) + for name, apn in pairs(src_apns or {}) do dst_apns[name] = apn end + for _, r in ipairs(src_rankings or {}) do dst_rankings[#dst_rankings + 1] = r end +end + +local function get_ranked_apns(mcc, mnc, imsi, spn, gid1, custom_records) if mnc == nil then return {}, {} end - if #mnc == 1 then mnc = "0"..mnc end - -- get apns from the network service - local apns = get_apns(mcc, mnc) - -- or read directly - -- local apns = binser.r("etc/apns")[1] - local rankapns, rankings = rank(apns, imsi, spn, gid1) - return rankapns, rankings + mnc = normalise_mnc(mnc) + + local ranked_apns, rankings = {}, {} + + local custom_map = custom_apns_for(custom_records, mcc, mnc) + local custom_apns, custom_rankings = rank(custom_map, imsi, spn, gid1, '', { + match = 1, + plain = 1, + mismatch = 5, + }) + merge_into(ranked_apns, rankings, custom_apns, custom_rankings) + + local builtin = get_apns(mcc, mnc) + local builtin_apns, builtin_rankings = rank(builtin, imsi, spn, gid1, 'builtin-', { + match = 2, + plain = 3, + mismatch = 5, + }) + merge_into(ranked_apns, rankings, builtin_apns, builtin_rankings) + + add_default(ranked_apns, rankings) + table.sort(rankings, function (k1, k2) + if k1.rank == k2.rank then return tostring(k1.name) < tostring(k2.name) end + return k1.rank < k2.rank + end) + return ranked_apns, rankings end return { get_ranked_apns = get_ranked_apns, - build_connection_string = build_connection_string + build_connection_string = build_connection_string, + get_apns = get_apns, } diff --git a/src/services/gsm/apn_model.lua b/src/services/gsm/apn_model.lua new file mode 100644 index 000000000..393f2010d --- /dev/null +++ b/src/services/gsm/apn_model.lua @@ -0,0 +1,139 @@ +-- services/gsm/apn_model.lua +-- +-- Pure validation/normalisation for user-supplied APN records. + +local tablex = require 'shared.table' + +local M = {} + +local copy = tablex.deep_copy + +local ALLOWED_KEYS = { + carrier = true, + mcc = true, + mnc = true, + apn = true, + type = true, + protocol = true, + roaming_protocol = true, + user_visible = true, + authtype = true, + mmsc = true, + mmsproxy = true, + mmsport = true, + proxy = true, + port = true, + bearer_bitmask = true, + read_only = true, + user = true, + password = true, + mvno_type = true, + mvno_match_data = true, + mtu = true, + ppp_number = true, + vivoentry = true, + server = true, + localized_name = true, + visit_area = true, + bearer = true, + profile_id = true, + modem_cognitive = true, + max_conns = true, + max_conns_time = true, + skip_464xlat = true, + carrier_enabled = true, + mtusize = true, + auth = true, +} + +local function trim(s) + return tostring(s or ''):gsub('^%s+', ''):gsub('%s+$', '') +end + +local function is_digits(s, n1, n2) + if type(s) ~= 'string' then return false end + if not s:match('^%d+$') then return false end + return #s >= n1 and #s <= n2 +end + +local function normalise_string(v) + if v == nil then return nil end + if type(v) ~= 'string' and type(v) ~= 'number' and type(v) ~= 'boolean' then return nil, 'field must be scalar' end + local s = trim(v) + if s == '' then return nil end + return s +end + +function M.normalise_record(rec) + if type(rec) ~= 'table' then return nil, 'apn record must be a table' end + local out = {} + for k, v in pairs(rec) do + if type(k) == 'string' and ALLOWED_KEYS[k] then + local nv, err = normalise_string(v) + if err then return nil, k .. ': ' .. err end + if nv ~= nil then out[k] = nv end + end + end + if type(out.carrier) ~= 'string' or out.carrier == '' then return nil, 'carrier is required' end + if not is_digits(out.mcc, 3, 3) then return nil, 'mcc must be three digits' end + if not is_digits(out.mnc, 1, 3) then return nil, 'mnc must be one to three digits' end + if type(out.apn) ~= 'string' or out.apn == '' then return nil, 'apn is required' end + if out.mvno_type ~= nil then + local mt = out.mvno_type:lower() + if mt ~= 'spn' and mt ~= 'gid' and mt ~= 'imsi' then return nil, 'mvno_type must be spn, gid or imsi' end + out.mvno_type = mt + if type(out.mvno_match_data) ~= 'string' or out.mvno_match_data == '' then + return nil, 'mvno_match_data is required when mvno_type is set' + end + end + return out, nil +end + +function M.normalise_list(records) + if records == nil then return {}, nil end + if type(records) ~= 'table' then return nil, 'apns must be a list' end + local out = {} + local seen = {} + for i, rec in ipairs(records) do + local item, err = M.normalise_record(rec) + if not item then return nil, 'apns[' .. tostring(i) .. ']: ' .. tostring(err) end + local key = table.concat({ item.carrier, item.mcc, item.mnc, item.apn }, '\0') + if seen[key] then return nil, 'duplicate APN: ' .. item.carrier .. '/' .. item.mcc .. '/' .. item.mnc .. '/' .. item.apn end + seen[key] = true + out[#out + 1] = item + end + return out, nil +end + +function M.list_from_payload(payload) + if type(payload) ~= 'table' then return nil, 'payload must be a table' end + if payload.records ~= nil then return M.normalise_list(payload.records) end + return M.normalise_list(payload) +end + +local SECRET_KEYS = { + user = true, + password = true, + auth = true, +} + +function M.redact_record(record) + local out = copy(record or {}) + for key in pairs(SECRET_KEYS) do + if out[key] ~= nil then + out[key] = nil + out['has_' .. key] = true + end + end + return out +end + +function M.redact_list(records) + local out = {} + for i, record in ipairs(records or {}) do + out[i] = M.redact_record(record) + end + return out +end + +return M diff --git a/src/services/gsm/apn_store_control_store.lua b/src/services/gsm/apn_store_control_store.lua new file mode 100644 index 000000000..4128b1b2c --- /dev/null +++ b/src/services/gsm/apn_store_control_store.lua @@ -0,0 +1,108 @@ +-- services/gsm/apn_store_control_store.lua +-- +-- Control-store-backed custom APN storage. GSM owns this adapter; UI routes call +-- GSM capabilities and never address the HAL control-store directly. + +local fibers = require 'fibers' +local op = require 'fibers.op' +local cjson = require 'cjson.safe' + +local cap_args = require 'services.hal.types.capability_args' +local apn_model = require 'services.gsm.apn_model' + +local M = {} + +local Store = {} +Store.__index = Store + +local DEFAULT_ID = 'gsm' +local DEFAULT_KEY = 'custom-apns-v1' + +local function rpc_topic(id, method) + return { 'cap', 'control-store', id or DEFAULT_ID, 'rpc', method } +end + +local function unwrap(method) + return function(reply, err) + if reply == nil then return nil, err end + if type(reply) ~= 'table' or type(reply.ok) ~= 'boolean' then return nil, 'invalid_control_store_reply' end + if reply.ok then return reply.reason, nil end + return nil, tostring(reply.reason or err or ('control_store_' .. method .. '_failed')) + end +end + +local function call_op(self, method, payload) + if type(self._conn) ~= 'table' or type(self._conn.call_op) ~= 'function' then + return op.always(nil, 'control_store_connection_required') + end + return self._conn:call_op(rpc_topic(self._id, method), payload or {}, self._call_opts) + :wrap(unwrap(method)) +end + +local function decode_records(body) + if body == nil then return {}, nil end + if type(body) ~= 'string' then return nil, 'custom_apns_body_not_string' end + local payload, derr = cjson.decode(body) + if type(payload) ~= 'table' then return nil, 'custom_apns_json_invalid:' .. tostring(derr) end + local records = payload.records or payload.apns or payload + return apn_model.normalise_list(records) +end + +function Store:load_op() + return fibers.run_scope_op(function () + local opts, oerr = cap_args.new.ControlStoreGetOpts(self._key) + if not opts then return nil, oerr or 'invalid_control_store_get_opts' end + local body, err = fibers.perform(call_op(self, 'get', opts)) + if body == nil then + if tostring(err or '') == 'not found' then return {}, nil end + return nil, err or 'control_store_get_failed' + end + return decode_records(body) + end):wrap(function(st, rep, records, err) + if st ~= 'ok' then return nil, tostring(records or err or rep) end + return records, err + end) +end + +function Store:save_op(records) + return fibers.run_scope_op(function () + local list, lerr = apn_model.normalise_list(records) + if not list then return nil, lerr end + local body, berr = cjson.encode({ + schema = 'devicecode.gsm.custom-apns/1', + records = list, + }) + if type(body) ~= 'string' then return nil, 'custom_apns_json_encode_failed:' .. tostring(berr) end + local opts, oerr = cap_args.new.ControlStorePutOpts(self._key, body) + if not opts then return nil, oerr or 'invalid_control_store_put_opts' end + local ok, err = fibers.perform(call_op(self, 'put', opts)) + if ok == nil then return nil, err or 'control_store_put_failed' end + return list, nil + end):wrap(function(st, rep, records, err) + if st ~= 'ok' then return nil, tostring(records or err or rep) end + return records, err + end) +end + +function Store:describe() + return { + kind = 'control-store', + id = self._id, + key = self._key, + } +end + +function M.new(conn, opts) + opts = opts or {} + return setmetatable({ + _conn = conn, + _id = opts.id or opts.store_id or DEFAULT_ID, + _key = opts.key or DEFAULT_KEY, + _call_opts = opts.call_opts, + }, Store) +end + +M.DEFAULT_ID = DEFAULT_ID +M.DEFAULT_KEY = DEFAULT_KEY +M.Store = Store +return M diff --git a/src/services/gsm/topics.lua b/src/services/gsm/topics.lua new file mode 100644 index 000000000..68c793c67 --- /dev/null +++ b/src/services/gsm/topics.lua @@ -0,0 +1,50 @@ +-- services/gsm/topics.lua +-- +-- Topic helpers for GSM-owned state and capability surfaces. + +local M = {} + +local function t(...) return { ... } end + +function M.config(name) + return t('cfg', name or 'gsm') +end + +function M.modem_state(name, field) + return t('state', 'gsm', 'modem', name, field) +end + +function M.uplink(name) + return t('state', 'gsm', 'uplink', name) +end + +function M.custom_apns_state() + return t('state', 'gsm', 'apns', 'custom') +end + +function M.apns_status_state() + return t('state', 'gsm', 'apns', 'status') +end + +function M.rpc(method, id) + return t('cap', 'gsm', id or 'main', 'rpc', method) +end + +function M.cap_status(id) + return t('cap', 'gsm', id or 'main', 'status') +end + +function M.cap_meta(id) + return t('cap', 'gsm', id or 'main', 'meta') +end + +function M.apn_methods() + return { + 'list-custom-apns', + 'replace-custom-apns', + 'add-custom-apn', + 'delete-custom-apn', + } +end + +return M diff --git a/src/services/ui.lua b/src/services/ui.lua index a9b6a4198..1f1e7b0e3 100644 --- a/src/services/ui.lua +++ b/src/services/ui.lua @@ -14,6 +14,7 @@ local M = { read_model_store = require 'services.ui.read_model_store', read_model_watches = require 'services.ui.read_model_watches', queries = require 'services.ui.queries', + local_model = require 'services.ui.local_model', user_operation = require 'services.ui.user_operation', http = { listener = require 'services.ui.http.listener', diff --git a/src/services/ui/http/listener.lua b/src/services/ui/http/listener.lua index c1ff2025a..04e8921a5 100644 --- a/src/services/ui/http/listener.lua +++ b/src/services/ui/http/listener.lua @@ -10,6 +10,7 @@ local fibers = require 'fibers' local resource = require 'devicecode.support.resource' local scoped_work = require 'devicecode.support.scoped_work' local http_sdk = require 'services.http'.sdk +local safe = require 'coxpcall' local M = {} @@ -53,7 +54,7 @@ end local function request_id_of(ctx, next_id) if type(ctx) == 'table' then if type(ctx.id) == 'function' then - local ok, id = pcall(function () return ctx:id() end) + local ok, id = safe.pcall(function () return ctx:id() end) if ok and id ~= nil then return id end end if ctx.id ~= nil then return ctx.id end diff --git a/src/services/ui/http/request.lua b/src/services/ui/http/request.lua index 7c7597a79..c804e1e9c 100644 --- a/src/services/ui/http/request.lua +++ b/src/services/ui/http/request.lua @@ -8,10 +8,12 @@ local routes = require 'services.ui.http.routes' local static = require 'services.ui.http.static' local sse = require 'services.ui.http.sse' local queries = require 'services.ui.queries' +local local_model = require 'services.ui.local_model' local auth = require 'services.ui.auth' local user_operation = require 'services.ui.user_operation' local upload = require 'services.ui.update.upload' local resource = require 'devicecode.support.resource' +local safe = require 'coxpcall' local ok_cjson, cjson = pcall(require, 'cjson.safe') if not ok_cjson then cjson = require 'cjson' end @@ -28,7 +30,7 @@ local function header_one(headers, name) if v ~= nil then return v end end if type(headers.get) == 'function' then - local ok, v = pcall(function () return headers:get(string.lower(name)) end) + local ok, v = safe.pcall(function () return headers:get(string.lower(name)) end) if ok and v ~= nil then return v end end if type(headers) == 'table' then @@ -154,6 +156,89 @@ local function handle_read(owner, route, deps) return { status = 'ok', route = 'read' } end + +local function handle_local_ui_bootstrap(owner, deps) + local model = assert(deps.model, 'local UI bootstrap requires model') + local result = local_model.bootstrap(model:snapshot()) + perform_response(owner:reply_json_op(200, result)) + return { status = 'ok', route = 'local_ui_bootstrap' } +end + +local function call_local_rpc(scope, topic, payload, deps, timeout) + return user_operation.run_op { + principal = { kind = 'service', id = 'ui-local', roles = { 'admin' } }, + conn = deps.conn, + connect = deps.connect, + bus = deps.bus, + disconnect_borrowed = false, + timeout = timeout or deps.command_timeout or 5.0, + run_op = function (_, conn) + return conn:call_op(topic, payload or {}, { timeout = false }) + :wrap(function (reply, call_err) + if reply == nil then return nil, call_err or 'upstream_failed' end + if type(reply) == 'table' and type(reply.ok) == 'boolean' then + if reply.ok then return { value = reply.reason }, nil end + return nil, tostring(reply.reason or call_err or 'upstream_failed') + end + return { value = reply }, nil + end) + end, + } +end + +local function handle_gsm_apns_get(scope, owner, deps) + local st, _rep, result_or_primary = fibers.perform(call_local_rpc( + scope, + { 'cap', 'gsm', 'main', 'rpc', 'list-custom-apns' }, + {}, + deps, + deps.apn_timeout or 5.0 + )) + if st ~= 'ok' then + perform_response(owner:reply_error_op(503, result_or_primary or 'gsm_apns_unavailable')) + return { status = 'failed', err = result_or_primary } + end + perform_response(owner:reply_json_op(200, result_or_primary.value or {})) + return { status = 'ok', route = 'gsm_apns_get' } +end + +local function handle_gsm_apns_put(scope, owner, ctx, deps) + local body, berr = json_body_table(ctx, deps, { require_json_content_type = true }) + if not body then + perform_response(owner:reply_error_op(nil, berr)) + return { status = 'bad_request', err = berr } + end + local st, _rep, result_or_primary = fibers.perform(call_local_rpc( + scope, + { 'cap', 'gsm', 'main', 'rpc', 'replace-custom-apns' }, + body, + deps, + deps.apn_timeout or 5.0 + )) + if st ~= 'ok' then + perform_response(owner:reply_error_op(400, result_or_primary or 'gsm_apns_update_failed')) + return { status = 'failed', err = result_or_primary } + end + perform_response(owner:reply_json_op(200, { ok = true, apns = result_or_primary.value or {} })) + return { status = 'ok', route = 'gsm_apns_put' } +end + +local function handle_diagnostics_stub(owner) + perform_response(owner:reply_json_op(200, { + schema = 'devicecode.diagnostics.stub/1', + stub = true, + diagnostics = { + bootstrap_installed = { expected = 0, installed = 0, missing = {} }, + modems_installed = { expected = 0, installed = 0, missing = {} }, + packages_installed = { expected = 0, installed = 0, missing = {} }, + packages_running = { expected = 0, installed = 0, missing = {} }, + services_running = { expected = 0, installed = 0, missing = {} }, + }, + diagnostics_logs = { 'Diagnostics service is not implemented in this build.' }, + })) + return { status = 'ok', route = 'diagnostics_stub' } +end + local function handle_login(owner, ctx, deps) local body, berr = json_body_table(ctx, deps, { require_json_content_type = false }) if not body then @@ -240,6 +325,14 @@ function M.run(scope, ctx, deps) if route.kind == 'read' then return handle_read(owner, route, deps) + elseif route.kind == 'local_ui_bootstrap' then + return handle_local_ui_bootstrap(owner, deps) + elseif route.kind == 'gsm_apns_get' then + return handle_gsm_apns_get(scope, owner, deps) + elseif route.kind == 'gsm_apns_put' then + return handle_gsm_apns_put(scope, owner, ctx, deps) + elseif route.kind == 'diagnostics_stub' then + return handle_diagnostics_stub(owner) elseif route.kind == 'login' then return handle_login(owner, ctx, deps) elseif route.kind == 'logout' then diff --git a/src/services/ui/http/response.lua b/src/services/ui/http/response.lua index dd68b02fc..d878d17ab 100644 --- a/src/services/ui/http/response.lua +++ b/src/services/ui/http/response.lua @@ -11,6 +11,7 @@ local fibers = require 'fibers' local op = require 'fibers.op' local errors = require 'services.ui.errors' local tablex = require 'shared.table' +local safe = require 'coxpcall' local ok_http_headers, http_headers = pcall(require, 'services.http.headers') if not ok_http_headers then http_headers = nil end @@ -38,7 +39,8 @@ local function terminate_ctx(ctx, reason) if not ctx then return true, nil end local fn = ctx.terminate or ctx.abandon_now if type(fn) ~= 'function' then return nil, 'response context has no terminate' end - local ok, err = fn(ctx, reason) + local called, ok, err = safe.pcall(fn, ctx, reason) + if not called then return nil, ok or 'response context termination failed' end if ok == false or ok == nil then return nil, err or 'response context termination failed' end return true, nil end diff --git a/src/services/ui/http/routes.lua b/src/services/ui/http/routes.lua index 81d6eac6b..b4daebe75 100644 --- a/src/services/ui/http/routes.lua +++ b/src/services/ui/http/routes.lua @@ -2,6 +2,8 @@ -- -- Pure route decoding for UI HTTP requests. +local safe = require 'coxpcall' + local M = {} local function split_path(path) @@ -15,7 +17,7 @@ end local function method_of(ctx) if ctx and type(ctx.method) == 'function' then - local ok, v = pcall(function () return ctx:method() end) + local ok, v = safe.pcall(function () return ctx:method() end) if ok and v ~= nil then return string.upper(tostring(v)) end end return string.upper(tostring((ctx and (ctx.method or ctx.verb)) or 'GET')) @@ -23,7 +25,7 @@ end local function path_of(ctx) if ctx and type(ctx.path) == 'function' then - local ok, v = pcall(function () return ctx:path() end) + local ok, v = safe.pcall(function () return ctx:path() end) if ok and v ~= nil then return v end end return (ctx and (ctx.path or ctx.uri)) or '/' @@ -54,6 +56,19 @@ function M.decode(ctx) if method == 'DELETE' or method == 'POST' then return { kind = 'logout' } end end + if parts[2] == 'local-ui' and parts[3] == 'bootstrap' and method == 'GET' then + return { kind = 'local_ui_bootstrap' } + end + + if parts[2] == 'gsm' and parts[3] == 'apns' and parts[4] == 'custom' then + if method == 'GET' then return { kind = 'gsm_apns_get' } end + if method == 'PUT' then return { kind = 'gsm_apns_put' } end + end + + if parts[2] == 'diagnostics' and method == 'GET' then + return { kind = 'diagnostics_stub' } + end + if parts[2] == 'state' and method == 'GET' then local topic = {} for i = 3, #parts do topic[#topic + 1] = parts[i] end diff --git a/src/services/ui/http/static.lua b/src/services/ui/http/static.lua index 362c9128d..e8f787a29 100644 --- a/src/services/ui/http/static.lua +++ b/src/services/ui/http/static.lua @@ -15,8 +15,14 @@ local TYPES = { ['.css'] = 'text/css', ['.js'] = 'application/javascript', ['.json'] = 'application/json', + ['.webmanifest'] = 'application/manifest+json', ['.png'] = 'image/png', ['.svg'] = 'image/svg+xml', + ['.ico'] = 'image/x-icon', + ['.webp'] = 'image/webp', + ['.woff'] = 'font/woff', + ['.woff2'] = 'font/woff2', + ['.ttf'] = 'font/ttf', ['.txt'] = 'text/plain', } @@ -40,8 +46,17 @@ end function M.run(scope, owner, route, opts) opts = opts or {} - local filename = clean_path(opts.root or '.', route.path) + local requested_path = route.path + local filename = clean_path(opts.root or '.', requested_path) local f, err = file_io.open(filename, 'r') + if not f then + local has_ext = tostring(requested_path or ''):match('/[^/]*%.[^/%.]+$') ~= nil + if opts.spa_fallback ~= false and not has_ext then + filename = clean_path(opts.root or '.', '/index.html') + f, err = file_io.open(filename, 'r') + if f then requested_path = '/index.html' end + end + end if not f then perform_required(owner:reply_error_op(404, 'not_found'), 'static not found response failed') return { status = 'not_found', path = route.path, err = err } @@ -76,7 +91,7 @@ function M.run(scope, owner, route, opts) perform_required(owner:end_stream_op(), 'static end write failed') file_owner:terminate_checked('done', 'static file cleanup') f = nil - return { status = 'ok', path = route.path, bytes = bytes } + return { status = 'ok', path = requested_path, bytes = bytes } end return M diff --git a/src/services/ui/local_model.lua b/src/services/ui/local_model.lua new file mode 100644 index 000000000..0b1672104 --- /dev/null +++ b/src/services/ui/local_model.lua @@ -0,0 +1,79 @@ +-- services/ui/local_model.lua +-- +-- Pure retained-state projection for the temporary Vue/Tailwind local UI. +-- This module deliberately selects a small allow-list of useful retained topics +-- rather than exposing the full /api/state model to ordinary local pages. + +local tablex = require 'shared.table' +local topicx = require 'shared.topic' +local topics = require 'services.ui.topics' + +local M = {} + +local copy = tablex.deep_copy +local starts_with = topicx.starts_with + +local ALLOW_PREFIXES = { + { 'svc' }, + { 'state', 'device' }, + { 'state', 'net' }, + { 'state', 'gsm' }, + { 'state', 'fabric' }, + { 'state', 'update' }, + { 'state', 'workflow', 'update-job' }, + { 'obs', 'v1', 'gsm', 'metric' }, + { 'obs', 'v1', 'gsm', 'event' }, +} + +local DENY_PREFIXES = { + { 'cfg' }, + { 'raw' }, + { 'state', 'ui' }, + { 'svc', 'ui' }, + { 'obs', 'v1', 'ui' }, +} + +local function allowed(topic) + for _, prefix in ipairs(DENY_PREFIXES) do + if starts_with(topic, prefix) then return false end + end + for _, prefix in ipairs(ALLOW_PREFIXES) do + if starts_with(topic, prefix) then return true end + end + return false +end + +local function sorted_items(snapshot) + local out = {} + for _, msg in pairs((snapshot and snapshot.items) or {}) do + if type(msg) == 'table' and type(msg.topic) == 'table' and allowed(msg.topic) then + out[#out + 1] = { + topic = copy(msg.topic), + payload = copy(msg.payload), + origin = copy(msg.origin), + } + end + end + table.sort(out, function(a, b) + return topics.topic_key(a.topic) < topics.topic_key(b.topic) + end) + return out +end + +function M.bootstrap(snapshot) + local out = {} + for _, msg in ipairs(sorted_items(snapshot)) do + out[topics.topic_string(msg.topic)] = msg + end + return { + schema = 'devicecode.ui.local-bootstrap/1', + version = snapshot and snapshot.version or 0, + items = out, + } +end + +M.allowed = allowed +M.ALLOW_PREFIXES = ALLOW_PREFIXES +M.DENY_PREFIXES = DENY_PREFIXES + +return M diff --git a/tests/integration/devhost/local_ui_http_spec.lua b/tests/integration/devhost/local_ui_http_spec.lua new file mode 100644 index 000000000..9e3ef7cf6 --- /dev/null +++ b/tests/integration/devhost/local_ui_http_spec.lua @@ -0,0 +1,129 @@ +-- tests/integration/devhost/local_ui_http_spec.lua +-- +-- Devhost coverage for the initial local UI port. These tests deliberately go +-- through curl and the real HTTP/UI/GSM services. Only the HAL control-store is +-- faked so that APN persistence can be exercised without hardware. + +local cjson = require 'cjson.safe' + +local runfibers = require 'tests.support.run_fibers' +local harness = require 'tests.support.local_ui_devhost' + +local T = {} + +local function fail(msg) error(msg or 'assertion failed', 2) end +local function assert_eq(a, b, msg) if a ~= b then fail((msg or 'values differ') .. ': expected ' .. tostring(b) .. ', got ' .. tostring(a)) end end +local function assert_true(v, msg) if v ~= true then fail(msg or ('expected true, got ' .. tostring(v))) end end +local function assert_not_nil(v, msg) if v == nil then fail(msg or 'expected non-nil') end return v end +local function assert_nil(v, msg) if v ~= nil then fail((msg or 'expected nil') .. ': got ' .. tostring(v)) end end + +local function devhost_port(offset) + local base = tonumber(os.getenv('LOCAL_UI_DEVHOST_PORT')) or 18120 + return base + (offset or 0) +end + +local function json_body(status, body) + assert_eq(status, '200', body) + local decoded, err = cjson.decode(body or '') + return assert_not_nil(decoded, 'expected JSON body, got: ' .. tostring(body) .. ' decode_err=' .. tostring(err)) +end + +function T.devhost_local_ui_serves_static_and_curated_bootstrap_over_real_http() + runfibers.run(function (scope) + local inst = harness.start(scope, { port = devhost_port(1), static_root = '../www' }) + harness.wait_http_ready(inst.base_url, { timeout = 5 }) + + local status, body = harness.curl({ + '--silent', '--show-error', '--max-time', '5', + '--write-out', '\n__HTTP_STATUS__:%{http_code}', + inst.base_url .. '/', + }) + assert_eq(status, '200') + assert_true(body:find('Big Box', 1, true) ~= nil, 'static UI should serve the local page') + + status, body = harness.curl({ + '--silent', '--show-error', '--max-time', '5', + '--write-out', '\n__HTTP_STATUS__:%{http_code}', + inst.base_url .. '/overview', + }) + assert_eq(status, '200') + assert_true(body:find('Big Box', 1, true) ~= nil, 'SPA fallback should serve index.html for browser routes') + + status, body = harness.curl({ + '--silent', '--show-error', '--max-time', '5', + '--write-out', '\n__HTTP_STATUS__:%{http_code}', + inst.base_url .. '/api/local-ui/bootstrap', + }) + local payload = json_body(status, body) + assert_eq(payload.schema, 'devicecode.ui.local-bootstrap/1') + assert_not_nil(payload.items['state/net/summary'], 'bootstrap should include curated network state') + assert_not_nil(payload.items['state/device/components'], 'bootstrap should include curated device state') + assert_nil(payload.items['raw/host/secret'], 'bootstrap must not include raw HAL topics') + assert_nil(payload.items['cfg/secret'], 'bootstrap must not include cfg topics') + end, { timeout = 12 }) +end + +function T.devhost_local_ui_apns_round_trip_through_gsm_and_fake_control_store() + runfibers.run(function (scope) + local inst = harness.start(scope, { port = devhost_port(2), static_root = '../www' }) + harness.wait_http_ready(inst.base_url, { timeout = 5 }) + + local apns = { + records = { + { + carrier = 'Demo Carrier', + mcc = '234', + mnc = '10', + apn = 'demo.internet', + user = 'clinic', + password = 'prototype-secret', + }, + }, + } + + local status, body = harness.curl_json('PUT', inst.base_url .. '/api/gsm/apns/custom', apns) + assert_eq(status, '200') + assert_true(body.ok, 'APN PUT should succeed') + assert_eq(body.apns[1].apn, 'demo.internet') + + status, body = harness.curl_json('GET', inst.base_url .. '/api/gsm/apns/custom') + assert_eq(status, '200') + assert_eq(body[1].carrier, 'Demo Carrier') + assert_eq(body[1].password, 'prototype-secret') + + local stored = assert_not_nil(inst.control_store:get('custom-apns-v1'), 'APN list should be persisted in fake control-store') + assert_true(stored:find('demo.internet', 1, true) ~= nil, 'persisted APN JSON should contain the APN') + + status, body = harness.curl_json('GET', inst.base_url .. '/api/local-ui/bootstrap') + assert_eq(status, '200') + local apn_state = assert_not_nil(body.items['state/gsm/apns/custom'], 'bootstrap should include GSM APN retained state after PUT') + assert_eq(apn_state.payload.count, 1) + assert_eq(apn_state.payload.records[1].apn, 'demo.internet') + assert_eq(apn_state.payload.records[1].password, nil) + assert_eq(apn_state.payload.records[1].user, nil) + assert_eq(apn_state.payload.records[1].has_password, true) + assert_eq(apn_state.payload.records[1].has_user, true) + + local saw_put = false + for _, call in ipairs(inst.control_store.calls) do + if call.method == 'put' and call.payload and call.payload.key == 'custom-apns-v1' then saw_put = true end + end + assert_true(saw_put, 'UI APN PUT should have reached GSM and then the control-store capability') + end, { timeout = 12 }) +end + +function T.devhost_local_ui_diagnostics_route_is_stubbed_for_now() + runfibers.run(function (scope) + local inst = harness.start(scope, { port = devhost_port(3), static_root = '../www' }) + harness.wait_http_ready(inst.base_url, { timeout = 5 }) + + local status, body = harness.curl_json('GET', inst.base_url .. '/api/diagnostics') + assert_eq(status, '200') + assert_eq(body.schema, 'devicecode.diagnostics.stub/1') + assert_true(body.stub, 'diagnostics should remain explicitly stubbed in this pass') + assert_not_nil(body.diagnostics, 'stub should preserve old diagnostics body shape') + assert_not_nil(body.diagnostics_logs, 'stub should preserve old diagnostics logs shape') + end, { timeout = 12 }) +end + +return T diff --git a/tests/run.lua b/tests/run.lua index c66198229..5ad3bd568 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -144,11 +144,15 @@ local files = { "unit.ui.test_supervision", "unit.ui.test_update_upload", "unit.ui.test_user_operation", + "unit.ui.test_local_ui", + "integration.devhost.local_ui_http_spec", 'unit.metrics.processing_spec', 'unit.metrics.config_spec', 'unit.metrics.senml_spec', 'unit.metrics.http_spec', 'integration.devhost.metrics_spec', + "unit.gsm.test_apn_model", + "unit.gsm.test_apn", "unit.net.test_architecture", "unit.net.test_config", "unit.net.test_intent_realiser", diff --git a/tests/support/fake_control_store.lua b/tests/support/fake_control_store.lua new file mode 100644 index 000000000..6b4a5116f --- /dev/null +++ b/tests/support/fake_control_store.lua @@ -0,0 +1,147 @@ +-- tests/support/fake_control_store.lua +-- +-- Tiny in-memory stand-in for HAL's curated control-store capability. It binds +-- cap/control-store//rpc/{get,put,delete,list} on the bus and records calls +-- so devhost tests can prove UI -> GSM -> control-store paths are used without +-- requiring a real HAL backend or filesystem. + +local fibers = require 'fibers' + +local M = {} +local Store = {} +Store.__index = Store + +local METHODS = { 'get', 'put', 'delete', 'list' } + +local function t(...) return { ... } end + +local function cap_status_topic(id) + return t('cap', 'control-store', id, 'status') +end + +local function cap_meta_topic(id) + return t('cap', 'control-store', id, 'meta') +end + +local function cap_state_topic(id) + return t('cap', 'control-store', id, 'state') +end + +local function cap_rpc_topic(id, method) + return t('cap', 'control-store', id, 'rpc', method) +end + +local function copy(v) + if type(v) ~= 'table' then return v end + local out = {} + for k, sv in pairs(v) do out[k] = copy(sv) end + return out +end + +local function sorted_keys(values, prefix) + local out = {} + prefix = prefix or '' + for key in pairs(values or {}) do + if prefix == '' or tostring(key):sub(1, #prefix) == prefix then + out[#out + 1] = key + end + end + table.sort(out) + return out +end + +function Store:new(opts) + opts = opts or {} + return setmetatable({ + id = opts.id or 'gsm', + values = copy(opts.values or {}), + calls = {}, + endpoints = {}, + }, Store) +end + +function Store:_reply(method, payload) + payload = payload or {} + local key = payload.key + self.calls[#self.calls + 1] = { method = method, payload = copy(payload) } + + if method == 'get' then + if type(key) ~= 'string' or key == '' then return { ok = false, reason = 'invalid key' } end + local body = self.values[key] + if body == nil then return { ok = false, reason = 'not found' } end + return { ok = true, reason = body } + elseif method == 'put' then + if type(key) ~= 'string' or key == '' then return { ok = false, reason = 'invalid key' } end + if type(payload.data) ~= 'string' then return { ok = false, reason = 'data must be a string' } end + self.values[key] = payload.data + return { ok = true, reason = true } + elseif method == 'delete' then + if type(key) ~= 'string' or key == '' then return { ok = false, reason = 'invalid key' } end + self.values[key] = nil + return { ok = true, reason = true } + elseif method == 'list' then + return { ok = true, reason = sorted_keys(self.values, payload.prefix) } + end + return { ok = false, reason = 'unsupported method: ' .. tostring(method) } +end + +function Store:start(conn, opts) + opts = opts or {} + local id = opts.id or self.id or 'gsm' + local scope = opts.scope + self.id = id + + conn:retain(cap_state_topic(id), 'added') + conn:retain(cap_status_topic(id), { + schema = 'devicecode.cap.status/1', + state = 'available', + available = true, + methods = METHODS, + }) + conn:retain(cap_meta_topic(id), { + schema = 'devicecode.cap.meta/1', + class = 'control-store', + id = id, + offerings = { get = true, put = true, delete = true, list = true }, + methods = METHODS, + fake = true, + }) + + for _, method in ipairs(METHODS) do + local method_name = method + local ep = assert(conn:bind(cap_rpc_topic(id, method_name), { queue_len = 32 })) + self.endpoints[method_name] = ep + + local function serve() + while true do + local req = fibers.perform(ep:recv_op()) + if req == nil then return end + req:reply(self:_reply(method_name, req.payload or {})) + end + end + + if scope and type(scope.spawn) == 'function' then + local ok, err = scope:spawn(serve) + if ok ~= true then error(err or 'fake control-store spawn failed', 0) end + else + fibers.spawn(serve) + end + end + return true +end + +function Store:get(key) + return self.values[key] +end + +function Store:put(key, value) + self.values[key] = value + return true +end + +function M.new(opts) + return Store:new(opts) +end + +M.Store = Store +return M diff --git a/tests/support/local_ui_devhost.lua b/tests/support/local_ui_devhost.lua new file mode 100644 index 000000000..5e5bec85e --- /dev/null +++ b/tests/support/local_ui_devhost.lua @@ -0,0 +1,236 @@ +-- tests/support/local_ui_devhost.lua +-- +-- Shared devhost harness for the initial local UI port. It composes the real +-- HTTP, UI and GSM services over an in-process bus, with a fake HAL +-- control-store capability for durable APN tests and demos. + +local busmod = require 'bus' +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' +local exec = require 'fibers.io.exec' +local cjson = require 'cjson.safe' +local safe = require 'coxpcall' + +local http_service = require 'services.http.service' +local ui_service = require 'services.ui.service' +local gsm_service = require 'services.gsm' +local fake_control_store_mod = require 'tests.support.fake_control_store' +local probe = require 'tests.support.bus_probe' + +local M = {} + +local function assert_spawn(scope, fn, label) + local ok, err = scope:spawn(fn) + if ok ~= true then error((label or 'spawn failed') .. ': ' .. tostring(err), 0) end + return true +end + +local function topic_key(parts) + local out = {} + for i = 1, #(parts or {}) do out[#out + 1] = tostring(parts[i]) end + return table.concat(out, '/') +end + +function M.ui_cfg(port, root) + return { + schema = 'devicecode.config/ui/1', + enabled = true, + http = { + enabled = true, + cap_id = 'main', + host = '127.0.0.1', + port = port, + max_active_requests = 8, + }, + static = { + root = root or '../www', + index = 'index.html', + chunk_size = 16384, + }, + sse = { enabled = true, replay = true, queue_len = 16 }, + sessions = { prune_interval = false }, + uploads = { enabled = false, require_auth = false }, + } +end + +function M.gsm_cfg(store_id) + return { + schema = 'devicecode.config/gsm/1', + apn_store = { + kind = 'control-store', + id = store_id or 'gsm', + key = 'custom-apns-v1', + }, + modems = { + default = { enabled = false, signal_freq = 60 }, + known = {}, + }, + } +end + +local function retain_config(conn, service, data, rev) + conn:retain({ 'cfg', service }, { + schema = 'devicecode.config.snapshot/1', + service = service, + rev = rev or 1, + data = data, + }) +end + +function M.publish_demo_state(conn) + conn:retain({ 'state', 'device', 'identity' }, { + schema = 'devicecode.device.identity/1', + serial = 'BBX-DEMO-0001', + model = 'Big Box devhost', + }) + conn:retain({ 'state', 'device', 'components' }, { + cm5 = { id = 'cm5', label = 'CM5', state = 'running' }, + mcu = { id = 'mcu', label = 'MCU', state = 'running' }, + }) + conn:retain({ 'state', 'net', 'summary' }, { + schema = 'devicecode.net.summary/1', + state = 'running', + wan = 'cellular', + }) + conn:retain({ 'state', 'net', 'wan_runtime' }, { + schema = 'devicecode.net.wan-runtime/1', + selected = 'gsm-main', + internet = true, + }) + conn:retain({ 'state', 'fabric', 'summary' }, { + schema = 'devicecode.fabric.summary/1', + state = 'running', + links = 0, + }) + conn:retain({ 'raw', 'host', 'secret' }, { should_not = 'be in local-ui bootstrap' }) + conn:retain({ 'cfg', 'secret' }, { should_not = 'be in local-ui bootstrap' }) +end + +function M.start(scope, opts) + opts = opts or {} + local port = opts.port or tonumber(os.getenv('LOCAL_UI_DEVHOST_PORT')) or 18089 + local static_root = opts.static_root or '../www' + local bus = opts.bus or busmod.new() + local control_store = opts.control_store or fake_control_store_mod.new({ id = opts.store_id or 'gsm' }) + + local config_conn = bus:connect({ origin_base = { kind = 'test', service = 'config' } }) + control_store:start(bus:connect({ origin_base = { kind = 'test', service = 'fake-hal-control-store' } }), { + id = opts.store_id or 'gsm', + scope = scope, + }) + retain_config(config_conn, 'gsm', M.gsm_cfg(opts.store_id or 'gsm'), 1) + retain_config(config_conn, 'ui', M.ui_cfg(port, static_root), 1) + if opts.demo_state ~= false then M.publish_demo_state(config_conn) end + + assert_spawn(scope, function (service_scope) + http_service.run(service_scope, { + conn = bus:connect({ origin_base = { service = 'http' } }), + id = 'main', + backend_timeout = 2, + connection_setup_timeout = 2, + intra_stream_timeout = 2, + max_accept_queue = 32, + }) + end, 'http service') + + assert_spawn(scope, function () + gsm_service.start(bus:connect({ origin_base = { service = 'gsm' } }), { + name = 'gsm', + env = 'dev', + heartbeat_s = 60, + }) + end, 'gsm service') + + assert_spawn(scope, function (service_scope) + ui_service.run(service_scope, { + conn = bus:connect({ origin_base = { service = 'ui' } }), + bus = bus, + connect = function () return bus:connect({ origin_base = { service = 'ui-request' } }) end, + service_id = 'ui', + config = M.ui_cfg(port, static_root), + read_model_opts = { queue_len = 128 }, + http_call_opts = { timeout = 3 }, + command_timeout = 3, + apn_timeout = 3, + encode_json = function (value) + local encoded, err = cjson.encode(value) + if type(encoded) ~= 'string' then error(err or 'json_encode_failed', 0) end + return encoded + end, + }) + end, 'ui service') + + local probe_conn = bus:connect({ origin_base = { kind = 'test', service = 'probe' } }) + probe.wait_retained_payload(probe_conn, { 'cap', 'http', 'main', 'status' }, { timeout = 2 }) + probe.wait_retained_payload(probe_conn, { 'cap', 'gsm', 'main', 'status' }, { timeout = 2 }) + + return { + bus = bus, + port = port, + base_url = ('http://127.0.0.1:%d'):format(port), + control_store = control_store, + config_conn = config_conn, + } +end + +local function parse_curl_output(out) + out = tostring(out or '') + local status = out:match('\n__HTTP_STATUS__:(%d+)%s*$') + local body = out:gsub('\n__HTTP_STATUS__:%d+%s*$', '') + return status, body +end + +function M.curl(args) + local cmd = exec.command('curl', unpack(args)) + local out, st, code, sig, err = fibers.perform(cmd:combined_output_op()) + if not (st == 'exited' and code == 0) then + error(('curl failed: status=%s code=%s signal=%s err=%s output=%s'):format( + tostring(st), tostring(code), tostring(sig), tostring(err), tostring(out) + ), 0) + end + return parse_curl_output(out) +end + +function M.curl_json(method, url, payload) + local args = { + '--silent', '--show-error', '--max-time', '5', + '--write-out', '\n__HTTP_STATUS__:%{http_code}', + '--request', method, + } + if payload ~= nil then + args[#args + 1] = '--header' + args[#args + 1] = 'Content-Type: application/json' + args[#args + 1] = '--data-binary' + args[#args + 1] = assert(cjson.encode(payload)) + end + args[#args + 1] = url + local status, body = M.curl(args) + local decoded = nil + if body ~= '' then + local err + decoded, err = cjson.decode(body) + if decoded == nil then + error(('expected JSON response from %s %s, got status=%s body=%q decode_err=%s'):format( + tostring(method), tostring(url), tostring(status), tostring(body), tostring(err) + ), 2) + end + end + return status, decoded, body +end + +function M.wait_http_ready(base_url, opts) + opts = opts or {} + local ok = probe.wait_until(function () + local success, ready = safe.pcall(function () + local st = M.curl({ '--silent', '--show-error', '--max-time', '2', '--output', '/dev/null', '--write-out', '\n__HTTP_STATUS__:%{http_code}', base_url .. '/api/local-ui/bootstrap' }) + return st == '200' + end) + return success == true and ready == true + end, { timeout = opts.timeout or 4, interval = 0.05 }) + if not ok then error('local UI HTTP endpoint did not become ready: ' .. tostring(base_url), 0) end + return true +end + +function M.topic_key(parts) return topic_key(parts) end + +return M diff --git a/tests/unit/gsm/test_apn.lua b/tests/unit/gsm/test_apn.lua new file mode 100644 index 000000000..100999712 --- /dev/null +++ b/tests/unit/gsm/test_apn.lua @@ -0,0 +1,25 @@ +local apn = require 'services.gsm.apn' + +local tests = {} +local function fail(msg) error(msg or 'assertion failed', 2) end +local function eq(a,b,msg) if a ~= b then fail(msg or ('expected '..tostring(b)..', got '..tostring(a))) end end +local function ok(v,msg) if not v then fail(msg or 'expected truthy') end end + +function tests.test_custom_apn_is_ranked_before_default() + local ranked, rankings = apn.get_ranked_apns('234', '10', '234100000000000', nil, nil, { + { carrier='Custom', mcc='234', mnc='10', apn='custom.net' }, + }) + ok(rankings[1]) + eq(rankings[1].name, 'custom-1') + eq(ranked['custom-1'].apn, 'custom.net') +end + +function tests.test_connection_string_uses_allowed_auth_when_present() + local s, err = apn.build_connection_string({ apn='internet', user='u', password='p', authtype='1' }, true) + ok(s, err) + ok(s:find('apn=internet', 1, true)) + ok(s:find('allowed-auth=pap', 1, true)) + ok(s:find('allow-roaming=true', 1, true)) +end + +return tests diff --git a/tests/unit/gsm/test_apn_model.lua b/tests/unit/gsm/test_apn_model.lua new file mode 100644 index 000000000..83f151fe3 --- /dev/null +++ b/tests/unit/gsm/test_apn_model.lua @@ -0,0 +1,41 @@ +local model = require 'services.gsm.apn_model' + +local tests = {} +local function fail(msg) error(msg or 'assertion failed', 2) end +local function eq(a,b,msg) if a ~= b then fail(msg or ('expected '..tostring(b)..', got '..tostring(a))) end end +local function ok(v,msg) if not v then fail(msg or 'expected truthy') end end + +function tests.test_normalise_record_requires_core_fields() + local rec, err = model.normalise_record({ carrier=' Test ', mcc='234', mnc='10', apn=' internet ' }) + ok(rec, err) + eq(rec.carrier, 'Test') + eq(rec.apn, 'internet') + local bad = model.normalise_record({ carrier='x', mcc='23', mnc='10', apn='internet' }) + if bad then fail('invalid MCC accepted') end +end + +function tests.test_normalise_list_rejects_duplicates() + local list, err = model.normalise_list({ + { carrier='A', mcc='234', mnc='10', apn='internet' }, + { carrier='A', mcc='234', mnc='10', apn='internet' }, + }) + if list then fail('duplicate accepted') end + ok(err and err:find('duplicate', 1, true)) +end + + +function tests.test_redact_list_removes_apn_secrets_but_preserves_presence_flags() + local redacted = model.redact_list({ + { carrier='A', mcc='234', mnc='10', apn='internet', user='alice', password='secret', auth='token' }, + }) + eq(redacted[1].carrier, 'A') + eq(redacted[1].apn, 'internet') + eq(redacted[1].user, nil) + eq(redacted[1].password, nil) + eq(redacted[1].auth, nil) + eq(redacted[1].has_user, true) + eq(redacted[1].has_password, true) + eq(redacted[1].has_auth, true) +end + +return tests diff --git a/tests/unit/ui/test_local_ui.lua b/tests/unit/ui/test_local_ui.lua new file mode 100644 index 000000000..d1ff9b03b --- /dev/null +++ b/tests/unit/ui/test_local_ui.lua @@ -0,0 +1,37 @@ +-- tests/unit/ui/test_local_ui.lua + +local routes = require 'services.ui.http.routes' +local read_model = require 'services.ui.read_model' +local local_model = require 'services.ui.local_model' + +local tests = {} + +local function fail(msg) error(msg or 'assertion failed', 2) end +local function eq(a,b,msg) if a ~= b then fail(msg or ('expected '..tostring(b)..', got '..tostring(a))) end end +local function ok(v,msg) if not v then fail(msg or 'expected truthy') end end + +local function ctx(method, path) + return { method = method, path = path } +end + +function tests.test_routes_decode_local_ui_and_apn_routes() + eq(routes.decode(ctx('GET', '/api/local-ui/bootstrap')).kind, 'local_ui_bootstrap') + eq(routes.decode(ctx('GET', '/api/gsm/apns/custom')).kind, 'gsm_apns_get') + eq(routes.decode(ctx('PUT', '/api/gsm/apns/custom')).kind, 'gsm_apns_put') + eq(routes.decode(ctx('GET', '/api/diagnostics')).kind, 'diagnostics_stub') +end + +function tests.test_local_model_allow_list_excludes_cfg_and_raw() + local model = read_model.new() + model:set({ 'state', 'net', 'summary' }, { ok = true }) + model:set({ 'state', 'gsm', 'apns', 'custom' }, { records = {} }) + model:set({ 'cfg', 'gsm' }, { secret = true }) + model:set({ 'raw', 'member', 'mcu' }, { secret = true }) + local boot = local_model.bootstrap(model:snapshot()) + ok(boot.items['state/net/summary']) + ok(boot.items['state/gsm/apns/custom']) + if boot.items['cfg/gsm'] then fail('cfg/gsm leaked into local-ui bootstrap') end + if boot.items['raw/member/mcu'] then fail('raw member leaked into local-ui bootstrap') end +end + +return tests diff --git a/tools/devhost/local-ui-demo.lua b/tools/devhost/local-ui-demo.lua new file mode 100644 index 000000000..fec00d03f --- /dev/null +++ b/tools/devhost/local-ui-demo.lua @@ -0,0 +1,79 @@ +#!/usr/bin/env lua +-- tools/devhost/local-ui-demo.lua +-- +-- Run the real HTTP, UI and GSM services on devhost with a fake HAL +-- control-store. This is intended for manual UI work: +-- +-- lua tools/devhost/local-ui-demo.lua --port 18089 +-- open http://127.0.0.1:18089/ +-- +-- APNs are persisted for this process in the in-memory fake control-store. + +package.path = 'src/?.lua;src/?/init.lua;' .. package.path +package.path = 'vendor/lua-fibers/src/?.lua;vendor/lua-fibers/src/?/init.lua;' .. package.path +package.path = 'vendor/lua-bus/src/?.lua;vendor/lua-bus/src/?/init.lua;' .. package.path +package.path = 'vendor/lua-trie/src/?.lua;vendor/lua-trie/src/?/init.lua;' .. package.path +package.path = './?.lua;./?/init.lua;tests/?.lua;tests/?/init.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' +local op = require 'fibers.op' +local harness = require 'tests.support.local_ui_devhost' + +local function arg_value(name, default) + for i = 1, #arg do + if arg[i] == name then return arg[i + 1] or default end + local v = tostring(arg[i]):match('^' .. name:gsub('%-', '%%-') .. '=(.+)$') + if v then return v end + end + return default +end + +local function has_flag(name) + for i = 1, #arg do if arg[i] == name then return true end end + return false +end + +if has_flag('--help') or has_flag('-h') then + io.write([[Big Box local UI devhost demo + +Usage: + lua tools/devhost/local-ui-demo.lua [--port 18089] [--static-root www] + +Starts: + - real services.http + - real services.ui + - real services.gsm + - fake HAL control-store capability for GSM APNs + +Then open: + http://127.0.0.1:/ + +Useful curl checks: + curl -fsS http://127.0.0.1:/api/local-ui/bootstrap | jq .schema + curl -fsS http://127.0.0.1:/api/gsm/apns/custom +]]) + os.exit(0) +end + +local port = tonumber(arg_value('--port', os.getenv('LOCAL_UI_DEVHOST_PORT') or '18089')) or 18089 +local static_root = arg_value('--static-root', 'www') + +fibers.run(function (scope) + local inst = harness.start(scope, { + port = port, + static_root = static_root, + demo_state = true, + }) + harness.wait_http_ready(inst.base_url, { timeout = 6 }) + + io.write(('Big Box local UI demo running at %s/\n'):format(inst.base_url)) + io.write('Using real HTTP/UI/GSM services and fake HAL control-store for APNs.\n') + io.write('Press Ctrl-C to stop.\n') + io.flush() + + fibers.perform(op.choice( + sleep.sleep_op(365 * 24 * 60 * 60), + scope:fault_op() + )) +end) diff --git a/www/app.js b/www/app.js new file mode 100644 index 000000000..43802febf --- /dev/null +++ b/www/app.js @@ -0,0 +1,125 @@ +const retained = new Map(); +const $ = sel => document.querySelector(sel); +const pretty = value => value == null ? 'Not reported' : JSON.stringify(value, null, 2); +const topicKey = topic => Array.isArray(topic) ? topic.join('/') : ''; + +async function json(path, opts) { + const res = await fetch(path, opts); + if (!res.ok) throw new Error(`${path}: ${res.status}`); + return res.json(); +} + +async function bootstrap() { + const body = await json('/api/local-ui/bootstrap', { cache: 'no-store' }); + retained.clear(); + for (const [key, item] of Object.entries(body.items || {})) retained.set(key, item); + renderOverview(); +} + +function connectEvents() { + const es = new EventSource('/events'); + const apply = ev => { + try { + const msg = JSON.parse(ev.data); + const key = topicKey(msg.topic); + if (!key) return; + const op = msg.op || msg.kind; + if (op === 'delete' || op === 'unretain') retained.delete(key); + else retained.set(key, { topic: msg.topic, payload: msg.payload, origin: msg.origin }); + renderOverview(); + } catch (err) { + console.error(err); + } + }; + for (const name of ['set', 'retain', 'delete', 'unretain']) es.addEventListener(name, apply); + es.onopen = () => { $('#connection').textContent = 'Live'; }; + es.onerror = () => { $('#connection').textContent = 'Offline'; }; +} + +function byPrefix(prefix) { + return [...retained.entries()].filter(([key]) => key === prefix || key.startsWith(`${prefix}/`)).map(([, item]) => item); +} + +function card(title, value) { + return `

${title}

${escapeHtml(pretty(value))}
`; +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"]/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch])); +} + +function renderOverview() { + const payload = key => retained.get(key)?.payload; + $('#cards').innerHTML = [ + card('Network summary', payload('state/net/summary')), + card('WAN runtime', payload('state/net/wan_runtime') || payload('state/net/wan')), + card('GSM', Object.fromEntries(byPrefix('state/gsm').map(item => [item.topic.join('/'), item.payload]))), + card('Updates', payload('state/update/summary')), + ].join(''); + + $('#components').innerHTML = byPrefix('state/device/component').map(item => ` +
${escapeHtml(item.topic.join('/'))}
${escapeHtml(pretty(item.payload))}
+ `).join('') || '

No component state reported.

'; +} + +async function loadApns() { + const tbody = $('#apn-table'); + $('#apn-status').textContent = 'Loading APNs...'; + try { + const records = await json('/api/gsm/apns/custom'); + window.__apns = Array.isArray(records) ? records : []; + tbody.innerHTML = window.__apns.map((apn, index) => ` + + ${escapeHtml(apn.carrier || '')} + ${escapeHtml(apn.mcc || '')} + ${escapeHtml(apn.mnc || '')} + ${escapeHtml(apn.apn || '')} + + + `).join(''); + $('#apn-status').textContent = `${window.__apns.length} custom APN record(s).`; + } catch (err) { + $('#apn-status').textContent = `APN service unavailable: ${err.message}`; + } +} + +async function saveApns(records) { + const res = await fetch('/api/gsm/apns/custom', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(records), + }); + if (!res.ok) throw new Error(await res.text()); + await loadApns(); +} + +$('#apn-form').addEventListener('submit', async ev => { + ev.preventDefault(); + const data = Object.fromEntries(new FormData(ev.currentTarget).entries()); + for (const key of Object.keys(data)) if (String(data[key]).trim() === '') delete data[key]; + try { + await saveApns([...(window.__apns || []), data]); + ev.currentTarget.reset(); + } catch (err) { + alert(`Could not save APN: ${err.message}`); + } +}); + +$('#apn-table').addEventListener('click', async ev => { + const button = ev.target.closest('button[data-remove]'); + if (!button) return; + const index = Number(button.dataset.remove); + const next = [...(window.__apns || [])]; + next.splice(index, 1); + try { await saveApns(next); } catch (err) { alert(`Could not remove APN: ${err.message}`); } +}); + +$('#run-diagnostics').addEventListener('click', async () => { + $('#diagnostics-result').textContent = 'Running...'; + try { $('#diagnostics-result').textContent = pretty(await json('/api/diagnostics')); } + catch (err) { $('#diagnostics-result').textContent = err.message; } +}); + +bootstrap().catch(err => { $('#connection').textContent = 'Error'; console.error(err); }); +connectEvents(); +loadApns(); diff --git a/www/index.html b/www/index.html new file mode 100644 index 000000000..bc1056c1e --- /dev/null +++ b/www/index.html @@ -0,0 +1,63 @@ + + + + + + + + + Big Box local UI + + +
+ Big Box + + Loading +
+ +
+
+

Local status

+

This page reads the device state from the local UI service and updates from retained events.

+
+

Components

+
+
+ +
+

Custom APNs

+

Prototype admin-network APN editor. Values are mediated by GSM and stored in the HAL control-store.

+
+
+ + + + + + + + + + +
+ + + +
CarrierMCCMNCAPN
+
+ +
+

Diagnostics

+

Diagnostics are stubbed in this build and will move to a separate service in a later pass.

+ +

+    
+
+ + + + diff --git a/www/manifest.webmanifest b/www/manifest.webmanifest new file mode 100644 index 000000000..958ef0d05 --- /dev/null +++ b/www/manifest.webmanifest @@ -0,0 +1,8 @@ +{ + "name": "Big Box Local UI", + "short_name": "Big Box", + "start_url": "/", + "display": "standalone", + "theme_color": "#6d5e9d", + "background_color": "#f7f7fa" +} diff --git a/www/style.css b/www/style.css new file mode 100644 index 000000000..7cf08eb36 --- /dev/null +++ b/www/style.css @@ -0,0 +1,21 @@ +:root { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #172033; background: #f7f7fa; } +body { margin: 0; } +.topbar { display: flex; align-items: center; gap: 1rem; padding: .85rem 1rem; background: #6d5e9d; color: white; position: sticky; top: 0; z-index: 1; box-shadow: 0 2px 8px rgb(0 0 0 / .18); } +.topbar nav { display: flex; gap: .75rem; flex: 1; } +.topbar a { color: white; text-decoration: none; opacity: .9; } +.topbar a:hover { opacity: 1; text-decoration: underline; } +.pill { border: 1px solid rgb(255 255 255 / .5); border-radius: 999px; padding: .2rem .55rem; font-size: .85rem; } +main { max-width: 72rem; margin: 0 auto; padding: 1rem; } +.panel { background: white; border: 1px solid #e1e3ea; border-radius: 1rem; padding: 1rem; margin: 1rem 0; box-shadow: 0 1px 4px rgb(0 0 0 / .06); } +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 1rem; } +.card, .item { border: 1px solid #e1e3ea; border-radius: .75rem; padding: .85rem; background: #fbfbfd; } +.card h3 { margin-top: 0; } +pre { white-space: pre-wrap; overflow-wrap: anywhere; background: #f2f3f7; padding: .75rem; border-radius: .5rem; max-height: 24rem; overflow: auto; } +.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: .75rem; margin: 1rem 0; } +input, button { font: inherit; border-radius: .5rem; border: 1px solid #cfd3df; padding: .65rem; } +button { background: #6d5e9d; color: white; border-color: #6d5e9d; cursor: pointer; } +button.secondary { background: white; color: #6d5e9d; } +table { border-collapse: collapse; width: 100%; } +th, td { text-align: left; border-bottom: 1px solid #e1e3ea; padding: .65rem; vertical-align: top; } +.notice { margin: .75rem 0; color: #4d4669; } +@media (prefers-color-scheme: dark) { :root { color: #f4f4f8; background: #20222b; } .panel, .card, .item { background: #282b35; border-color: #3a3f4e; } pre { background: #1d2028; } input { background: #1d2028; color: #f4f4f8; } }