⛄ Sync 2026-09-26 23:11:03

This commit is contained in:
github-actions[bot] committed 2026-09-26 23:11:03 +08:00
1 parent a9ef1dfba0
commit 8b5f6dab51
19 files changed
+1326 -1077

No files matched your search

+2 -2
View File
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=gowebdav PKG_NAME:=gowebdav
PKG_VERSION:=5.16.0 PKG_VERSION:=5.16.1
PKG_RELEASE:=22 PKG_RELEASE:=23
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/hacdias/webdav/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/hacdias/webdav/tar.gz/v$(PKG_VERSION)?
+1 -1
View File
@@ -23,7 +23,7 @@ LUCI_DEPENDS:= \
PKG_NAME:=luci-app-homeproxy PKG_NAME:=luci-app-homeproxy
PKG_VERSION:=27.919.1.14 PKG_VERSION:=27.919.1.14
PKG_RELEASE:=17 PKG_RELEASE:=18
define Package/luci-app-homeproxy/conffiles define Package/luci-app-homeproxy/conffiles
/etc/config/homeproxy /etc/config/homeproxy
+3
View File
@@ -1,5 +1,8 @@
<div align="center"> <div align="center">
## 新增本仓库“架构重构”试验性产品luci-app-homeproxy-pro
(https://github.com/szwjp/luci-app-homeproxy-pro)
# luci-app-homeproxy # luci-app-homeproxy
**The modern ImmortalWrt proxy platform for ARM64 / AMD64** **The modern ImmortalWrt proxy platform for ARM64 / AMD64**
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.9.26 PKG_VERSION:=26.9.26
PKG_RELEASE:=293 PKG_RELEASE:=294
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
@@ -649,6 +649,7 @@ local current_node = map:get(section)
params += opt.query("congestion_control", dom_prefix + "tuic_congestion_control"); params += opt.query("congestion_control", dom_prefix + "tuic_congestion_control");
params += opt.query("udp_relay_mode", dom_prefix + "tuic_udp_relay_mode"); params += opt.query("udp_relay_mode", dom_prefix + "tuic_udp_relay_mode");
params += opt.query("allowinsecure", dom_prefix + "tls_allowInsecure"); params += opt.query("allowinsecure", dom_prefix + "tls_allowInsecure");
params += opt.query("pcs", dom_prefix + "tls_pinSHA256");
params += "#" + encodeURI(v_alias.value); params += "#" + encodeURI(v_alias.value);
if (params[0] == "&") { if (params[0] == "&") {
@@ -679,6 +680,7 @@ local current_node = map:get(section)
params += opt.query("alpn", dom_prefix + "alpn"); params += opt.query("alpn", dom_prefix + "alpn");
params += opt.query("sni", dom_prefix + "tls_serverName"); params += opt.query("sni", dom_prefix + "tls_serverName");
params += opt.query("insecure", dom_prefix + "tls_allowInsecure"); params += opt.query("insecure", dom_prefix + "tls_allowInsecure");
params += opt.query("pcs", dom_prefix + "tls_pinSHA256");
} }
params += "#" + encodeURI(v_alias.value); params += "#" + encodeURI(v_alias.value);
@@ -1705,6 +1707,7 @@ local current_node = map:get(section)
dom_prefix + 'tls_allowInsecure', dom_prefix + 'tls_allowInsecure',
!((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0' && (queryParam.allow_insecure ?? '0') === '0') !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0' && (queryParam.allow_insecure ?? '0') === '0')
); );
opt.set(dom_prefix + 'tls_pinSHA256', queryParam.pcs || queryParam.pinSHA256 || '');
if (hash) { if (hash) {
opt.set('remarks', decodeURIComponent(hash.substr(1))); opt.set('remarks', decodeURIComponent(hash.substr(1)));
} }
@@ -1737,29 +1740,23 @@ local current_node = map:get(section)
} }
queryParam.security = queryParam.security || "tls"; queryParam.security = queryParam.security || "tls";
if (queryParam.security) { if (queryParam.security) {
opt.set(dom_prefix + 'tls', true);
if (queryParam.security == "tls") { if (queryParam.security == "tls") {
opt.set(dom_prefix + 'tls', true);
opt.set(dom_prefix + 'reality', false); opt.set(dom_prefix + 'reality', false);
opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || queryParam.peer || '');
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
if (queryParam.fp && queryParam.fp.trim() != "") {
opt.set(dom_prefix + 'utls', true);
opt.set(dom_prefix + 'fingerprint', queryParam.fp);
}
} }
if (queryParam.security == "reality") { if (queryParam.security == "reality") {
opt.set(dom_prefix + 'tls', true);
opt.set(dom_prefix + 'reality', true); opt.set(dom_prefix + 'reality', true);
opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || queryParam.peer || '');
if (queryParam.fp && queryParam.fp.trim() != "") {
opt.set(dom_prefix + 'utls', true);
opt.set(dom_prefix + 'fingerprint', queryParam.fp);
}
opt.set(dom_prefix + 'reality_publicKey', queryParam.pbk || ''); opt.set(dom_prefix + 'reality_publicKey', queryParam.pbk || '');
opt.set(dom_prefix + 'reality_shortId', queryParam.sid || ''); opt.set(dom_prefix + 'reality_shortId', queryParam.sid || '');
} }
opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || queryParam.peer || '');
if (queryParam.fp && queryParam.fp.trim() != "") {
opt.set(dom_prefix + 'utls', true);
opt.set(dom_prefix + 'fingerprint', queryParam.fp);
}
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
opt.set(dom_prefix + 'tls_pinSHA256', queryParam.pcs || queryParam.pinSHA256 || '');
} }
if (m.hash) { if (m.hash) {
opt.set('remarks', decodeURIComponent(m.hash.substr(1))); opt.set('remarks', decodeURIComponent(m.hash.substr(1)));
@@ -448,6 +448,7 @@ local function encode_tuic(node)
if node["disable-sni"] then table.insert(p, "disable_sni=1") end if node["disable-sni"] then table.insert(p, "disable_sni=1") end
if node["skip-cert-verify"] then table.insert(p, "allowInsecure=1") end if node["skip-cert-verify"] then table.insert(p, "allowInsecure=1") end
if node["udp-relay-mode"] then table.insert(p, "udp_relay_mode=" .. node["udp-relay-mode"]) end if node["udp-relay-mode"] then table.insert(p, "udp_relay_mode=" .. node["udp-relay-mode"]) end
if node["fingerprint"] then table.insert(p, "pcs=" .. urlencode(node["fingerprint"])) end
if #p > 0 then if #p > 0 then
link = link .. "?" .. table.concat(p, "&") link = link .. "?" .. table.concat(p, "&")
@@ -483,11 +484,8 @@ local function encode_anytls(node)
if o.tls.alpn then table.insert(p, "alpn=" .. urlencode(o.tls.alpn)) end if o.tls.alpn then table.insert(p, "alpn=" .. urlencode(o.tls.alpn)) end
if o.tls.fp then table.insert(p, "fp=" .. urlencode(o.tls.fp)) end if o.tls.fp then table.insert(p, "fp=" .. urlencode(o.tls.fp)) end
if o.tls.ech then table.insert(p, "ech=" .. urlencode(o.tls.ech)) end if o.tls.ech then table.insert(p, "ech=" .. urlencode(o.tls.ech)) end
if o.tls.pcs then if o.tls.pcs then table.insert(p, "pcs=" .. urlencode(o.tls.pcs)) end
table.insert(p, "insecure=1") table.insert(p, "insecure=" .. (o.tls.insecure and "1" or "0"))
else
table.insert(p, "insecure=" .. (o.tls.insecure and "1" or "0"))
end
if #p > 0 then if #p > 0 then
link = link .. "?" .. table.concat(p, "&") link = link .. "?" .. table.concat(p, "&")
@@ -1560,6 +1560,7 @@ local function processData(szType, content, add_mode, group, sub_cfg)
end end
result.tls_serverName = params.sni result.tls_serverName = params.sni
result.tls_disable_sni = params.disable_sni result.tls_disable_sni = params.disable_sni
result.tls_pinSHA256 = params.pcs or params.pinsha256
result.tuic_alpn = params.alpn or "h3" result.tuic_alpn = params.alpn or "h3"
result.tuic_congestion_control = params.congestion_control or "cubic" result.tuic_congestion_control = params.congestion_control or "cubic"
result.tuic_udp_relay_mode = params.udp_relay_mode or "native" result.tuic_udp_relay_mode = params.udp_relay_mode or "native"
@@ -1615,6 +1616,7 @@ local function processData(szType, content, add_mode, group, sub_cfg)
if params.security == "tls" or params.security == "reality" then if params.security == "tls" or params.security == "reality" then
result.tls = "1" result.tls = "1"
result.tls_serverName = params.sni or params.peer result.tls_serverName = params.sni or params.peer
result.tls_pinSHA256 = params.pcs or params.pinsha256
result.alpn = params.alpn result.alpn = params.alpn
if params.fp and params.fp ~= "" then if params.fp and params.fp ~= "" then
result.utls = "1" result.utls = "1"
+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-qosmate PKG_NAME:=luci-app-qosmate
PKG_VERSION:=1.0.14 PKG_VERSION:=1.0.14
PKG_RELEASE:=4 PKG_RELEASE:=5
PKG_MAINTAINER:=Markus Hütter <mh@hudra.net> PKG_MAINTAINER:=Markus Hütter <mh@hudra.net>
PKG_LICENSE:=GPL-3.0-or-later PKG_LICENSE:=GPL-3.0-or-later
@@ -253,7 +253,7 @@ return view.extend({
if (headerRow) { if (headerRow) {
var headers = headerRow.querySelectorAll('th'); var headers = headerRow.querySelectorAll('th');
headers.forEach(function(th, index) { headers.forEach(function(th, index) {
if (th.textContent.includes('Activity')) { if (th.textContent.includes(_('Activity'))) {
activityColumnIndex = index; activityColumnIndex = index;
} }
@@ -253,6 +253,52 @@ var TimeSeriesChart = {
} }
}; };
// Statistics tables and charts are rendered with translated headings, so they
// have to be grouped into the Egress/Ingress tabs by comparing against the
// translations as well. A plain English keyword match such as
// title.includes('Egress') dropped every table and chart in a non-English UI.
var qosmateEgressHeadings = [
'CAKE Egress Statistics',
'CAKE Statistics for Default Class (Egress)',
'Egress Bytes Sent by Class',
'Egress Bytes Sent by Tin',
'Egress Dropped Packets by Class',
'Egress Dropped Packets by Tin',
'Egress Packets Sent by Class',
'Egress Packets Sent by Tin',
'HFSC Egress Class Statistics',
'HTB Egress Class Statistics',
'Hybrid Egress Class Statistics'
];
var qosmateIngressHeadings = [
'CAKE Ingress Statistics',
'CAKE Statistics for Default Class (Ingress)',
'HFSC Ingress Class Statistics',
'HTB Ingress Class Statistics',
'Hybrid Ingress Class Statistics',
'Ingress Bytes Sent by Class',
'Ingress Bytes Sent by Tin',
'Ingress Dropped Packets by Class',
'Ingress Dropped Packets by Tin',
'Ingress Packets Sent by Class',
'Ingress Packets Sent by Tin'
];
var isEgressHeading = function(heading) {
for (var i = 0; i < qosmateEgressHeadings.length; i++)
if (heading === _(qosmateEgressHeadings[i]))
return true;
return false;
};
var isIngressHeading = function(heading) {
for (var i = 0; i < qosmateIngressHeadings.length; i++)
if (heading === _(qosmateIngressHeadings[i]))
return true;
return false;
};
// Main view // Main view
return view.extend({ return view.extend({
pollInterval: 5, pollInterval: 5,
@@ -440,7 +486,7 @@ return view.extend({
// Create table for CAKE egress tins // Create table for CAKE egress tins
if (egressRows.length > 0) { if (egressRows.length > 0) {
result.tables.push(self.createStatsTable( result.tables.push(self.createStatsTable(
_('CAKE Egress Statistics - eth1'), _('CAKE Egress Statistics'),
[_('Tin'), _('Threshold'), _('Target'), _('Interval'), _('Peak Delay'), _('Avg Delay'), _('Sparse Delay'), _('Bytes'), _('Packets'), _('Dropped'), _('ECN Marked')], [_('Tin'), _('Threshold'), _('Target'), _('Interval'), _('Peak Delay'), _('Avg Delay'), _('Sparse Delay'), _('Bytes'), _('Packets'), _('Dropped'), _('ECN Marked')],
egressRows egressRows
)); ));
@@ -511,7 +557,7 @@ return view.extend({
// Create table for CAKE ingress tins // Create table for CAKE ingress tins
if (ingressRows.length > 0) { if (ingressRows.length > 0) {
result.tables.push(self.createStatsTable( result.tables.push(self.createStatsTable(
_('CAKE Ingress Statistics - eth1'), _('CAKE Ingress Statistics'),
[_('Tin'), _('Threshold'), _('Target'), _('Interval'), _('Peak Delay'), _('Avg Delay'), _('Sparse Delay'), _('Bytes'), _('Packets'), _('Dropped'), _('ECN Marked')], [_('Tin'), _('Threshold'), _('Target'), _('Interval'), _('Peak Delay'), _('Avg Delay'), _('Sparse Delay'), _('Bytes'), _('Packets'), _('Dropped'), _('ECN Marked')],
ingressRows ingressRows
)); ));
@@ -1621,9 +1667,9 @@ return view.extend({
var titleElement = cakeResults.tables[i].querySelector('h3, .table-title'); var titleElement = cakeResults.tables[i].querySelector('h3, .table-title');
if (titleElement) { if (titleElement) {
var title = titleElement.textContent || titleElement.innerText; var title = titleElement.textContent || titleElement.innerText;
if (title.includes('Egress')) { if (isEgressHeading(title)) {
egressTable = cakeResults.tables[i]; egressTable = cakeResults.tables[i];
} else if (title.includes('Ingress')) { } else if (isIngressHeading(title)) {
ingressTable = cakeResults.tables[i]; ingressTable = cakeResults.tables[i];
} }
} }
@@ -1649,9 +1695,9 @@ return view.extend({
var chartTitle = cakeResults.charts[i].querySelector('h3'); var chartTitle = cakeResults.charts[i].querySelector('h3');
var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : ''; var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : '';
if (titleText.includes('Egress')) { if (isEgressHeading(titleText)) {
egressCharts.push(cakeResults.charts[i]); egressCharts.push(cakeResults.charts[i]);
} else if (titleText.includes('Ingress')) { } else if (isIngressHeading(titleText)) {
ingressCharts.push(cakeResults.charts[i]); ingressCharts.push(cakeResults.charts[i]);
} }
} }
@@ -1701,9 +1747,9 @@ return view.extend({
var titleElement = hfscResults.tables[i].querySelector('h3, .table-title'); var titleElement = hfscResults.tables[i].querySelector('h3, .table-title');
if (titleElement) { if (titleElement) {
var title = titleElement.textContent || titleElement.innerText; var title = titleElement.textContent || titleElement.innerText;
if (title.includes('Egress')) { if (isEgressHeading(title)) {
egressTable = hfscResults.tables[i]; egressTable = hfscResults.tables[i];
} else if (title.includes('Ingress')) { } else if (isIngressHeading(title)) {
ingressTable = hfscResults.tables[i]; ingressTable = hfscResults.tables[i];
} }
} }
@@ -1729,9 +1775,9 @@ return view.extend({
var chartTitle = hfscResults.charts[i].querySelector('h3'); var chartTitle = hfscResults.charts[i].querySelector('h3');
var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : ''; var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : '';
if (titleText.includes('Egress')) { if (isEgressHeading(titleText)) {
egressCharts.push(hfscResults.charts[i]); egressCharts.push(hfscResults.charts[i]);
} else if (titleText.includes('Ingress')) { } else if (isIngressHeading(titleText)) {
ingressCharts.push(hfscResults.charts[i]); ingressCharts.push(hfscResults.charts[i]);
} }
} }
@@ -1783,13 +1829,13 @@ return view.extend({
var titleElement = hybridResults.tables[i].querySelector('h3, .table-title'); var titleElement = hybridResults.tables[i].querySelector('h3, .table-title');
if (titleElement) { if (titleElement) {
var title = titleElement.textContent || titleElement.innerText; var title = titleElement.textContent || titleElement.innerText;
if (title.includes('Hybrid Egress Class')) { if (title === _('Hybrid Egress Class Statistics')) {
egressClassTable = hybridResults.tables[i]; egressClassTable = hybridResults.tables[i];
} else if (title.includes('Hybrid Ingress Class')) { } else if (title === _('Hybrid Ingress Class Statistics')) {
ingressClassTable = hybridResults.tables[i]; ingressClassTable = hybridResults.tables[i];
} else if (title.includes('CAKE') && title.includes('Egress')) { } else if (title === _('CAKE Statistics for Default Class (Egress)')) {
cakeEgressTable = hybridResults.tables[i]; cakeEgressTable = hybridResults.tables[i];
} else if (title.includes('CAKE') && title.includes('Ingress')) { } else if (title === _('CAKE Statistics for Default Class (Ingress)')) {
cakeIngressTable = hybridResults.tables[i]; cakeIngressTable = hybridResults.tables[i];
} }
} }
@@ -1820,9 +1866,9 @@ return view.extend({
var chartTitle = hybridResults.charts[i].querySelector('h3'); var chartTitle = hybridResults.charts[i].querySelector('h3');
var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : ''; var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : '';
if (titleText.includes('Egress')) { if (isEgressHeading(titleText)) {
egressCharts.push(hybridResults.charts[i]); egressCharts.push(hybridResults.charts[i]);
} else if (titleText.includes('Ingress')) { } else if (isIngressHeading(titleText)) {
ingressCharts.push(hybridResults.charts[i]); ingressCharts.push(hybridResults.charts[i]);
} }
} }
@@ -1872,9 +1918,9 @@ return view.extend({
var titleElement = htbResults.tables[i].querySelector('h3, .table-title'); var titleElement = htbResults.tables[i].querySelector('h3, .table-title');
if (titleElement) { if (titleElement) {
var title = titleElement.textContent || titleElement.innerText; var title = titleElement.textContent || titleElement.innerText;
if (title.includes('HTB Egress Class')) { if (title === _('HTB Egress Class Statistics')) {
egressClassTable = htbResults.tables[i]; egressClassTable = htbResults.tables[i];
} else if (title.includes('HTB Ingress Class')) { } else if (title === _('HTB Ingress Class Statistics')) {
ingressClassTable = htbResults.tables[i]; ingressClassTable = htbResults.tables[i];
} }
} }
@@ -1898,9 +1944,9 @@ return view.extend({
var chartTitle = htbResults.charts[i].querySelector('h3'); var chartTitle = htbResults.charts[i].querySelector('h3');
var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : ''; var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : '';
if (titleText.includes('Egress')) { if (isEgressHeading(titleText)) {
egressCharts.push(htbResults.charts[i]); egressCharts.push(htbResults.charts[i]);
} else if (titleText.includes('Ingress')) { } else if (isIngressHeading(titleText)) {
ingressCharts.push(htbResults.charts[i]); ingressCharts.push(htbResults.charts[i]);
} }
} }
File diff suppressed because it is too large. Load diff
@@ -161,9 +161,11 @@
text-align: end !important; text-align: end !important;
} }
/* meter rows — any stock .table row with a .cbi-progressbar. docs/css.md above. */ /* meter rows — a key/value-shaped .table row with a .cbi-progressbar (same shape test as
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)):has(.cbi-progressbar) { display: block; } * :218 below and 30-tables.css's key/value rule); a data table's own bar stays untouched.
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) { * docs/css.md above. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))):has(.cbi-progressbar) { display: block; }
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: var(--fs-space-1) var(--fs-space-2-5); gap: var(--fs-space-1) var(--fs-space-2-5);
@@ -172,8 +174,8 @@
border: 0; border: 0;
position: relative; position: relative;
} }
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar):last-child { padding-bottom: var(--fs-space-0-5); } :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar):last-child { padding-bottom: var(--fs-space-0-5); }
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td { :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td {
display: block; display: block;
/* beats base's .td[width="33%"] with no flag: that sets a width ATTRIBUTE, a later layer wins */ /* beats base's .td[width="33%"] with no flag: that sets a width ATTRIBUTE, a later layer wins */
width: auto; width: auto;
@@ -182,7 +184,7 @@
} }
/* label — undoes key/value nowrap: a mount point is one unbreakable line past the card. /* label — undoes key/value nowrap: a mount point is one unbreakable line past the card.
* padding-inline-end is the gutter for the out-of-flow value (::after over the bar's edge). */ * padding-inline-end is the gutter for the out-of-flow value (::after over the bar's edge). */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:first-child { :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:first-child {
grid-column: 1; grid-row: 1; grid-column: 1; grid-row: 1;
align-self: center; align-self: center;
min-width: 0; min-width: 0;
@@ -191,32 +193,40 @@
font-size: var(--fs-type); font-size: var(--fs-type);
} }
/* value rides the label's own line at desktop (::after floats over the row's top edge) —
* opt out of the bare-meter default reserve. <=560px below restores it (own line, no room). */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .cbi-progressbar {
margin-block-start: 0;
}
/* narrow card: the value takes its own line, the reserve above cannot fit it — docs/css.md /* narrow card: the value takes its own line, the reserve above cannot fit it — docs/css.md
* above has the 218px/190px measurement. */ * above has the 218px/190px measurement. */
@container fs-view (max-width: 560px) { @container fs-view (max-width: 560px) {
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:first-child { :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:first-child {
padding-inline-end: 0; padding-inline-end: 0;
} }
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .cbi-progressbar { :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .cbi-progressbar {
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading)); margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
} }
} }
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:nth-child(2) { :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:nth-child(2) {
grid-column: 1 / -1; grid-row: 2; grid-column: 1 / -1; grid-row: 2;
} }
/* per-row dividers dropped, keyed on table SHAPE (no header row) not on the three named cards — /* per-row dividers dropped, keyed on table SHAPE (no .th, no 3rd .td — same test as
* docs/css.md above. A DATA table (.tr.table-titles present) keeps its dividers. */ * 30-tables.css's key/value rule, docs/css.md "The key/value discriminator") not on the
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .td, * three named cards. attendedsysupgrade's 11_upgrades.js (a stock header row) keeps its
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .th, * dividers under this test. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr { border-bottom: 0; } :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .td,
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .th,
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr { border-bottom: 0; }
/* own rule, not a 4th line above: .cbi-value carries no :has(), and a selector list is not /* own rule, not a 4th line above: .cbi-value carries no :has(), and a selector list is not
* forgiving below the browser floor (npm run css-floor) */ * forgiving below the browser floor (npm run css-floor) */
:where(#view)[data-page="admin-status-overview"] .cbi-section .cbi-value { border-bottom: 0; } :where(#view)[data-page="admin-status-overview"] .cbi-section .cbi-value { border-bottom: 0; }
/* key/value name is the QUIET half: --fs-dim at normal weight, matching what the System table's /* key/value name is the QUIET half: --fs-dim at normal weight, matching what the System table's
* first column already resolved to. Data tables excluded by the same header-row test above. */ * first column already resolved to. Data tables excluded by the same shape test above. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .td:first-child { :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .td:first-child {
font-weight: var(--fs-weight-normal); font-weight: var(--fs-weight-normal);
color: var(--fs-dim); color: var(--fs-dim);
} }
@@ -240,16 +250,19 @@
/* progressbar's attr(title) was --fs-type-xs/--fs-dim beside a now-demoted label; give Memory's /* progressbar's attr(title) was --fs-type-xs/--fs-dim beside a now-demoted label; give Memory's
* bars something at full strength. Overview only — on Software the value sits beside its own * bars something at full strength. Overview only — on Software the value sits beside its own
* field label. */ * field label. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .cbi-progressbar::after { :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .cbi-progressbar::after {
font-size: var(--fs-type); font-size: var(--fs-type);
color: var(--fs-text); color: var(--fs-text);
} }
/* row height in DATA tables (DHCP leases, station list): theme/30-tables.css pads for a page /* row height in DATA tables (DHCP leases, station list, attendedsysupgrade's 11_upgrades.js):
* that IS a table, here it's a card read at a glance — 8 leases measured 740px against a 440px * theme/30-tables.css pads for a page that IS a table, here it's a card read at a glance — 8
* System card. One step down the space ladder, only here. */ * leases measured 740px against a 440px System card. Positive test is the complement of the
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:has(.tr.table-titles) .td, * key/value shape above (:has(.th) or a 3rd .td), not .tr.table-titles — a stock header .th
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:has(.tr.table-titles) .th { * emitted through initFromMarkup never carries that class. One step down the space ladder,
* only here. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:is(:has(.th), :has(.td:nth-child(3))) .td,
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:is(:has(.th), :has(.td:nth-child(3))) .th {
padding-block: var(--fs-space-1-5); padding-block: var(--fs-space-1-5);
} }
@@ -31,4 +31,9 @@
:where(#view)[data-page="admin-system-package-manager"] label:has(+ .cbi-progressbar) { :where(#view)[data-page="admin-system-package-manager"] label:has(+ .cbi-progressbar) {
display: block; margin: 0; font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-dim); display: block; margin: 0; font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-dim);
} }
/* value rides the "Disk space:" label's line at both widths — one unconditional opt-out of
* the bare-meter reserve; theme/90-responsive.css's phone rule only adds margin-bottom. */
:where(#view)[data-page="admin-system-package-manager"] label + .cbi-progressbar {
margin-block-start: 0;
}
} }
@@ -1,6 +1,10 @@
@layer theme { @layer theme {
/* One thin meter for every .cbi-progressbar: track + accent fill, title value floated above /* One thin meter for every .cbi-progressbar: track + accent fill, title value floated above
* the right edge instead of stuffed inside a fat pill. */ * the right edge instead of stuffed inside a fat pill. Default reserves that value's own line
* in flow (bootstrap's margin:1.4em model) — an abspos value with zero reserve spilled 5-6px
* UP across the divider of the row above, off the overview page (key/value .table, no header
* row). Contexts that put the value BESIDE the bar or on a neighbour's line opt out with
* margin-block-start: 0. */
.cbi-progressbar { .cbi-progressbar {
position: relative; position: relative;
height: 10px; height: 10px;
@@ -10,6 +14,7 @@
overflow: visible; overflow: visible;
min-width: 120px; min-width: 120px;
margin: 0; margin: 0;
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
} }
.cbi-progressbar > div { .cbi-progressbar > div {
height: 100%; height: 100%;
@@ -39,6 +44,7 @@
.cbi-value-field > .cbi-progressbar { .cbi-value-field > .cbi-progressbar {
/* @mirror meter/beside */ /* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value); margin-inline-end: var(--fs-meter-value);
margin-block-start: 0;
/* @endmirror */ /* @endmirror */
--fs-meter-value: calc(var(--fs-space-10) * 2); --fs-meter-value: calc(var(--fs-space-10) * 2);
} }
@@ -51,6 +57,7 @@
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar { .cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar {
/* @mirror meter/beside */ /* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value); margin-inline-end: var(--fs-meter-value);
margin-block-start: 0;
/* @endmirror */ /* @endmirror */
--fs-meter-value: var(--fs-space-10); --fs-meter-value: var(--fs-space-10);
min-width: 60px; /* narrower and it stops reading as a meter; the column gives up the rest */ min-width: 60px; /* narrower and it stops reading as a meter; the column gives up the rest */
@@ -151,34 +151,36 @@
} }
/* --- KEY/VALUE: "no id" is not enough — GridSection also renders no-id .cbi-section-table, /* --- KEY/VALUE: "no id" is not enough — GridSection also renders no-id .cbi-section-table,
* excluded along with .fs-dt and the legend's inline table-layout. */ * excluded along with .fs-dt and the legend's inline table-layout. Shape, not a class, says
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:first-child { * data table: any .th or a 3rd .td rules a table out — a real key/value row is exactly 2 .td
* and no .th. docs/css.md, "The key/value discriminator". */
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:first-child {
color: var(--fs-dim); font-weight: var(--fs-weight); width: 40%; white-space: nowrap; color: var(--fs-dim); font-weight: var(--fs-weight); width: 40%; white-space: nowrap;
} }
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:not(:first-child) { .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:last-child:not(:first-child) {
font-family: var(--fs-font-mono); color: var(--fs-text); text-align: end; font-family: var(--fs-font-mono); color: var(--fs-text); text-align: end;
} }
/* below ~560px the 40%/nowrap column starves the value — stacked below (560, not the config /* below ~560px the 40%/nowrap column starves the value — stacked below (560, not the config
* table's 960px). :first-child:first-child doubles the pseudo-class ON PURPOSE, to outrank. */ * table's 960px). :first-child:first-child doubles the pseudo-class ON PURPOSE, to outrank. */
@container fs-content (max-width: 560px) { @container fs-content (max-width: 560px) {
/* the row's own .td/.th padding above spent a FULL padding per half; moved to the ROW */ /* the row's own .td/.th padding above spent a FULL padding per half; moved to the ROW */
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .tr { .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .tr {
display: block; display: block;
padding: var(--fs-space-1) 0 var(--fs-space-1-5); padding: var(--fs-space-1) 0 var(--fs-space-1-5);
border: 0; border: 0;
border-bottom: var(--fs-hairline); border-bottom: var(--fs-hairline);
} }
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .tr:last-child { .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .tr:last-child {
border-bottom: 0; border-bottom: 0;
} }
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:first-child:first-child { .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:first-child:first-child {
display: block; width: auto; white-space: normal; display: block; width: auto; white-space: normal;
padding: 0; border: 0; padding: 0; border: 0;
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
letter-spacing: var(--fs-eyebrow-tracking); color: var(--fs-eyebrow-color); letter-spacing: var(--fs-eyebrow-tracking); color: var(--fs-eyebrow-color);
text-transform: uppercase; text-transform: uppercase;
} }
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:last-child:not(:first-child) { .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:last-child:last-child:not(:first-child) {
display: block; width: auto; text-align: start; display: block; width: auto; text-align: start;
padding: 0; border: 0; padding: 0; border: 0;
} }
+16 -13
View File
@@ -13,12 +13,12 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=wwand-ipa PKG_NAME:=wwand-ipa
PKG_VERSION:=0.1.0 PKG_VERSION:=0.1.0_p1
PKG_RELEASE:=1 PKG_RELEASE:=2
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/wwand-ipa.git PKG_SOURCE_URL:=https://github.com/ddimension/wwand-ipa.git
PKG_SOURCE_VERSION:=fc0fad051213f23d10864f26ebc7205ae5c0a451 PKG_SOURCE_VERSION:=f84625427503041472ba10f32df4969091a86f17
PKG_MIRROR_HASH:=skip PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only PKG_LICENSE:=GPL-2.0-only
@@ -34,21 +34,24 @@ define Package/wwand-ipa
CATEGORY:=Network CATEGORY:=Network
SUBMENU:=WWAN SUBMENU:=WWAN
TITLE:=eSIM fleet management (SGP.32 eIM) for wwand TITLE:=eSIM fleet management (SGP.32 eIM) for wwand
# wwand-esim: its bridge carries the assistant's APDUs; wwand-ipad: the # wwand-esim: its bridge carries the assistant's APDUs and runs direct
# assistant itself (onomondo-ipa with the stdio card backend) # downloads; wwand-ipad: the assistant itself (ipad, SGP.32 v1.3)
DEPENDS:=+wwand-esim +wwand-ipad DEPENDS:=+wwand-esim +wwand-ipad
PKGARCH:=all PKGARCH:=all
endef endef
define Package/wwand-ipa/description define Package/wwand-ipa/description
Lets an eIM manage the modem's eSIM: wwand polls it (after the connection Lets an eIM manage the modem's eSIM (GSMA SGP.32): wwand polls it (after
comes up, then every ipa_interval seconds) with the onomondo-ipa assistant, the connection comes up, then every ipa_interval seconds) with the ipad
relays the assistant's card traffic over the modem's APDU channel, and after assistant, relays the assistant's card traffic over the modem's APDU
a profile change resets the SIM (or the modem) and waits for the session on channel, resets the SIM (or the modem) after a profile change and waits for
the new profile before the result is reported. Runs against an SGP.22 the session on the new profile, runs direct downloads through lpac, and
consumer eUICC (the assistant's IoT eUICC emulation); the eIM must accept writes the enabled profile's APN into a wwand_sim section for its ICCID.
that mode. Enable per modem with `wwandctl ipa [modem] eim <file>`. While Drives an IoT eUICC, or an SGP.22 consumer eUICC through ipad's emulation,
enabled, manual profile changes on that card are refused unless forced. whose results are signed with a device key the eIM imports
(`wwandctl ipa export`). Enable per modem with `wwandctl ipa [modem] eim
<file>`. While enabled, manual profile changes on that card are refused
unless forced.
endef endef
define Package/luci-app-wwand-ipa define Package/luci-app-wwand-ipa
+48 -116
View File
@@ -1,154 +1,86 @@
# onomondo-ipa, the GSMA SGP.32 IoT Profile Assistant, built for wwand-ipa. # ipad, the GSMA SGP.32 v1.3 IoT Profile Assistant, built for wwand-ipa.
# #
# The assistant polls an eIM (the fleet server) for eUICC packages and runs # It polls an eIM (the fleet server) for eUICC packages and runs them on the
# them on the card. Upstream reaches the card through PC/SC; patch 100 adds a # card, which it reaches through lpac's stdio APDU protocol: wwand's
# card backend that speaks lpac's stdio APDU protocol instead, so wwand's # esim_bridge relays those APDUs over the modem's own channel (QMI UIM / MBIM
# esim_bridge relays its APDUs over the modem's own channel (QMI UIM / MBIM # UICC / AT), exactly as for lpac. An IoT eUICC is driven as it is; an SGP.22
# UICC / AT) exactly as it does for lpac, and adds -H: a profile change is # consumer eUICC through ipad's emulation of the SGP.32 functions, with its
# handed to the host, which resets the SIM and waits for the new session. The # results signed by a device key the eIM imports (`wwandctl ipa export`).
# patch carries a test for the card backend (tests/scard_stdio). The package
# build does not run it (BUILD_TESTING=OFF, target ipa only); on a host:
# cmake -S . -B build -DIPA_SCARD_STDIO=ON && cmake --build build && ctest --test-dir build
# The -H wiring in main.c has no test of its own: exercising it takes an eIM
# that sends a profile change.
# #
# TLS: the same minimal static wolfSSL + libcurl bundle as wwand-lpac, for the # mbedTLS 3.6 LTS is built in statically: RSP needs brainpoolP256r1, which
# same reason (OpenWrt's mbedtls lacks curves the GSMA PKI uses, and # OpenWrt's shared mbedtls does not enable by default. The project's tests
# libcurl+OpenSSL is ~6.4 MB). The eIM is reached over HTTPS with the system # (unit, a scripted eIM, the real binary over the stdio protocol) are not run
# CA bundle unless the eIM configuration says otherwise. # by the package build; see the ipad README.
#
# Upstream implements SGP.32 v1.0; wwand always runs it in IoT eUICC emulation
# (-E), i.e. against an SGP.22 consumer eUICC. See wwand's src-ucode/ipa.uc.
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=wwand-ipad PKG_NAME:=wwand-ipad
PKG_VERSION:=0.0.1_git20260901 PKG_VERSION:=0.1.0
PKG_RELEASE:=1 PKG_RELEASE:=2
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/onomondo/onomondo-ipa.git PKG_SOURCE_URL:=https://github.com/ddimension/ipad.git
PKG_SOURCE_VERSION:=6aaeb38a569e648c8305aa5bd4d300cc1ea6822a PKG_SOURCE_VERSION:=fbb175f4d272f0db45b3bc006392f172cd248aa5
PKG_MIRROR_HASH:=skip PKG_MIRROR_HASH:=skip
PKG_LICENSE:=AGPL-3.0-only curl GPL-3.0-or-later PKG_LICENSE:=GPL-2.0-only Apache-2.0
PKG_LICENSE_FILES:=LICENSE PKG_LICENSE_FILES:=LICENSE
PKG_MAINTAINER:= PKG_MAINTAINER:=
# bundled, statically-linked dependencies (keep in step with wwand-lpac) MBEDTLS_VERSION:=3.6.7
WOLFSSL_VERSION:=5.9.2-stable
CURL_VERSION:=8.21.0
PKG_BUILD_PARALLEL:=1 PKG_BUILD_PARALLEL:=1
CMAKE_BINARY_SUBDIR:=openwrt-build
# wolfSSL's SP math carries inline assembly that MIPS16 cannot encode
PKG_BUILD_FLAGS:=no-mips16
include $(INCLUDE_DIR)/package.mk include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/cmake.mk
define Download/wolfssl # the release tarball; its SHA-256 is the one mbedtls-3.6.7-sha256sum.txt on
FILE:=wolfssl-$(WOLFSSL_VERSION).tar.gz # the release page publishes
URL:=https://github.com/wolfSSL/wolfssl/archive/v$(WOLFSSL_VERSION) define Download/mbedtls
FILE:=mbedtls-$(MBEDTLS_VERSION).tar.bz2
URL:=https://github.com/Mbed-TLS/mbedtls/releases/download/mbedtls-$(MBEDTLS_VERSION)
HASH:=skip HASH:=skip
endef endef
$(eval $(call Download,wolfssl)) $(eval $(call Download,mbedtls))
define Download/curl define Build/Prepare
FILE:=curl-$(CURL_VERSION).tar.xz $(call Build/Prepare/Default)
URL:=https://github.com/curl/curl/releases/download/curl-8_21_0 $(TAR) -C $(PKG_BUILD_DIR) -xjf $(DL_DIR)/mbedtls-$(MBEDTLS_VERSION).tar.bz2
HASH:=skip
endef endef
$(eval $(call Download,curl))
CMAKE_OPTIONS += \
-DIPAD_MBEDTLS_DIR=$(PKG_BUILD_DIR)/mbedtls-$(MBEDTLS_VERSION) \
-DIPAD_WERROR=OFF \
-DBUILD_TESTING=OFF
TARGET_CFLAGS += -ffunction-sections -fdata-sections
TARGET_LDFLAGS += -Wl,--gc-sections
define Package/wwand-ipad define Package/wwand-ipad
SECTION:=net SECTION:=net
CATEGORY:=Network CATEGORY:=Network
SUBMENU:=WWAN SUBMENU:=WWAN
TITLE:=SGP.32 IoT Profile Assistant (onomondo-ipa) for wwand-ipa TITLE:=SGP.32 v1.3 IoT Profile Assistant (ipad) for wwand-ipa
URL:=https://github.com/onomondo/onomondo-ipa URL:=https://github.com/ddimension/ipad
# the eIM is an HTTPS server with an ordinary certificate chain # the eIM is an HTTPS server; without a pin in its configuration the
# system CAs decide
DEPENDS:=+ca-bundle DEPENDS:=+ca-bundle
endef endef
define Package/wwand-ipad/description define Package/wwand-ipad/description
onomondo-ipa, the GSMA SGP.32 IoT Profile Assistant, built with a card ipad, a GSMA SGP.32 v1.3 IoT Profile Assistant: eIM packages (every PSMO
backend that reaches the eUICC through wwand (lpac's stdio APDU protocol) and eCO), profile rollback, IPA/eUICC data, direct and indirect profile
instead of PC/SC, and with a minimal static wolfSSL + libcurl linked in. download, notifications and the connectivity parameters of the enabled
Installed as /usr/lib/wwand/ipad; wwand-ipa runs it. Implements SGP.32 v1.0 profile. Drives an IoT eUICC, or an SGP.22 consumer eUICC through an
and is run in IoT eUICC emulation against an SGP.22 consumer eUICC. emulation that signs its results with a device key the eIM imports.
endef Reaches the card through wwand (lpac's stdio APDU protocol); mbedTLS
linked in statically. Installed as /usr/lib/wwand/ipad; wwand-ipa runs it.
PREFIX:=$(PKG_BUILD_DIR)/.bundle
WOLFSSL_DIR:=$(PKG_BUILD_DIR)/wolfssl-$(WOLFSSL_VERSION)
CURL_DIR:=$(PKG_BUILD_DIR)/curl-$(CURL_VERSION)
IPA_BUILD:=$(PKG_BUILD_DIR)/.ipa-build
LTO_CFLAGS:=-flto=auto -ffat-lto-objects
BUNDLE_AR:=AR="$(TARGET_CROSS)gcc-ar" RANLIB="$(TARGET_CROSS)gcc-ranlib"
# unpack + patch the assistant, then the bundled wolfSSL + curl beside it
define Build/Prepare
$(call Build/Prepare/Default)
$(TAR) -C $(PKG_BUILD_DIR) -xzf $(DL_DIR)/wolfssl-$(WOLFSSL_VERSION).tar.gz
$(TAR) -C $(PKG_BUILD_DIR) -xJf $(DL_DIR)/curl-$(CURL_VERSION).tar.xz
endef
Build/Configure:=
define Build/Compile
# 1) minimal static wolfSSL (the wwand-lpac recipe)
( cd $(WOLFSSL_DIR) && ./autogen.sh )
( cd $(WOLFSSL_DIR) && ./configure --host=$(GNU_TARGET_NAME) --prefix=$(PREFIX) \
--enable-static --disable-shared \
--enable-curl --enable-tls13 --enable-brainpool --enable-ecccustcurves \
--enable-curve25519 --disable-dtls --disable-oldtls \
--disable-examples --disable-crypttests \
CC="$(TARGET_CC)" $(BUNDLE_AR) \
CFLAGS="$(TARGET_CFLAGS) $(LTO_CFLAGS) -ffunction-sections -fdata-sections -DWOLFSSL_NO_ASN_STRICT" )
+$(MAKE) -C $(WOLFSSL_DIR)
$(MAKE) -C $(WOLFSSL_DIR) install
# 2) minimal static libcurl (HTTP/HTTPS only) against it. HTTP stays: an
# eIM configuration may name a plain-HTTP lab eIM (the assistant's -S).
( cd $(CURL_DIR) && \
PKG_CONFIG_PATH="$(PREFIX)/lib/pkgconfig" PKG_CONFIG_LIBDIR="$(PREFIX)/lib/pkgconfig" \
./configure --host=$(GNU_TARGET_NAME) --prefix=$(PREFIX) --with-wolfssl=$(PREFIX) \
--enable-static --disable-shared \
--disable-ftp --disable-file --disable-ldap --disable-ldaps --disable-rtsp \
--disable-dict --disable-telnet --disable-tftp --disable-pop3 --disable-imap \
--disable-smtp --disable-gopher --disable-mqtt --disable-smb --disable-ntlm \
--disable-manual --disable-unix-sockets --disable-progress-meter \
--without-libpsl --without-zlib --without-nghttp2 --without-brotli \
--without-zstd --without-libidn2 --without-librtmp --without-ca-path \
--with-ca-bundle=/etc/ssl/certs/ca-certificates.crt \
CC="$(TARGET_CC)" $(BUNDLE_AR) \
CFLAGS="$(TARGET_CFLAGS) $(LTO_CFLAGS) -ffunction-sections -fdata-sections -DSIZEOF_LONG_LONG=8" \
CPPFLAGS="-I$(PREFIX)/include" LDFLAGS="-L$(PREFIX)/lib" LIBS="-lm" )
+$(MAKE) -C $(CURL_DIR)
$(MAKE) -C $(CURL_DIR) install
# 3) the assistant with the stdio card backend, statically against both.
# AR/RANLIB go in as full paths: CMake took the bare name as a path relative
# to its build directory and wrote `.ipa-build/aarch64-openwrt-linux-musl-gcc-ar`
# into libasn's link.txt, which failed with "Error running link command: no
# such file or directory" (SDK snapshot, aarch64, 2026-09-26).
rm -rf $(IPA_BUILD)
mkdir -p $(IPA_BUILD)
( cd $(IPA_BUILD) && cmake $(PKG_BUILD_DIR) \
-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=$(ARCH) \
-DCMAKE_C_COMPILER="$(TARGET_CROSS)gcc" \
-DCMAKE_AR="$(TOOLCHAIN_DIR)/bin/$(TARGET_CROSS)gcc-ar" \
-DCMAKE_RANLIB="$(TOOLCHAIN_DIR)/bin/$(TARGET_CROSS)gcc-ranlib" \
-DCMAKE_C_FLAGS="$(TARGET_CFLAGS) $(LTO_CFLAGS) -ffunction-sections -fdata-sections -I$(PREFIX)/include" \
-DCMAKE_EXE_LINKER_FLAGS="$(TARGET_LDFLAGS) $(LTO_CFLAGS) -Wl,--gc-sections -L$(PREFIX)/lib" \
-DCMAKE_C_STANDARD_LIBRARIES="-L$(PREFIX)/lib -lwolfssl -lm" \
-DIPA_SCARD_STDIO=ON -DBUILD_TESTING=OFF )
+$(MAKE) -C $(IPA_BUILD) ipa
endef endef
define Package/wwand-ipad/install define Package/wwand-ipad/install
$(INSTALL_DIR) $(1)/usr/lib/wwand $(INSTALL_DIR) $(1)/usr/lib/wwand
$(INSTALL_BIN) $(IPA_BUILD)/src/ipa/ipa $(1)/usr/lib/wwand/ipad $(INSTALL_BIN) $(PKG_BUILD_DIR)/$(CMAKE_BINARY_SUBDIR)/ipad $(1)/usr/lib/wwand/ipad
endef endef
$(eval $(call BuildPackage,wwand-ipad)) $(eval $(call BuildPackage,wwand-ipad))
-833
View File
@@ -1,833 +0,0 @@
diff --git a/include/onomondo/ipa/scard_stdio.h b/include/onomondo/ipa/scard_stdio.h
new file mode 100644
index 0000000..fa9ec54
--- /dev/null
+++ b/include/onomondo/ipa/scard_stdio.h
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2026 André Valentin <avalentin@marcant.net>
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+#pragma once
+
+/*! Hand a profile change to the host and wait until it has restored the data
+ * connection, then re-open the card channel the host's SIM reset closed.
+ * \returns 1 when the host reports the connection back, 0 when it gave up
+ * waiting, negative when the host is gone. */
+int ipa_scard_stdio_await_online(void);
diff --git a/src/ipa/CMakeLists.txt b/src/ipa/CMakeLists.txt
index 2cf4a2b..b4ee0e5 100644
--- a/src/ipa/CMakeLists.txt
+++ b/src/ipa/CMakeLists.txt
@@ -5,7 +5,15 @@ add_subdirectory(libasn)
add_subdirectory(libipa)
add_library(http STATIC http.c)
-add_library(scard STATIC scard.c)
+option(IPA_SCARD_STDIO "Reach the eUICC through a host process (lpac stdio protocol) instead of PC/SC" OFF)
+if (IPA_SCARD_STDIO)
+ add_library(scard STATIC scard_stdio.c)
+ target_compile_definitions(scard PUBLIC IPA_SCARD_STDIO)
+ set(IPA_SCARD_LIBS "")
+else()
+ add_library(scard STATIC scard.c)
+ set(IPA_SCARD_LIBS pcsclite)
+endif()
target_include_directories(http PUBLIC ${CMAKE_SOURCE_DIR}/include)
target_include_directories(scard PUBLIC ${CMAKE_SOURCE_DIR}/include)
@@ -16,7 +24,7 @@ set_property(TARGET ipa PROPERTY C_STANDARD 99)
target_compile_options(ipa PRIVATE -Wall)
target_include_directories(ipa PUBLIC ${CMAKE_SOURCE_DIR}/include)
-target_link_libraries(ipa libipa http scard pcsclite curl)
+target_link_libraries(ipa libipa http scard ${IPA_SCARD_LIBS} curl)
if (M32)
set_target_properties(http PROPERTIES COMPILE_FLAGS "-m32" LINK_FLAGS "-m32")
diff --git a/src/ipa/main.c b/src/ipa/main.c
index d4316f2..5dfa703 100644
--- a/src/ipa/main.c
+++ b/src/ipa/main.c
@@ -17,6 +17,9 @@
#include <onomondo/ipa/utils.h>
#include <onomondo/ipa/log.h>
#include <onomondo/ipa/ipad.h>
+#ifdef IPA_SCARD_STDIO
+#include <onomondo/ipa/scard_stdio.h>
+#endif
#define DEFAULT_READER_NUMBER 0
#define DEFAULT_CHANNEL_NUMBER 1
@@ -58,6 +61,9 @@ static void print_help(void)
printf(" -I .................. disable SSL certificate verification (insecure)\n");
printf(" -E .................. emulate IoT eUICC (compatibility mode to use consumer eUICCs)\n");
printf(" -1 .................. force the IPAd to process only one eUICC package (debug, use with caution)\n");
+#ifdef IPA_SCARD_STDIO
+ printf(" -H .................. hand profile changes to the host and wait for it to restore connectivity\n");
+#endif
}
struct ipa_buf *load_ber_from_file(char *dir, char *file)
@@ -134,6 +140,22 @@ static void sig_usr1(int signum)
running = false;
}
+#ifdef IPA_SCARD_STDIO
+/* The host ends a run it has given up on with SIGTERM. Without a handler that
+ * kills the process before ipa_free_ctx() hands back the nvstate, and in IoT
+ * eUICC emulation the nvstate IS the eIM configuration and the replay counter.
+ * No SA_RESTART: a read blocked on the host must return, so the card layer
+ * fails the current exchange and main() reaches the save below. */
+static void install_sigterm(void)
+{
+ struct sigaction sa = { 0 };
+
+ sa.sa_handler = sig_usr1;
+ sigemptyset(&sa.sa_mask);
+ sigaction(SIGTERM, &sa, NULL);
+}
+#endif
+
int main(int argc, char **argv)
{
struct ipa_config cfg = { 0 };
@@ -146,8 +168,12 @@ int main(int argc, char **argv)
struct ipa_buf *nvstate_load = NULL;
struct ipa_buf *nvstate_save = NULL;
bool getopt_one_euicc_pkg_only = false;
+ bool getopt_host_online = false;
signal(SIGUSR1, sig_usr1);
+#ifdef IPA_SCARD_STDIO
+ install_sigterm();
+#endif
printf("IPAd!\n");
@@ -159,7 +185,7 @@ int main(int argc, char **argv)
/* Overwrite configuration values with user defined parameters */
while (1) {
- opt = getopt(argc, argv, "ht:e:r:c:f:mn:C:SIEy:a1");
+ opt = getopt(argc, argv, "ht:e:r:c:f:mn:C:SIEy:a1H");
if (opt == -1)
break;
@@ -210,6 +236,9 @@ int main(int argc, char **argv)
case '1':
getopt_one_euicc_pkg_only = true;
break;
+ case 'H':
+ getopt_host_online = true;
+ break;
default:
printf("unhandled option: %c!\n", opt);
break;
@@ -284,6 +313,19 @@ int main(int argc, char **argv)
case IPA_POLL_AGAIN_WHEN_ONLINE:
/* ipa_poll asks us to wait with the next poll cycle until we have a stable IP
* connection. In this example we assume that IP connectivity is always available. */
+#ifdef IPA_SCARD_STDIO
+ /* On a router it is not: the modem keeps the old profile until the host resets
+ * the SIM, and the data session has to come back on the new one. The process
+ * stays alive meanwhile, because the pending result (and a rollback, should the
+ * eIM stay unreachable) lives in the context, not in the nvstate. Whatever the
+ * host answers, the next ipa_poll is the one that decides: it reports to the
+ * eIM, or rolls the profile back when it cannot. */
+ if (getopt_host_online && ipa_scard_stdio_await_online() < 0) {
+ IPA_LOGP(SMAIN, LERROR, "host went away during a profile change\n");
+ rc = -EIO;
+ goto leave;
+ }
+#endif
IPA_LOGP(SMAIN, LINFO, "poll cycle continues normally (profile change)\n");
rc = 0;
break;
diff --git a/src/ipa/scard_stdio.c b/src/ipa/scard_stdio.c
new file mode 100644
index 0000000..5b58991
--- /dev/null
+++ b/src/ipa/scard_stdio.c
@@ -0,0 +1,513 @@
+/*
+ * Copyright (c) 2026 André Valentin <avalentin@marcant.net>
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ *
+ * Smartcard backend for onomondo-ipa that reaches the eUICC through a HOST
+ * PROCESS instead of PC/SC: every card operation is one JSON line on stdout,
+ * and the answer is one JSON line on stdin. The wire format is lpac's stdio
+ * APDU driver (lpac driver/apdu/stdio.c, v2.3.0) byte for byte, so the host
+ * side that already bridges lpac -- wwand's esim_bridge, which answers from
+ * the modem's own APDU channel (QMI UIM / MBIM UICC / AT) -- bridges this too:
+ *
+ * -> {"type":"apdu","payload":{"func":"logic_channel_open","param":"<AID>"}}
+ * <- {"type":"apdu","payload":{"ecode":<channel or -1>}}
+ * -> {"type":"apdu","payload":{"func":"transmit","param":"<C-APDU>"}}
+ * <- {"type":"apdu","payload":{"ecode":0,"data":"<R-APDU incl. SW>"}}
+ *
+ * The two sides do not speak the same card dialect, and this file is where
+ * that is reconciled:
+ *
+ * - libipa talks raw T=0 (libipa/euicc.c): TERMINAL CAPABILITY on the basic
+ * channel, MANAGE CHANNEL to open one, SELECT ISD-R on it and then STORE
+ * DATA / GET RESPONSE with the channel number in CLA. A modem's APDU
+ * service does not let the host do any of that directly: QMI UIM opens a
+ * logical channel BY AID (OPEN_LOGICAL_CHANNEL, which selects it in the
+ * same step) and addresses the channel by id. So MANAGE CHANNEL is
+ * answered here, the channel is really opened when the SELECT names the
+ * AID, and CLA is rewritten to the channel the modem handed out.
+ * - The modem completes GET RESPONSE itself and returns the whole answer with
+ * 9000 (the QMI UIM long-response path in wwand's sim.uc reassembles even
+ * the chunked ones). libipa insists on seeing 61xx after the last STORE
+ * DATA block and fetching with GET RESPONSE, and it rejects a block whose
+ * length differs from what 61xx announced (euicc.c recv_es10x_block). So a
+ * response with data is held here and handed out again in 61xx/GET
+ * RESPONSE portions of exactly the announced size.
+ *
+ * Logging goes to stderr (libipa's log.c already writes there), so stdout
+ * carries only the protocol. A host may still merge the two, as wwand does
+ * (2>&1, into its syslog): every log line starts with the space-padded
+ * subsystem ("%8s %8s ", log.c:69), never with the '{' a protocol line starts
+ * with, so the two stay apart.
+ */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <string.h>
+#include <errno.h>
+#include <assert.h>
+#include <onomondo/ipa/utils.h>
+#include <onomondo/ipa/scard.h>
+#include <onomondo/ipa/scard_stdio.h>
+#include <onomondo/ipa/log.h>
+#include <onomondo/ipa/mem.h>
+
+/* ES10 answers (a profile list, certificates) reach several kB; one line
+ * carries the hex of the largest plus the JSON around it */
+#define RX_MAX_LEN 32768
+#define LINE_MAX_LEN (2 * RX_MAX_LEN + 128)
+#define AID_MAX_LEN 16
+
+struct scard_ctx {
+ /* the AID libipa selected, kept so the channel can be re-opened after
+ * the host has reset the card (see ipa_scard_stdio_await_online) */
+ uint8_t aid[AID_MAX_LEN];
+ size_t aid_len;
+
+ /* the channel the modem handed out; -1 while none is open */
+ int channel;
+
+ /* a card answer being handed out in GET RESPONSE portions */
+ uint8_t *pending;
+ size_t pending_len;
+ size_t pending_off;
+};
+
+/* One process drives one card, and the connectivity hook below is called from
+ * main.c, which only holds libipa's opaque context. */
+static struct scard_ctx *g_ctx;
+
+/* Set once a read from the host failed (EOF, or a SIGTERM interrupting it).
+ * Every later exchange fails at once instead of blocking again: the shutdown
+ * path still closes the channel and disconnects, and waiting on a host that
+ * has gone would keep main() from ever saving the nvstate. */
+static bool host_gone;
+
+static const char hexdigits[] = "0123456789ABCDEF";
+
+static char *to_hex(const uint8_t *data, size_t len)
+{
+ char *s = malloc(len * 2 + 1);
+ size_t i;
+
+ assert(s);
+ for (i = 0; i < len; i++) {
+ s[i * 2] = hexdigits[data[i] >> 4];
+ s[i * 2 + 1] = hexdigits[data[i] & 0x0F];
+ }
+ s[len * 2] = '\0';
+ return s;
+}
+
+static int hexval(char c)
+{
+ if (c >= '0' && c <= '9')
+ return c - '0';
+ if (c >= 'a' && c <= 'f')
+ return c - 'a' + 10;
+ if (c >= 'A' && c <= 'F')
+ return c - 'A' + 10;
+ return -1;
+}
+
+/* Read one line from stdin. Returns false on EOF: the host is gone, and every
+ * caller turns that into a card error rather than waiting forever. */
+static bool read_line(char *buf, size_t size)
+{
+ size_t n;
+
+ if (host_gone)
+ return false;
+
+ if (!fgets(buf, size, stdin)) {
+ host_gone = true;
+ return false;
+ }
+
+ n = strlen(buf);
+
+ /* A line longer than the buffer: the rest would be read as the NEXT
+ * answer and every later exchange would be off by one. Drain it and
+ * report the round as failed instead. */
+ if (n == size - 1 && buf[n - 1] != '\n') {
+ int c;
+
+ while ((c = getchar()) != EOF && c != '\n')
+ ;
+ buf[0] = '\0';
+ IPA_LOGP(SSCARD, LERROR, "stdio: answer longer than %zu bytes dropped\n", size);
+ return true;
+ }
+ while (n && (buf[n - 1] == '\n' || buf[n - 1] == '\r'))
+ buf[--n] = '\0';
+
+ return true;
+}
+
+/* The host's answers are produced by wwand (a fixed sprintf) or lpac-compatible
+ * bridges, so a field lookup is enough; a full JSON parser would be the only
+ * dependency this binary had besides libcurl. */
+static const char *field(const char *line, const char *name)
+{
+ char key[32];
+ const char *p;
+
+ snprintf(key, sizeof(key), "\"%s\"", name);
+ p = strstr(line, key);
+ if (!p)
+ return NULL;
+ p += strlen(key);
+ while (*p == ' ' || *p == ':')
+ p++;
+ return p;
+}
+
+/* One request/answer round. `data` receives the decoded "data" field (may be
+ * empty); returns the ecode, or -1 on a protocol failure. */
+static int roundtrip(const char *func, const uint8_t *param, size_t param_len, struct ipa_buf *data)
+{
+ static char line[LINE_MAX_LEN];
+ char *hex = param_len ? to_hex(param, param_len) : NULL;
+ const char *p;
+ int ecode;
+
+ if (host_gone) {
+ free(hex);
+ return -1;
+ }
+
+ fflush(stdout);
+ printf("{\"type\":\"apdu\",\"payload\":{\"func\":\"%s\",\"param\":%s%s%s}}\n",
+ func, hex ? "\"" : "", hex ? hex : "null", hex ? "\"" : "");
+ fflush(stdout);
+ free(hex);
+
+ if (!read_line(line, sizeof(line))) {
+ IPA_LOGP(SSCARD, LERROR, "stdio: host closed the channel during %s\n", func);
+ return -1;
+ }
+
+ p = field(line, "ecode");
+ if (!p) {
+ IPA_LOGP(SSCARD, LERROR, "stdio: malformed answer to %s: %s\n", func, line);
+ return -1;
+ }
+ ecode = atoi(p);
+
+ if (data) {
+ data->len = 0;
+ p = field(line, "data");
+ if (p && *p == '"') {
+ p++;
+ while (hexval(p[0]) >= 0 && hexval(p[1]) >= 0) {
+ if (data->len >= data->data_len) {
+ IPA_LOGP(SSCARD, LERROR, "stdio: answer to %s exceeds %zu bytes\n", func,
+ data->data_len);
+ return -1;
+ }
+ data->data[data->len++] = (hexval(p[0]) << 4) | hexval(p[1]);
+ p += 2;
+ }
+ }
+ }
+
+ return ecode;
+}
+
+/* CLA with the channel number in it (ISO/IEC 7816-4 5.4.1). Channels 0-3 sit
+ * in bits 1-2 of the first interindustry coding, 4-19 need the further
+ * coding: bit 7 set and channel-4 in bits 1-4. Bit 8 (the proprietary class
+ * STORE DATA uses) is kept either way. libipa sends no secure messaging, so
+ * the SM bits are left clear. lpac writes the low nibble instead
+ * (euicc/interface.c, v2.3.0), which is the same for 0-3 and wrong from 4 on;
+ * a modem is free to hand out a channel above 3. */
+static uint8_t cla_for_channel(uint8_t cla, int channel)
+{
+ if (channel <= 3)
+ return (cla & 0x80) | (uint8_t) channel;
+ return (cla & 0x80) | 0x40 | (uint8_t) (channel - 4);
+}
+
+static void drop_pending(struct scard_ctx *ctx)
+{
+ free(ctx->pending);
+ ctx->pending = NULL;
+ ctx->pending_len = 0;
+ ctx->pending_off = 0;
+}
+
+static void put_sw(struct ipa_buf *res, uint16_t sw)
+{
+ res->data[res->len++] = sw >> 8;
+ res->data[res->len++] = sw & 0xFF;
+}
+
+/* 61xx for what is still held: xx is the size of the next portion, 00 meaning
+ * 256 (ISO/IEC 7816-4 and ETSI TS 102 221 10.1.6, which libipa follows). */
+static uint16_t pending_sw(const struct scard_ctx *ctx)
+{
+ size_t left = ctx->pending_len - ctx->pending_off;
+
+ if (!left)
+ return 0x9000;
+ return 0x6100 | (left >= 256 ? 0 : (uint8_t) left);
+}
+
+static void close_channel(struct scard_ctx *ctx)
+{
+ uint8_t ch;
+
+ if (ctx->channel < 0)
+ return;
+ ch = (uint8_t) ctx->channel;
+ roundtrip("logic_channel_close", &ch, 1, NULL);
+ ctx->channel = -1;
+}
+
+static int open_channel(struct scard_ctx *ctx)
+{
+ int ch = roundtrip("logic_channel_open", ctx->aid, ctx->aid_len, NULL);
+
+ /* channel 0 is the basic channel, never one the modem hands out for an
+ * AID; lpac treats it as a failure the same way */
+ if (ch <= 0) {
+ IPA_LOGP(SSCARD, LERROR, "stdio: could not open a logical channel to %s (ecode %d)\n",
+ ipa_hexdump(ctx->aid, ctx->aid_len), ch);
+ ctx->channel = -1;
+ return -EIO;
+ }
+
+ ctx->channel = ch;
+ IPA_LOGP(SSCARD, LINFO, "stdio: logical channel %d open to %s\n", ch, ipa_hexdump(ctx->aid, ctx->aid_len));
+ return 0;
+}
+
+void *ipa_scard_init(unsigned int reader_num)
+{
+ struct scard_ctx *ctx;
+
+ (void)reader_num; /* the host decides which modem and slot this is */
+
+ if (roundtrip("connect", NULL, 0, NULL) != 0) {
+ IPA_LOGP(SSCARD, LERROR, "stdio: the host refused the connection\n");
+ return NULL;
+ }
+
+ ctx = IPA_ALLOC_ZERO(struct scard_ctx);
+ assert(ctx);
+ ctx->channel = -1;
+ g_ctx = ctx;
+ return ctx;
+}
+
+int ipa_scard_transceive(void *scard_ctx, struct ipa_buf *res, const struct ipa_buf *req)
+{
+ struct scard_ctx *ctx = scard_ctx;
+ struct ipa_buf *rx = NULL;
+ uint8_t *tx = NULL;
+ uint8_t cla, ins, p1;
+ uint16_t sw;
+ size_t n;
+ int rc = 0;
+
+ assert(ctx);
+ assert(res);
+ assert(req);
+ res->len = 0;
+
+ if (req->len < 4)
+ return -EINVAL;
+
+ cla = req->data[0];
+ ins = req->data[1];
+ p1 = req->data[2];
+
+ IPA_LOGP(SSCARD, LDEBUG, "stdio TX: %s\n", ipa_buf_hexdump(req));
+
+ /* TERMINAL CAPABILITY belongs to the terminal, which is the modem: it
+ * has sent its own to the card at power-up, and it is not something a
+ * host can say on the modem's behalf through a logical channel. lpac
+ * does not send it either. */
+ if ((cla & 0xF0) == 0x80 && ins == 0xAA) {
+ put_sw(res, 0x9000);
+ goto out;
+ }
+
+ /* MANAGE CHANNEL: the modem opens channels by AID, so opening waits for
+ * the SELECT that names one; closing closes the real channel. */
+ if (ins == 0x70) {
+ if (p1 & 0x80)
+ close_channel(ctx);
+ put_sw(res, 0x9000);
+ goto out;
+ }
+
+ /* SELECT by DF name: this is where the channel is really opened. Any
+ * channel still open is closed first so a re-select does not leak one
+ * per poll. The select response is not handed on: libipa checks only
+ * for 61xx here (euicc.c select_isd_r) and never fetches it. */
+ if (ins == 0xA4 && p1 == 0x04 && req->len >= 5) {
+ size_t lc = req->data[4];
+
+ if (lc == 0 || lc > AID_MAX_LEN || req->len < 5 + lc) {
+ put_sw(res, 0x6A82);
+ goto out;
+ }
+ close_channel(ctx);
+ drop_pending(ctx);
+ memcpy(ctx->aid, req->data + 5, lc);
+ ctx->aid_len = lc;
+ put_sw(res, open_channel(ctx) == 0 ? 0x6100 : 0x6A82);
+ goto out;
+ }
+
+ /* GET RESPONSE for an answer held here: hand out exactly the announced
+ * portion (Le 00 = 256), then 61xx for the rest or 9000. */
+ if (ins == 0xC0 && ctx->pending) {
+ size_t le = req->len >= 5 ? req->data[4] : 0;
+ size_t left = ctx->pending_len - ctx->pending_off;
+
+ if (le == 0)
+ le = 256;
+ n = le < left ? le : left;
+ if (n + 2 > res->data_len)
+ n = res->data_len - 2;
+ memcpy(res->data, ctx->pending + ctx->pending_off, n);
+ res->len = n;
+ ctx->pending_off += n;
+ sw = pending_sw(ctx);
+ if (sw == 0x9000)
+ drop_pending(ctx);
+ put_sw(res, sw);
+ goto out;
+ }
+
+ if (ctx->channel < 0) {
+ IPA_LOGP(SSCARD, LERROR, "stdio: APDU for a card channel that is not open\n");
+ return -EIO;
+ }
+
+ /* the channel number in CLA is the modem's, not the one libipa chose */
+ tx = malloc(req->len);
+ assert(tx);
+ memcpy(tx, req->data, req->len);
+ tx[0] = cla_for_channel(tx[0], ctx->channel);
+
+ drop_pending(ctx);
+ rx = ipa_buf_alloc(RX_MAX_LEN);
+ assert(rx);
+
+ if (roundtrip("transmit", tx, req->len, rx) != 0 || rx->len < 2) {
+ IPA_LOGP(SSCARD, LERROR, "stdio: transmit failed\n");
+ rc = -EIO;
+ goto free;
+ }
+
+ sw = (rx->data[rx->len - 2] << 8) | rx->data[rx->len - 1];
+ n = rx->len - 2;
+
+ /* An answer with data and a normal ending is held and announced as 61xx.
+ * 91xx is a normal ending too (a proactive command is pending, which the
+ * modem fetches itself); libipa knows only 9000/61xx and would abort. */
+ if (n && (sw == 0x9000 || (sw & 0xFF00) == 0x9100)) {
+ ctx->pending = malloc(n);
+ assert(ctx->pending);
+ memcpy(ctx->pending, rx->data, n);
+ ctx->pending_len = n;
+ ctx->pending_off = 0;
+ put_sw(res, pending_sw(ctx));
+ goto free;
+ }
+
+ if ((sw & 0xFF00) == 0x9100)
+ sw = 0x9000;
+
+ /* anything else goes to libipa as the card said it, clipped to fit */
+ if (n + 2 > res->data_len)
+ n = res->data_len - 2;
+ memcpy(res->data, rx->data, n);
+ res->len = n;
+ put_sw(res, sw);
+
+free:
+ free(tx);
+ ipa_buf_free(rx);
+ if (rc < 0)
+ return rc;
+out:
+ IPA_LOGP(SSCARD, LDEBUG, "stdio RX: %s\n", ipa_buf_hexdump(res));
+ return 0;
+}
+
+int ipa_scard_reset(void *scard_ctx)
+{
+ /* a card reset is the host's to do (it owns the modem and knows what a
+ * reset costs the data session); from here it is a re-open */
+ struct scard_ctx *ctx = scard_ctx;
+
+ assert(ctx);
+ drop_pending(ctx);
+ close_channel(ctx);
+ return ctx->aid_len ? open_channel(ctx) : 0;
+}
+
+int ipa_scard_atr(void *scard_ctx, struct ipa_buf *atr)
+{
+ /* the modem does not hand the ATR to the host over this channel */
+ (void)scard_ctx;
+ atr->len = 0;
+ return -EIO;
+}
+
+int ipa_scard_free(void *scard_ctx)
+{
+ struct scard_ctx *ctx = scard_ctx;
+
+ if (!ctx)
+ return 0;
+
+ drop_pending(ctx);
+ close_channel(ctx);
+ roundtrip("disconnect", NULL, 0, NULL);
+ if (g_ctx == ctx)
+ g_ctx = NULL;
+ IPA_FREE(ctx);
+ return 0;
+}
+
+int ipa_scard_stdio_await_online(void)
+{
+ static char line[LINE_MAX_LEN];
+ const char *p;
+ int online;
+
+ /* The profile just changed. The host has to make the modem use it (a SIM
+ * power cycle: without REFRESH the modem keeps running the old profile)
+ * and wait until the data session is back; only then can the result reach
+ * the eIM. That power cycle closes every logical channel, so ours is
+ * re-opened here before libipa talks to the card again. */
+ fflush(stdout);
+ printf("{\"type\":\"event\",\"payload\":{\"event\":\"profile_changed\"}}\n");
+ fflush(stdout);
+
+ if (!read_line(line, sizeof(line))) {
+ IPA_LOGP(SSCARD, LERROR, "stdio: host closed the channel while restoring connectivity\n");
+ return -1;
+ }
+
+ p = field(line, "online");
+ online = p && strncmp(p, "true", 4) == 0;
+ IPA_LOGP(SSCARD, LINFO, "stdio: host reports the connection %s\n", online ? "back" : "NOT back");
+
+ if (g_ctx) {
+ /* no close first: the card reset already closed it, and the modem
+ * may have handed that id to someone else since */
+ drop_pending(g_ctx);
+ if (g_ctx->aid_len && open_channel(g_ctx) < 0)
+ return -1;
+ }
+
+ return online;
+}
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index a3e0ef8..de33415 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -6,3 +6,6 @@ enable_testing()
add_subdirectory(activation_code)
add_subdirectory(utils)
add_subdirectory(bpp_segments)
+if (IPA_SCARD_STDIO)
+ add_subdirectory(scard_stdio)
+endif()
diff --git a/tests/scard_stdio/CMakeLists.txt b/tests/scard_stdio/CMakeLists.txt
new file mode 100644
index 0000000..639a9c7
--- /dev/null
+++ b/tests/scard_stdio/CMakeLists.txt
@@ -0,0 +1,11 @@
+# Copyright (c) 2026 André Valentin <avalentin@marcant.net>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+add_executable(scard_stdio_test scard_stdio_test.c)
+set_property(TARGET scard_stdio_test PROPERTY C_STANDARD 99)
+target_compile_options(scard_stdio_test PRIVATE -Wall)
+target_include_directories(scard_stdio_test PRIVATE ${PROJECT_SOURCE_DIR}/include)
+target_link_libraries(scard_stdio_test scard libipa)
+
+add_test(NAME scard_stdio_test
+ COMMAND sh -c "$<TARGET_FILE:scard_stdio_test> < ${CMAKE_CURRENT_SOURCE_DIR}/scard_stdio_test.in > scard_stdio_test.out && cmp scard_stdio_test.out ${CMAKE_CURRENT_SOURCE_DIR}/scard_stdio_test.ok")
diff --git a/tests/scard_stdio/scard_stdio_test.c b/tests/scard_stdio/scard_stdio_test.c
new file mode 100644
index 0000000..831320d
--- /dev/null
+++ b/tests/scard_stdio/scard_stdio_test.c
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2026 André Valentin <avalentin@marcant.net>
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ *
+ * The stdio card backend against a scripted host: stdin carries the host's
+ * answers (scard_stdio_test.in), stdout the requests, which the test compares
+ * with scard_stdio_test.ok. The card answers the way a modem's APDU service
+ * does -- the whole answer at once with 9000 -- and the checks here are what
+ * libipa relies on seeing instead (libipa/euicc.c).
+ */
+
+#include <stdio.h>
+#include <string.h>
+#include <assert.h>
+#include <onomondo/ipa/utils.h>
+#include <onomondo/ipa/scard.h>
+#include <onomondo/ipa/scard_stdio.h>
+
+static void *card;
+
+/* send one C-APDU (hex) and return the R-APDU as a buffer */
+static struct ipa_buf *xfer(const char *capdu_hex)
+{
+ struct ipa_buf *req = ipa_buf_alloc(300);
+ struct ipa_buf *res = ipa_buf_alloc(258);
+
+ req->len = ipa_binary_from_hexstr(req->data, req->data_len, capdu_hex);
+ assert(ipa_scard_transceive(card, res, req) == 0);
+ ipa_buf_free(req);
+ return res;
+}
+
+static unsigned sw_of(const struct ipa_buf *r)
+{
+ assert(r->len >= 2);
+ return (r->data[r->len - 2] << 8) | r->data[r->len - 1];
+}
+
+static void expect_sw(const char *capdu_hex, unsigned sw, size_t data_len)
+{
+ struct ipa_buf *r = xfer(capdu_hex);
+
+ if (sw_of(r) != sw || r->len - 2 != data_len) {
+ fprintf(stderr, "%s: got sw %04X with %zu bytes, want %04X with %zu\n", capdu_hex, sw_of(r),
+ r->len - 2, sw, data_len);
+ assert(0);
+ }
+ ipa_buf_free(r);
+}
+
+int main(void)
+{
+ card = ipa_scard_init(0);
+ assert(card);
+
+ /* TERMINAL CAPABILITY and MANAGE CHANNEL never reach the host */
+ expect_sw("80AA000005A903830107", 0x9000, 0);
+ expect_sw("0070000100", 0x9000, 0);
+
+ /* SELECT ISD-R opens the real channel (the host hands out 3); libipa
+ * accepts only 61xx here */
+ expect_sw("01A4040410A0000005591010FFFFFFFF8900000100", 0x6100, 0);
+
+ /* A 300-byte answer with 9000 is announced as 61 00 and handed out as
+ * 256 + 44, each portion exactly the size announced. CLA goes out with
+ * the host's channel (83), not libipa's (81). */
+ expect_sw("81E2910003BF2200", 0x6100, 0);
+ expect_sw("01C0000000", 0x612C, 256);
+ expect_sw("01C000002C", 0x9000, 44);
+
+ /* The host resets the card for a profile change: the channel it closed
+ * is re-opened (the host now hands out 2) and later APDUs use it */
+ assert(ipa_scard_stdio_await_online() == 1);
+ expect_sw("81E2910003BF2B00", 0x6A88, 0);
+
+ /* 91xx is a normal ending too: held and announced like 9000 */
+ expect_sw("81E2910003BF2D00", 0x6102, 0);
+ expect_sw("01C0000002", 0x9000, 2);
+
+ /* the host reports it could not get the connection back, and re-opens
+ * on channel 4: from there CLA takes the further coding, 0x40 | ch-4
+ * (ISO/IEC 7816-4 5.4.1), so STORE DATA goes out as C0, not 84 */
+ assert(ipa_scard_stdio_await_online() == 0);
+ expect_sw("81E2910003BF2B00", 0x9000, 0);
+
+ assert(ipa_scard_free(card) == 0);
+
+ /* the host is gone: every exchange fails at once instead of blocking */
+ card = ipa_scard_init(0);
+ assert(card == NULL);
+
+ fprintf(stderr, "scard_stdio_test: ok\n");
+ return 0;
+}
diff --git a/tests/scard_stdio/scard_stdio_test.in b/tests/scard_stdio/scard_stdio_test.in
new file mode 100644
index 0000000..50d2f24
--- /dev/null
+++ b/tests/scard_stdio/scard_stdio_test.in
@@ -0,0 +1,12 @@
+{"type":"apdu","payload":{"ecode":0}}
+{"type":"apdu","payload":{"ecode":3}}
+{"type":"apdu","payload":{"ecode":0,"data":"A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A59000"}}
+{"type":"event","payload":{"online":true}}
+{"type":"apdu","payload":{"ecode":2}}
+{"type":"apdu","payload":{"ecode":0,"data":"6A88"}}
+{"type":"apdu","payload":{"ecode":0,"data":"AABB9110"}}
+{"type":"event","payload":{"online":false}}
+{"type":"apdu","payload":{"ecode":4}}
+{"type":"apdu","payload":{"ecode":0,"data":"9000"}}
+{"type":"apdu","payload":{"ecode":0}}
+{"type":"apdu","payload":{"ecode":0}}
diff --git a/tests/scard_stdio/scard_stdio_test.ok b/tests/scard_stdio/scard_stdio_test.ok
new file mode 100644
index 0000000..2e653d9
--- /dev/null
+++ b/tests/scard_stdio/scard_stdio_test.ok
@@ -0,0 +1,13 @@
+{"type":"apdu","payload":{"func":"connect","param":null}}
+{"type":"apdu","payload":{"func":"logic_channel_open","param":"A0000005591010FFFFFFFF8900000100"}}
+{"type":"apdu","payload":{"func":"transmit","param":"83E2910003BF2200"}}
+{"type":"event","payload":{"event":"profile_changed"}}
+{"type":"apdu","payload":{"func":"logic_channel_open","param":"A0000005591010FFFFFFFF8900000100"}}
+{"type":"apdu","payload":{"func":"transmit","param":"82E2910003BF2B00"}}
+{"type":"apdu","payload":{"func":"transmit","param":"82E2910003BF2D00"}}
+{"type":"event","payload":{"event":"profile_changed"}}
+{"type":"apdu","payload":{"func":"logic_channel_open","param":"A0000005591010FFFFFFFF8900000100"}}
+{"type":"apdu","payload":{"func":"transmit","param":"C0E2910003BF2B00"}}
+{"type":"apdu","payload":{"func":"logic_channel_close","param":"04"}}
+{"type":"apdu","payload":{"func":"disconnect","param":null}}
+{"type":"apdu","payload":{"func":"connect","param":null}}
+3 -3
View File
@@ -50,12 +50,12 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=wwand PKG_NAME:=wwand
PKG_VERSION:=1.6.8_p23 PKG_VERSION:=1.6.8_p25
PKG_RELEASE:=57 PKG_RELEASE:=58
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git
PKG_SOURCE_VERSION:=cf8d5cf1cc5767d58824a02c61de4d9d2ff58cf6 PKG_SOURCE_VERSION:=cd55f6dd5c0b81d47f7dd6f8a07ab69b58bd1061
PKG_MIRROR_HASH:=skip PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only PKG_LICENSE:=GPL-2.0-only