Extension packages¶
An extension package is software that is not part of the firmware image and
is installed on the machine by its owner. forgeext, the extension host,
verifies a package, unpacks it, and keeps the record of what is installed.
It is a separate program from forgectrl on purpose:
manifest parsing, archive handling, and everything else that reads what an
outsider wrote stay out of the process that holds the cooling engine and the
supervisor.
Two other kinds of extension need no package at all: the built-in extensions are part of the image, and a program on another computer uses the remote API with a scoped token.
What a package's service runs inside (the account pool, the cgroup tree, the network rules) is part of the image: the extension sandbox.
The archive¶
A package is one file, <name>.ffx: an fwup
archive, the container the firmware itself ships in. It is a ZIP whose
entries are, in this order, meta.conf.ed25519 (the Ed25519 signature,
absent when the archive is unsigned), meta.conf (the metadata), and
data/payload.tar.gz, the one resource. The payload is a gzip-compressed tar
of the package's files with manifest.json at its top.
tools/mkffx.sh in the forgeext repository packs a directory:
The key pair comes from fwup -g. The script builds the payload the same
way every time (sorted names, owner 0, time 0), signs when it is given a key,
and checks the signature against the key's public half before it reports
success.
What the verifier takes¶
Firmware and packages share a container and a verifier, so the first thing the extension host is, is a product gate:
meta-productisForgeFIRM extension, exactly.- The archive lists no task. A task is what fwup would write to a disk.
- The metadata holds
meta-*lines and onefile-resourceblock forpayload.tar.gz, and nothing else. Every line is accounted for or the archive is refused. - An archive whose only valid signature is a key that signs firmware (the release key, the factory keyring) is refused outright.
- The archive holds its metadata and that one resource, in that order, and no other entry.
forgectrl's firmware paths hold the other door: they take firmware only.
The bytes that are parsed are the bytes that were verified. The host
reads the archive the way fwup does (streaming, the local headers, in
order), checks the signature itself over the meta.conf it took, and hashes
the payload against the BLAKE2b-256 that same meta.conf names. fwup is
asked as well (-m, -l, -V), because it is the program a firmware
archive would be handed to, and its reading of the product, the version, and
the task list must agree.
The payload is unpacked where nothing in it can reach outside. Regular
files and directories only: a symbolic link, a hard link, a device, or a
fifo refuses the package. Every path is relative, with no .., printable
ASCII, at most 192 characters and 16 directories deep. Modes are reduced to
0644 and 0755; whatever the archive said about setuid bits or owners is
dropped. The limits:
| Limit | Value |
|---|---|
| The archive | 32 MiB |
| The payload unpacked | 64 MiB, one file 32 MiB, 4096 files |
| Every installed package together | 256 MiB |
| Free space an install must leave | 576 MiB, what a firmware update may need |
| Installed packages | 64, of which 32 may run a service (the account pool) |
A refusal leaves nothing behind: staging lives under the root's tmp/ and
is removed on every path out.
Trust tiers¶
| Tier | Signed by | What an install takes |
|---|---|---|
| Official | The OpenGlow extension key in the image (/etc/forgefirm/keys/ext/forgefirm-ext.pub), a different key from the one that signs firmware |
Nothing beyond the grants below |
| Community | A key the owner added under /data/forgefirm/ext/keys/, or the author's key that the signed index endorses for that package's id |
The operator's typed consent |
| Unverified | Nobody this machine trusts: unsigned, or signed by a key it does not hold | The machine button held, as for unsigned firmware |
- The ids under
org.openglow.andorg.forgefirm.belong to the official tier: an archive that claims one and is not signed with the OpenGlow extension key is refused. - An update is signed by the key that signed the installed version. A package cannot change its signer, or lose its signature, by updating; the owner removes it first.
- Nothing updates itself. An update is the operator's act, and what it asks for that the installed version did not is shown as new.
The signed index¶
The catalog the panel shows is one file, index-1.ffi: an archive of the
package container's own form whose meta-product is
ForgeFIRM extension index, signed with the OpenGlow extension key and by
nothing else, holding one file, index.json, of at most 1 MiB. Its
meta-version is the index's own version, YYYY.MMDD.N. No owner key and
no endorsed key speaks for an index, and the product gate holds both ways:
an index is never a package, and a package is never an index.
One index serves every firmware. index.json is
{"index": 1, "packages": [...], "withdrawn": [...]}. The 1 is the
schema, and the file's name carries it too: a catalog that changes its form
publishes index-2.ffi beside index-1.ffi, and each machine goes on
reading the file its firmware knows. A field the host does not know is
passed over at every level, so a catalog that adds one is still kept by an
older machine.
The index lists at most 512 packages, each id once:
| Field | Holds |
|---|---|
id |
The package id |
name, author |
Text, at most 64 and 128 bytes |
description, license |
Text, optional, at most 256 and 64 bytes |
homepage |
Optional, https:// |
key |
The author's Ed25519 public key, as fwup writes it: required for an id outside OpenGlow's namespace, and refused for one inside it |
versions |
1 to 16 listed versions, each version once |
withdrawn |
Optional: at most 64 versions that OpenGlow withdrew, each {"version", "reason"}, none of them also listed |
Each listed version:
| Field | Holds |
|---|---|
version |
The version |
url |
Where the archive is fetched: https://, no user or password, at most 1024 bytes |
sha256, size |
The archive's SHA-256 (64 lowercase hex digits) and its size in bytes |
capabilities |
What its manifest asks for: at most 64 names, each printable text with no space |
api |
Optional: the extension API its manifest names |
core |
Optional: the firmware range its manifest names, {"min", "max"}, each a version. The catalog can narrow a range after the listing, and never widens it |
The top-level withdrawn names the packages that OpenGlow withdrew whole,
at most 512, each {"id", "key", "reason"}. The key follows the rule of a
listed package, and no id is both listed and withdrawn.
An index whose form breaks any of these rules is refused whole, and the index kept stays. The form is all that keeping an index judges.
Judged when read. Whether a version runs on this firmware is judged
when the index is read (forgeext index), never when it is kept. Thus a
newer catalog never stops an older machine from keeping it, and after a
firmware update the machine sees what it now runs with no new fetch. A
version is usable when all of these are true:
- The firmware's version is inside the version's
corerange. A dev image's version is a build stamp and not a version, so no range is judged there, and the answer'score_checkedsays so. - Its
apiis the extension API this firmware serves. - This firmware has every capability it asks for.
- Its size is at most what this firmware takes, 32 MiB.
Each version gets usable, and why when it is not usable. Each package
gets offer, the newest usable version, or null. With no offer, the
package also gets why: the newest listed version, and what keeps it off
this firmware. The judgment belongs to the reading, and it is never written
into the index kept.
The endorsement. A package signed with the key that the index names for
its id reads as Community on a machine whose owner never added that
key, and inspect says endorsed: true. One author's key can be named for
several ids, and it speaks for each of them. It speaks for those ids alone:
an archive under another id signed with it is judged as signed by nobody.
Pinning holds as for every package: an update is signed by the key that
signed the installed version.
Never back. An index older than the one kept is refused, however well it is signed. An index that OpenGlow signed once stays signed, so a copy of an old one would list again what was withdrawn after it. The same version again is kept.
Withdrawn. A withdrawal names the listed package: its id, signed with the key that the index names for it (in whichever key directory the machine finds that key), or with the OpenGlow extension key in OpenGlow's own namespace. An archive of the same id under another key is another package.
- A withdrawn version does not install, from the catalog or from a
file:
inspectandinstallrefuse it in OpenGlow's words, with its reason. - A package withdrawn whole is out of the catalog, and its key endorses
nothing. An archive signed with that key reads as unverified, unless the
owner added the key. Then the owner's key speaks for it, and
inspectshows the withdrawal (withdrawn, with the reason) for the owner to judge. - An installed copy of either stays installed. The machine removes nothing
on its own, and
listnames the withdrawal:withdrawnis{"scope": "version" or "package", "reason"}, or null. An update of a package withdrawn whole is judged as signed by nobody, and pinning refuses it.
Kept. forgeext index-verify checks the archive, lays out index.json
(with the index's version and each named key's id added) and one
keys/<id>.pub for each listed package's key, and swaps them in whole under
the host's lock. A change of owner leaves the index: it is OpenGlow's, not
the owner's.
Where it comes from. forgectrl fetches it only when the operator asks
(The catalog), from one fixed address:
https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/index-1.ffi.
The hosting is not trusted; the signature and the version are. How a
package is listed, and how the index is built and published, is the
listing policy.
The acceptance test exthost.catalog proves it on the image. On a scratch
root, the machine's own forgeext keeps an index signed with a stand-in
for the extension key, and the key it endorses makes that one id community
and no other. Read back, a version that asks for a capability the firmware
does not have is kept and not offered, and the offer is the newest version
it runs. An older index is refused, and an index that withdraws the probe's
version makes the probe refuse to install. On the machine's own root, the
host refuses every index that key did not sign, and a package handed over
as one. forgectrl's catalog routes refuse before they fetch, and leave
nothing staged. The test never requests the index's address, because
GitHub counts every request of it as a download, and that count is the
operators'. The fetch itself is proven by forgectrl's host test with a
stand-in for curl.
The manifest¶
manifest.json is the one thing a package says about itself. The parser is
strict on purpose: an unknown key, a duplicate key, a field of the wrong
type, or a capability listed twice is a refusal in words, never a default,
so that a typo cannot silently drop a capability the operator was meant to
see.
{
"manifest": 1,
"id": "org.example.notify",
"name": "Notify",
"version": "1.2.0",
"description": "Tells you when a job ends",
"author": "Someone",
"license": "MIT",
"homepage": "https://example.org/notify",
"api": "0.1",
"core": { "min": "0.0.7" },
"runtime": "python",
"service": { "exec": "bin/run.py", "args": ["--quiet"] },
"modes": ["grbl", "cloud"],
"capabilities": ["events", "machine.read", "net.outbound:mqtt.example.org:8883", "storage:8"],
"conflicts": ["org.example.other-notifier"]
}
| Key | Rule |
|---|---|
manifest |
The schema number, 1 |
id |
Reverse-DNS, lowercase: two or more labels of a-z, 0-9, and -, each starting with a letter; 63 characters in all. It names the package's directories |
name, author, license |
Required text. description and homepage are optional |
version |
MAJOR.MINOR.PATCH with an optional -prerelease. The archive's own meta-version must say the same |
api |
The extension API the package was built for, MAJOR.MINOR. This firmware serves 0.1. A 0.x API carries no stability promise, so the minor must match exactly |
core |
Optional min and max firmware versions. A package outside the range is refused at inspect and at install, when this firmware has a version to judge by (The firmware a package needs) |
runtime |
data (nothing executes), ui (runs in the operator's browser), shell, native (an ARMv7 hard-float binary, static or linked against the C library alone: libc.so.6 and libm.so.6, with no symbol newer than the image's glibc 2.39), or python (inside the release image's module list) |
service |
exec, a path inside the package, and optional args. Required for shell, native, and python; refused for data and ui. The entry point must be a file of the package, and executable for native |
modes |
grbl, cloud, or both (the default) |
capabilities |
Required, and may be empty. See below |
conflicts |
Ids this package cannot be installed beside. The refusal works in both directions |
Capabilities¶
The list is closed and compile-time. A manifest that asks for anything else
is refused, and nothing a manifest can spell reaches a privileged role or a
provider kind: role:homing and role:controller are refused by name,
because both are part of the firmware, and no other role exists.
Served means the extension API has a route for it today (The extension API). Every capability this firmware offers is served; the column stays because the vocabulary is settled ahead of the routes, so that a package's manifest and the operator's consent do not change shape as each one lands.
| Capability | Grants | Served | The operator's own grant |
|---|---|---|---|
machine.read |
The machine's status, cooling status, and mode | yes | |
events |
The machine's events | yes | |
hold |
Holding a job until the package clears the hold (pause tier only) | yes | required |
settings.own |
The package's own settings, declared in its manifest (A package's own settings) | yes | |
camera.lid, camera.head |
That camera's frames, under the privacy gate: one frame, never while a job is armed, or the video on its page (A package's camera) | yes | |
motion.jog |
Dark jogs inside the jog bounds, and canceling one (A package's jog) | yes | |
motion.job |
Running a program as the machine's one sender, under every arm gate (A package's job) | yes | required |
sender.keep_out |
Keeping the Grbl sender out while the package uses the machine, on an idle machine, holding the machine lease (A package keeps the Grbl sender out) | yes | required |
job_time.run |
Not being frozen while a job is armed | yes, as a limit the host applies | required |
ui |
A page of its own, a card on the panel's Extensions tab, in a sandboxed frame (A package's own page) | yes | |
ui.background |
Its page kept running while the panel shows another tab, from the moment the panel opens. Asked for with ui only (A package's own page) |
yes | required |
net.outbound:<host>:<port> |
One named destination: a lowercase DNS name, an IPv4 address, or an IPv6 address in brackets. Never the machine itself | yes, as a rule the host installs | |
net.outbound.operator |
The destinations the operator names for it, and no others (The operator's destinations) | yes, as a rule the host installs | |
net.listen:<port> |
One listening port, 1024 to 65535, never one of the firmware's, and one package per port. It answers callers and is no way out (the deny rules) | yes, as a rule the host installs | |
storage:<MiB> |
How much its data directory may hold, 1 to 256 MiB. Every service has a data directory; this says how large it may grow (The storage quota) | yes, as a limit the host enforces | |
wizard |
A check of its own on the Setup page, run on the setup's own runner, its result the package's (A package's check on the Setup page) | yes | |
mcode:<n> |
Answering M<n> in a job, one of M160 to M179: the job waits at it until the service answers (A package's M-code). One package per number, and a package that asks for one asks for job_time.run too |
yes | (job_time.run's) |
motion.offsets is named in the list but is not offered: a manifest
that asks for it is refused in those words, which is a different refusal
from the one a name the list does not hold gets. Nothing this firmware
serves is reached by it.
At most 15 outbound destinations take effect for one service, the manifest's and the operator's together: the host's rule chain and the sandbox's port list each hold 16, one of which a DNS lookup may take. A manifest naming more is installed, and the ones past the fifteenth are dropped when the service starts.
Three rules tie capabilities to the runtime. A data package holds none.
hold, job_time.run, net.outbound, net.outbound.operator,
net.listen, storage, mcode, wizard, and sender.keep_out belong to
a service, so a ui package cannot hold them. And the capabilities
marked required above are never implied by a tier: the operator grants
each one, per package, at install, and an install that lacks a grant, or
carries a grant for something the package did not ask for, is refused.
What is installed¶
The extension root is /data/forgefirm/ext:
| Path | Holds |
|---|---|
pkg/<id>/<version>/ |
The package's files, root-owned, never written again |
pkg/<id>/<version>.files |
What was unpacked: BLAKE2b-256, mode, size, and path of every file |
pkg/<id>/current |
A symbolic link to the version in use |
data/<id>/ |
A service's own data, mode 0700, owned by its account |
keys/ |
Public keys the owner added |
index/ |
The signed index as last verified: index.json, and keys/<id>.pub for each key it endorses |
tmp/ |
Staging, the host's alone |
required-holds/ |
One empty file per package whose hold the operator marked required, so a hold fails closed across a reboot before the host has read anything |
settings/<id>.json |
A package's own settings, root's alone at 0600 |
lock |
The host's lock, taken around every change to the root |
state.json |
What no package can say about itself: the tier and the key that signed it, its account of the pool (ffx0 to ffx31, the lowest free one), the operator's grants, enabled, quarantined, and a stamp every install writes anew |
An update keeps the version it replaces; the one before that is removed. state.json is written whole and renamed into
place, and a state file this program did not write (another schema, an
account outside the pool or held twice, a grant for a capability that needs
none) is refused whole rather than guessed at.
The integrity check compares an installed tree with its .files list:
every listed file present with its hash and mode, and nothing else there. A
changed file, a changed mode, an added file, or an added link fails it.
The extension host¶
forgeext run is the extension host: the daemon that runs the services of
the installed packages. The image starts it after forgectrl
(/etc/init.d/forgeext) and it runs whether extensions are on or off, so
turning them on or off never starts or stops a system service.
The master switch is the setting ext_enabled (0 or 1, default 0),
which the host reads from the settings file on every turn of its one-second
loop. While it is 0, no service runs. forgectrl takes ext_enabled=1 only
over the Extensions advisory
(forgectrl). Safe mode is the
file /run/forgefirm/ext-safe, made as root at the console: while it
exists no service runs, whatever the setting says, and it is gone at the
next reboot.
What the host knows about the machine it reads and never writes: the
armed window from GET /cool/status, the controller mode from GET /mode,
a running diagnostic from GET /status, and a firmware flash from
GET /update/status, all on forgectrl's read-only loopback port. What it
cannot read it takes the careful side of: an armed window it cannot see is
open, and a machine it cannot ask is not ready for a new process. A root
that a package's account cannot walk to (a directory on the way to
/data/forgefirm/ext without the search bit for others) is also a machine
that is not ready: the host names the directory in not_ready and in its
log and starts nothing, because a service started there would end at once
and be quarantined for a fault that is not its own.
What a service runs inside. The host forks, and before one instruction of the package runs, the new process:
- is in the package's own cgroup under
/sys/fs/cgroup/ffx(25 percent of the core, 48 MiB, 32 processes); - is idle-class in every scheduler (
SCHED_IDLE, nice 19, I/O class idle) and first in line for the OOM killer (oom_score_adj900); - holds no descriptor but
/dev/nulland its log pipe, with limits of no core file, 256 descriptors, 64 processes, and 1 GiB of address space; - has the package's pool account (
ffx0toffx31) and no group but its own, and can gain nothing by exec (no_new_privs); - sees of the file tree only the system's read-only parts, its package (read and execute), and its data directory (landlock);
- connects and binds only on the TCP ports it declared (landlock), and
sends only where its chain in the image's rule table allows, which is
never the machine itself
(the extension sandbox). A
package that named a destination by DNS name rather than by address is
given port 53 as well, to the resolvers of
/etc/resolv.confand over UDP and TCP, and that file is handed in as one more read-only path: a name it cannot look up is a destination it cannot reach; - cannot make the system calls a package has no business making, and can open only UNIX, IPv4, and IPv6 sockets (seccomp).
A step that cannot be taken is a failed start, never a looser sandbox. The
environment is fixed: PATH, LANG, HOME (the data directory),
TMPDIR, FFX_ID, FFX_PKG, FFX_DATA, FFX_API (its
API socket), PYTHONDONTWRITEBYTECODE, and
PYTHONUNBUFFERED, and FFX_CALL_FD for a service whose package has a
page (its page's calls). A service's output goes
to the forgeext logger under the package's id, at most 60 lines in 10
seconds, with a count of what was dropped.
The rules of the supervisor:
| Rule | Value |
|---|---|
| A service starts only when | extensions are on, the controller is up, motion is verified, no diagnostic runs, and no firmware is being flashed; never inside an armed window |
| Starts | one at a time, 5 s apart, at most 4 services running; of the services that are due, the one that has ended least lately goes first |
| A service that ends | is started again after a backoff that doubles from 1 s to 30 s |
| Healthy | after 60 s of running: the backoff starts over, and the package's previous version is removed |
| Quarantine | at the fifth end inside 10 minutes without reaching healthy; remembered in state.json until the operator lifts it |
| A service that should not run | (disabled, removed, quarantined, the wrong controller mode, extensions off) is stopped: its group is killed and removed, and its chain and map element are taken out of the rule table |
| A service whose install (a new version, or the same one removed and installed again), or whose operator's destinations, changed | is stopped and started again with them, outside an armed window; that is not an end that counts toward quarantine |
| Extensions off, safe mode, the host stopping | ext.shutdown is put in the event feed, and the services are stopped one second later (The events a package reads) |
The armed window. While the window is open, every service is frozen
with its cgroup's cgroup.freeze, and it is thawed when the window closes.
A package with the operator's job_time.run grant is not frozen: it runs
on at 3 percent of the core. The freeze follows the armed field of
GET /cool/status, read once a second. On the bench reference, across a
37.5 s window, the service's group read frozen 0.4 s after the field turned
on, 5 s before the latch unlocked for the run, and thawed 0.6 s after it
turned off.
A service that does not freeze is not left running. The host waits 300 ms
for the kernel's frozen flag and asks again at its next turn; after three
turns the service is quarantined, with "it could not be frozen for the armed
window" as the reason, and the operator's enable is what lets it out. The
freeze is what keeps a package off the step stream, so a group that will not
take it is a defect and not a passing condition. Job-time limits that will
not take fall back to the freeze the job_time.run grant lifted, and the
service is quarantined only when neither takes. A thaw that fails costs
nobody but the package itself: it is logged and tried again at the next
turn.
Holds. A package with the operator's hold grant may withhold fire
and never permit it (An extension's hold
has the engine's side: the verdict EXT, the pause tier, and the
operator's exits). The host owns the hold, not the package, so a package
that is frozen for the job changes nothing about it. For every enabled
package with the grant that runs in the controller mode in force, the host
keeps /run/forgefirm/holds/<id>.json. A thread that does nothing else
rewrites the files twice a second, and only while the main loop has spoken
within 15 s: a turn that takes a few seconds pauses nobody's job, and a
loop that hangs lets every hold go stale, which is what it is.
The operator marks a granted hold required or advisory
(forgeext hold <id> required|advisory; advisory until marked), and the
package is then named under <root>/required-holds/ for the engine:
| The package | Advisory | Required |
|---|---|---|
| runs, and has run healthy | What it last said | What it last said |
| has started and not yet run healthy | What it last said | Raised by the host: "the extension has only just started". A package that ends at every start reads as running for a moment each time, and a required hold does not flicker clear with it |
| should run and does not (ended, waiting, quarantined, the machine not ready) | Dropped, and logged once | Raised by the host: "the extension is not running" |
| the host itself gone, however it went | Dropped by the engine | Stands in the engine, on the stale file or on the name alone |
A running package raises and clears its own hold over its
API socket (POST /v0/hold); its word starts clear
with every start of its service. Extensions off and safe mode take every file away, and the engine does not
look at the names then; a package that is disabled or removed loses its
file and its name. Those are the operator's exits. A clean stop of the
host removes the advisory files and leaves the required ones to go stale.
status.json and forgeext list say which hold is which.
One host, and nothing it did not start. The host holds a lock
(/run/forgefirm/ext/daemon.lock) for its lifetime, and a second one is
refused. A host that ended without stopping its services would leave them
running with nobody to freeze them, so the init wrapper kills every group
of the pool the moment the host ends abnormally, and a starting host
removes every group and every allowlist chain it finds before it starts
anything.
The status file /run/forgefirm/ext/status.json is rewritten when it
changes: pid (the host that wrote it; the file outlives a host that was
killed, and a pid that is not a running host marks it as a dead one's),
enabled, off_reason, armed (null when it cannot be read),
not_ready, events (connected, and wanted: the running services
that hold the events capability), and per service id, state
(stopped, running, waiting, quarantined), account, pid,
frozen, job_limited, healthy, hold (required or advisory, when
it has one), and reason.
The acceptance test exthost.service proves the host on the image with a
reference package it builds and signs on the board: the confinement seen
from inside the service (its API socket included), safe mode, a killed
host, and the master switch.
exthost.hold-pause-tier takes a required hold from the grant to the
engine's verdict and out through each of the operator's exits, the
package's own raise and clear over its API socket included.
exthost.armed-freeze opens a real armed window over it with a dark cloud
print and samples the engine's flag, the group's state, the service's
heartbeat, and the latch five times a second: frozen from 2 s in to the
close with the heartbeat still, the freeze in place before the latch unlocks
for the run, thawed within 3 s of the close, one process throughout.
exthost.package-routes drives the operator's door against the host's
own state, and exthost.panel-install installs through it at each tier's
consent, the button held included. exthost.page-call carries a page's
calls to its own service through the relay. exthost.events proves the one
subscription and the poll a package reads it with. exthost.platform
proves the image holds the sandbox ready before any of it runs.
setup.extensions-consent proves the consent
(Release acceptance).
The operator's door¶
forgectrl is the machine's one front door, and the extension host is the only program that reads what a package brought, so the panel's package routes are a relay: forgectrl runs the host's command line (below), which answers in JSON, takes the host's lock around every change, and is what root at the console runs too. The host's daemon picks a change up on its next turn. Nothing of a request reaches a shell: the command is an argument vector, a package id is checked against the form of one before it is an argument, the actions are a closed list, and the child inherits none of forgectrl's descriptors (it holds the pulse device).
GET /ext/status answers enabled (the master switch), safe_mode,
host (the host's own status file with running: true, or running:
false alone when no host is alive behind it), and packages (the host's
list: id, version, tier, key, enabled, quarantined, grants, the hold's
kind, the account, the manifest, withdrawn (what the kept index says
OpenGlow withdrew of it, or null), and effective - the capabilities the
package may use, which is what needs no grant together with what the
operator granted. That last one is the list anything deciding what a
package may do reads, the panel's bridge included: the manifest's own
list is what was asked for, which is a different question), and destinations, the ones the
operator named for it. POST /ext/package takes id and
action: enable (which also lets a package out of quarantine),
disable (its service stops and its hold goes: the way out of a hold it
has on a job), remove, remove-keep-data, hold-required,
hold-advisory. POST /ext/dest takes id, action (add or remove),
and dest, and names a destination for a package or takes one away
(The operator's destinations).
Three more routes are the panel's alone, and all three refuse while
extensions are off: GET /ext/ui hands over a package's own page
(A package's own page), GET and
POST /ext/settings read and patch a package's own settings
(A package's own settings), and
POST /ext/call carries a page's call to its own service
(A page and its own service). They are what
the frame's bridge calls reach, never the frame itself.
Installing through the panel¶
Two requests. POST /ext/upload stages one archive (a second upload
replaces it; POST /ext/upload/discard removes it) and asks the host what
it is: the answer is the host's inspect (the tier, the manifest, what
needs a grant, what is new against the installed version, whether it is a
downgrade) with consent added. An archive the host will not take is
refused in the host's words and not kept. POST /ext/install then names
the grants and carries the consent, and forgectrl asks the host again what
the staged file is, because the tier is never the request's to say:
| Tier | Installing takes |
|---|---|
| Official | The login |
| Community | The login and the typed phrase I UNDERSTAND |
| Unverified | The login and the machine's button held while the request is made, as for unsigned firmware; the phrase is no substitute |
forgectrl passes the host the consent it took (--consent-community,
--consent-unverified) and the grants, each checked against the form of a
capability name before it is an argument; which grants a package needs,
and that no grant is given that it did not ask for, is the host's to
enforce. The staged file goes with a successful install and stays after a
refused one, for another try.
The catalog¶
GET /ext/catalog answers the host's index: index, the kept document
judged against this firmware (judged when read), or
null when none is kept; core_version and core_checked, the firmware
version it was judged by; and url, the address the index is fetched from.
Nothing reaches the network until the operator asks:
POST /ext/catalog/refreshfetches the index with curl (httpsalone, redirects included, at most 2 MiB and 30 s, under curl's own name) into a file beside the staging file, has the host verify and keep it, removes the file, and answers asGET /ext/catalogdoes. A fetch that fails is502in curl's words, and the host's refusal is409in its words; the index kept stays as it was.POST /ext/catalog/get(id) reads the package from the kept index, never from the request, and takes the version the host offers: the newest one this firmware runs. It fetches that version's archive from itsurlinto the staging file, bounded by itssizeand 240 s. The bytes are held to that size and that SHA-256 before the host reads any of them. From there it is an upload: the answer is the host'sinspectwithconsentandcatalogadded, andPOST /ext/installfollows as above. Bytes that are not the listed archive, and an archive of another package or another version than the one offered, are refused with409and not kept. A package with no version this firmware runs is409, with the host's reason, before anything is fetched. An id the index does not list is404, and with no index kept it is409.
The fetch of a package is gated as the upload is (the machine idle, no lease in the way), and the one staging file is the upload's or the fetch's, never both at once. All three routes are the logged-in operator's, and none of them refuses while extensions are off.
The owner's keys¶
A package is judged by who signed it: the OpenGlow extension key makes it
official, a key under <root>/keys/*.pub makes it community, and anything
else is unverified. Those keys are the owner's, and the trust anchor is
theirs to replace, so POST /ext/key takes one only with the machine's
button held, the way unsigned firmware is installed. The key itself is
written to a file and handed to the host as a path, never as an argument,
and the host parses it as an Ed25519 public key (fwup's base64, or 32 raw
bytes) before it lands: what cannot be read as a key never becomes a trust
anchor. A name is letters, digits, dash, underscore, and dot, at most 48
bytes, and the file under keys/ is that name with .pub after it.
POST /ext/key/remove takes one away. A package installed under a key
that is then removed stays as it was: the key decides what an archive
reads as at the moment it is inspected, not what an installed package is.
GET /ext/status lists the keys with each one's id, the same id a
package's key names.
The operator's destinations¶
A package that talks to things on the operator's own network - a smart
plug, a Home Assistant host, an MQTT broker - cannot name them in its
manifest: only the operator knows where they are. Such a package asks for
net.outbound.operator, and the operator names each place it may reach,
per package, on the package's card (forgeext dest <id> add|remove
<host>:<port> at the console). A package that asks is given none until
then. Nothing a package does reaches the list: it is not a setting and not
a bridge call.
A destination is host:port in net.outbound's form. A numeric address
of the machine, loopback included, is refused when it is named; a name is
judged by what it resolves to when the service starts, and the rule table
refuses the machine whatever the list says. At most 8 are named for one
package, and never more than a service's 15 with its manifest's. An update
that still asks keeps them; one whose own destinations and the operator's
would be more than 15 is refused; one that no longer asks keeps none.
A change to the list starts a running service again at the host's next
turn, outside an armed window, and the new chain and the sandbox's ports
are its. GET /v0/self names every destination a service may reach, its
manifest's and then the operator's, and so does the bridge's self, so a
package's page can show the operator what is still to be named.
exthost.operator-destinations proves it on the image by dialing the
machine's own DNS resolver on TCP port 53: refused inside the sandbox until
the operator names it, connected once named, and refused again when it is
taken away.
A change of owner¶
The forgotten-password reset (Setup)
is where a machine changes hands, and the account step offers to take the
extension tree with it: every package, everything under data/, and every
key the last owner added. A package can hold that owner's tokens and
credentials, and a key of theirs would go on making their packages install
as community rather than as unverified. The offer is made only when there
is something to take and is ticked by default; the account is made either
way, and a wipe that fails is reported rather than refusing the account.
Everything under data/ goes, not only the directories the state names: a
package removed with its data kept leaves one behind, and it holds exactly
what the wipe is for. The root's own directories stay, and the supervisor
stops whatever was running when it next reads the state.
forgeext wipe is the command behind it. It is the reset's alone - no
route and no panel action reaches it, because an operator who wants one
package gone removes that package.
The extension API¶
A package reaches the machine through the host or not at all: no listener
of the machine answers a pool account
(the extension sandbox). Every
running service has one Unix stream socket, /run/forgefirm/ext/api/<id>.sock,
owned by root and the package's account at mode 0660, and named in its
environment as FFX_API. The account is the only one that can connect, the
socket says which package is calling, and the host checks the caller's
credentials against it anyway. The socket exists before the service starts
and goes when it ends.
The API is version 0.1 and carries no stability promise. One request per
connection, HTTP/1.0 or 1.1 in a closed form, JSON both ways:
- the request line is
GETorPOST, one space, an origin-form path of letters, digits,/,.,-, and_(no query, no escape, no..), one space, the version; - lines end in CRLF; no continuation lines, no transfer coding, one
Content-Length, nothing after the body; - the head is at most 4096 bytes and the body at most 4096.
What does not fit is refused with a status and a sentence
({"error": "..."}), never guessed at.
| Request | Needs | Answers |
|---|---|---|
GET /v0/self |
id, version, api, capabilities (the ones the package may use: those that need no grant, and its grants), and destinations (where it may connect: its manifest's, then the operator's) |
|
GET /v0/machine/status |
machine.read |
forgectrl's GET /status |
GET /v0/machine/cool |
machine.read |
forgectrl's GET /cool/status |
GET /v0/machine/mode |
machine.read |
forgectrl's GET /mode |
GET /v0/settings |
settings.own |
settings (every declared key with its value) and schema |
POST /v0/settings |
settings.own |
A patch of settings, applied whole or not at all; the same answer |
POST /v0/camera |
camera.lid or camera.head, for the one it asks for |
The body {"camera": "lid"\|"head", "resolution": "full"\|"half", "quality": 1-100, "lamp": 0-1023} (the camera required, the rest optional, and no other key); the answer is a JPEG, not JSON |
POST /v0/motion/jog |
motion.jog |
The body {"x":, "y":, "z":, "feed":} in millimeters and mm/min, each a number and no other key, at least one axis moving; the machine's answer. The host names the package to the machine, so while it keeps the Grbl sender out its jog passes the lease it holds |
POST /v0/motion/cancel |
motion.jog |
Ends a jog; the machine's answer |
POST /v0/motion/job |
motion.job, granted |
The body {"program": "<a file of its own data>", "lit_within_s":, "timeout_s":}; the machine's answer |
POST /v0/motion/job/abort |
motion.job, granted |
Ends the running job |
GET /v0/sender |
sender.keep_out, granted |
{"out": bool, "released": bool}: whether this package keeps the Grbl sender out, and whether the operator let the sender back in while the package still claims it |
POST /v0/sender |
sender.keep_out, granted |
The body {"out": true} or {"out": false} and nothing else keeps the Grbl sender out or lets it back in; the same answer, or the machine's refusal in its words |
GET /v0/hold |
hold, granted |
{"raised": bool, "reason": "..."} |
POST /v0/hold |
hold, granted |
The body {"raised": bool, "reason": "..."} (those two keys and no other; the reason at most 95 bytes of printable ASCII without the quote and the backslash) raises or clears the package's hold; the new state |
POST /v0/events |
events |
The body {"since": n, "wait": s} (those two keys and no other, both optional) asks for the machine's events after n, waiting up to s seconds for one; {"next": n, "dropped": n, "connected": bool, "events": [{"seq": n, "event": "...", "data": {...}}]}. See The events a package reads |
A capability the package does not hold is 403 in words, a path the API
does not have is 404, and the machine routes are relayed only as JSON
objects (502 when forgectrl does not answer, or answers anything else).
The broker runs on a thread of its own, so a slow answer from forgectrl
delays other packages' requests and never the supervisor's turn. Each
package has 20 requests a second (429 beyond that, after the request has
been read) and 4 of the broker's 16 connections; a request that has not
arrived in 5 s is 408.
A package's own page¶
A package that asks for ui ships one self-contained HTML file at
ui/index.html, at most 512 KiB. Asking for ui without the file is
refused at install, and shipping the file without asking for ui is
refused too. It is one file because the frame it renders in can fetch
nothing: there would be nothing to load a second file with.
GET /ext/ui hands the page to the panel as a JSON string. The daemon
composes no markup out of a package's file; the panel is what builds the
frame.
The page is a card on the panel's Extensions tab. The tab is there while
extensions are on, and it holds one card for each package that is turned on
and asks for ui, in the order GET /ext/status lists them. The cards
follow that list: a package turned off, removed, or no longer asking for
ui takes its card with it, and an updated one's page loads afresh. The
frames live only while the tab is shown, and leaving the tab removes
them, so a page nobody is looking at asks the machine for nothing and
nothing it does starts from a tab the operator has left.
The exception is the operator's grant of ui.background. Such a page
loads when the panel does, whatever tab it opens on, and keeps running on
every tab until the panel is closed, the package is turned off, or
extensions are. Whether a package holds it is read from its effective
list, the machine's word, never the manifest's. While any page runs, the
panel asks for GET /ext/status every 5 s. A browser may slow a page it is
not showing: Chrome runs a hidden frame's timers about once a second, and
what the page draws is not seen until the tab is.
The frame holds nothing. It is sandboxed without
allow-same-origin - with it, the page would hold the operator's
session and the panel token, and every other measure here would be
decoration. Without it the page is its own opaque origin: it cannot read
the panel's cookies, reach the panel's window, or navigate the top window.
The sandbox attribute alone does not stop a page reaching the network, so the policy travels in the document, as its first element:
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';
img-src blob: data:; connect-src 'none'; form-action 'none'; base-uri 'none';
webrtc 'block'
A package may add its own policy after that one and only make it stricter.
The page follows the panel's theme. The frame element carries the
panel's color scheme (color-scheme: light or dark, with the theme), and
in both browsers this project tests a framed page's prefers-color-scheme
answers with that scheme, live, as the theme changes. After the policy the
panel puts <meta name="color-scheme" content="light dark">, so a page
that names no scheme follows the panel; one that names its own keeps it.
The frame is transparent: a page that paints no background sits on the
card. The frame-isolation harness below checks both, with the panel's
theme set against the browser's own preference.
Where a frame may go is the panel page's to say, not the frame's. A
frame's own policy governs what it loads; it does not stop the page
navigating its frame to another address, carrying what it was shown in the
URL. Every page forgectrl serves therefore sends
Content-Security-Policy: frame-src 'none': no frame of the panel may
navigate anywhere. A srcdoc frame still renders under it, and the panel
frames nothing else.
The proof is a browser harness, because what it tests is the browser:
tools/frame_isolation.py in the forgectrl repository runs the panel as
its dev server's mock serves it, installs a hostile package whose page tries
every way out, and opens that page through the panel's own frame and bridge
(Test).
forgectrl.panel-serves holds the machine's own page to sending the header.
WebRTC is a documented leak. It is outside the Content Security Policy in both browsers this project tests, and connection hints get past it at low bandwidth. A package with a page can therefore send a little data out of the operator's browser. That is named here and on the operator's own page (Extensions), and a package without a page cannot do it at all.
The label above the frame is the panel's, the card's heading, outside the frame: the name and the trust tier are what the panel knows of the package, never what the package claims. When the panel knows neither, the label says the id and nothing more.
The bridge¶
The page reaches the machine only by asking the panel, over postMessage.
Three rules govern it:
- A frame is known by
event.source, never byevent.origin. Every sandboxed frame reports the originnull, so two open packages are indistinguishable by origin; identifying by origin would let either speak for the other. - The capability checked is the one the machine says the package may
use: the
effectivelist ofGET /ext/status, which is the same answer the package's own API socket gives a service atGET /v0/self. The manifest's list is what was asked for and is never the test. What a frame claims about itself is never an input either. - A package the operator disabled holds nothing. Disabling is the way out of everything a package does: its page stops being served, and its card goes at the panel's next refresh (every 5 s while the Extensions tab is shown) rather than being left talking to the bridge.
- No credential ever enters a message. The panel makes each call under its own session. A camera frame is fetched by the panel and handed over as bytes, so the camera key stays where it is.
| The page asks for | It needs | It gets |
|---|---|---|
self |
its id, version, tier, the capabilities it may use, and the destinations its service may reach (the same lists GET /v0/self gives its service); and units, the panel's display units (metric or imperial, Settings), so the page shows lengths as the panel does while every value it exchanges stays in millimeters |
|
machine.status, machine.cool, machine.mode |
machine.read |
the machine's own answer |
settings.get, settings.set |
settings.own |
its settings and their schema |
camera.frame |
camera.lid or camera.head |
the frame as bytes, taken as a background capture, so it yields to a viewer and is refused while a job is armed. It takes camera, and optionally resolution (full or half, the default), quality (1 to 100), and lamp (0 to 1023); a value outside those is refused by name, and nothing else in the message is carried |
camera.stream |
camera.lid or camera.head |
the camera's video: the panel reads /cam/stream under its own session and posts each picture to the page as a JPEG Blob ({stream: "camera", frame}), and {stream: "camera", end: true, error} when the machine ends it. It takes camera, and optionally fps (1 to 15, 5 when not given) and lamp (0 to 1023); on: false ends it. One stream a page: a new one replaces the last, and the stream ends with the page |
motion.jog |
motion.jog |
the machine's answer, under every bound the jog already has |
motion.cancel |
motion.jog |
ends a jog |
motion.job |
motion.job, granted |
a program the page wrote (program, at most 2 MiB, with the optional lit_within_s and timeout_s) run as the machine's one sender; who the job is from is the panel's word, the package's id, never a name the page chose |
motion.job.state, motion.job.abort |
motion.job, granted |
the job's record, and ending the running job |
frame.height |
the page's own frame made px tall, inside 200 to 1400 pixels; the label and the panel around the frame do not move |
|
service.call |
ui |
a call to its own package's service, whatever package the message names (method, path, and for a POST an optional body object); {status, body}, the service's own status and the JSON it answered (below) |
Anything else is refused by name, and a capability the package does not hold is refused in those words.
A page and its own service¶
A package that has a page and a service may have the one ask the other: a rules editor, say, whose service is what applies the rules. Nothing about it is a way out for either side. The page still reaches nothing but the bridge, and the service is still reached by nothing but the host.
The socket is the host's. For a service whose package asks for ui,
the host binds /run/forgefirm/ext/call/<id>.sock, root's socket at mode
0600 in a directory that is root's alone (0700), and hands the service
only the listening end, at descriptor 4, named in its environment as
FFX_CALL_FD=4. The service answers; it never names a path, so there is
no socket the host could be pointed at but the one it made. The name goes
when the service stops.
A call is one request per connection, in a closed form. The host
writes it: GET or POST, a path of letters, digits, /, ., -, and
_ (no query, no .., at most 200 bytes), and for a POST a JSON object
of at most 4096 bytes, which the host parses and writes out again ({}
when the page sent none; a GET carries none). The service answers
HTTP/1.1 with a status and JSON, at most 64 KiB in all, within 10 s. What
comes back to the page is the service's own status and its JSON: a
service's refusal is its own, not the relay's.
What refuses a call, in words: a package that is not installed, one
that is disabled ("its service is not running"), one with no page or no
service, a service that is not running, one frozen for the armed window
("its service is frozen while a job is armed", said at once rather than
after the wait), an answer that is late, too long, or not JSON. A package
with the job_time.run grant is not frozen and answers through the
window.
The panel's bridge makes the call as POST /ext/call (id, method,
path, body) under its own session, with the package's id from the
frame that asked. forgectrl holds the method, the path's leading /, and
the body's being an object before anything runs, and the host's command
forgeext call judges the rest; the kit's ffx.serve() (Python) and
ffx_serve_one() (C) are the service's side
(Write an extension package).
exthost.page-call proves the relay on the image, forgeext's sdk_test
the socket, both clients of the kit, and a frozen service, and the
frame-isolation harness that a page's call is its own package's whatever
the message names.
A package's jog¶
A jog is the one call in this API that moves the machine, and it is the only motion a package reaches: there is no arbitrary G-code here, and a jog is the one motion that ships dark whatever the laser's modal state is.
It is bounded twice. The host refuses a jog past the bounds without asking the machine, and the machine refuses it again: the machine owns them and is the only thing that can enforce them, and the host keeping to the same numbers means a package's mistake never becomes a line the machine has to turn away.
| At most | |
|---|---|
| X and Y, one jog | 100 mm |
| Z, one jog | 5 mm |
| Feed | 10 to 12000 mm/min |
Everything the machine already enforces still stands and is the machine's to enforce: GRBL mode only, the machine lease, and the rule that a sender at the controller port always wins - a line from a Grbl sender cancels a package's jog. The one exception is a package that keeps the sender out (below): no sender is connected then to win.
The lid being open does not stop a jog, and a jog is motion the operator did not ask for. That is said plainly in the Extensions advisory, because it is the one thing a package does that an operator can be standing inside.
How the host is allowed to ask¶
A jog is a write, and the machine grants a write to nobody without a
credential. The host has one of its own: forgectrl mints it fresh every
time the daemon starts, it holds only what the host may relay, it is
never in the store and never in the operator's list of tokens (it is not
theirs to manage, and revoking it by accident would stop extensions), and
it dies with the daemon. It is written to /run/forgefirm/ext-host.token
at mode 0600, which an extension account cannot read - and could not use
if it could, since a pool account cannot reach a loopback listener at all.
forgectrl takes it from a loopback peer only.
The panel token is deliberately not used for this: it reaches every route, so a flaw in the host or its broker would reach every route too.
A package's job¶
motion.job is the widest capability a package can hold, and it is one
of those the operator grants by hand, whatever the package's tier.
A program is named, not sent. The request reader takes 4 KiB of body
and a program is not that, so a package writes its program into its own
data directory and names the file. The name is a file name with no
directory in it, and the host resolves it and checks the result is
really inside that package's data directory - a link or a .. out of it
is not a program the host will read. The program is at most 2 MiB, which
is also what the package's storage quota has to hold.
Who the job is from is the host's word, not the package's. The job carries the package's id as its sender. A package that could name the sender could make a job look as though it came from somewhere else.
Everything the machine already does to a sender it does to this one, and those are the machine's to enforce: the job runs under the machine lease, it is refused while a sender is connected (a Grbl sender holds TCP 23 outside the lease), and every arm gate and the button press stand. A package that keeps the sender out holds the lease itself, and its own job runs under that lease. A program that commands no laser never opens an armed window, so there is nothing for a press to arm; the gates are in force and are simply never reached.
A package's M-code¶
M160 to M179 belong to packages. A package that holds mcode:<n> answers
M<n> in a job: the exhaust confirmed on, the air assist's pressure, a
door, a count. Each number is one package's (an install that asks for a
number another installed package holds is refused in words), and the
package asks for job_time.run as well, which the operator grants: the
M-code is answered in the middle of a job, while the armed window freezes
every other service.
Nothing answers it: the job stops where the line is parsed. The GRBL
controller takes one of these numbers only while forgectrl says something
answers it now, and every other one is the core's error:20, as for any
unsupported command, before the job's motion reaches it. forgectrl tells
the controller the numbers the host's running services answer, from the
host's status file, whenever they change and every 2 s (so a controller
that restarted learns them again).
Something answers it: the job waits there. The M-code is a
synchronized barrier. The core drains the planner before it runs it, which
means every step is the stream's, not that the head has stopped: the kernel
is still playing the last move's tail out of its ring. So the controller
waits for the kernel to go idle (at most 5 s, or the job is held) before it
announces the M-code, and from then on the head is still and the stream
carries no fire: the laser is dark for as long as the job waits, by the
stream's own rule for a starved planner. The controller puts the M-code in
its port's state with its P, Q, and R words (The controller port) and pumps the protocol as
a dwell does, so status reports, a feed hold, the door, and a soft reset all
work, and a reset or an alarm ends the wait at once. forgectrl's relay sees
it, and asks the host (forgeext mcode), which asks the service on its call
socket, the one a page's calls use:
A package that answers M-codes has the socket (FFX_CALL_FD) whether or
not it has a page. A 2xx answer lets the job go on, and its message, if it
has one, goes to the sender as [MSG:M160: done: <message>]. Any other
answer, the host's refusal of the call (the package disabled, frozen, or
never granted job_time.run), or no answer in 30 s holds the job with a
[MSG:] that says why: the operator resumes it, and it goes on without what
the M-code was for, or stops it. The host gives the service 20 s, so its
answer, or its silence, comes back inside the controller's 30. The 30 s
stays under laser_disarm_s's default of 60, so a job waiting at an M-code
keeps its armed window. A port jog is refused while a job waits
(busy:mcode): the job is Idle there, and it is still the job.
The driver's mcode_test.py harness proves the controller's side on the
null-sink build: the table and its refusals, error:20 for a number
nothing answers, the wait with the head still and a port jog refused, the
answer's words, a refusal and a timeout each holding the job, a reset
ending the wait, and a wait under an open armed window with M3 S500
shipping no FIRE tick. forgectrl's mcode_test and forgeext's sdk_test
and install_test prove the relay and the host. On the machine,
exthost.mcode runs dark jobs through a package that answers M160: the
job waits with the head still and goes on after the answer, a job naming
M161 stops at it with nothing moved, and a refusal holds the job.
A package's check on the Setup page¶
A package that holds wizard adds a check of its own to the Setup page,
as pkg:<id>, after the machine's own checks. It runs on the setup's own
runner, with the same log, progress, prompts, and result a check of the
machine's has, and the runner asks the package's service for each step on
its call socket (forgeext wizard <id> state|start|answer|abort):
| Request | The service answers |
|---|---|
GET /wizard |
{"title": "...", "done": true or false}: the Setup page's list names the check with it |
POST /wizard/start |
The first step |
POST /wizard/answer {"id": "...", "answer": "..."} |
The next step, after the operator answered a prompt; a confirm is answered yes or no |
POST /wizard/abort |
Anything; the operator stopped the check, or it ran out of time or of form |
A step is {"log": [...], "phase": "...", "progress": 0 to 100,
"prompt": {"kind", "id", "text", "options"}}, the prompt's kind one of
continue, confirm, number, and choice, or, the last step,
{"result": {"ok": true or false, "summary": "..."}}. The runner holds a
step to its form (at most 8 log lines, a prompt's id a word, a choice with
two options at least, a text's length) and to 64 steps, and stops the check
with the reason, telling the service, when one is out of form.
The check is the package's, and nothing of the machine's is. Its result
is kept by the package, and the Setup page's list reads it back from
GET /wizard; the machine's setup record never holds it, and no package's
check is in the setup gate or is ever the next open step. It holds no
machine lease: whatever it does to the machine it does through the
package's own capabilities, under the lease like any other request of the
package's. It can gate only the package's own work.
exthost.wizard proves it on the image: the check listed, run through
its prompt with the lease free, the operator's Yes reaching the service as
yes, the result the package's summary and listed as done from its own
answer, the machine's setup record and gate unchanged, an abort reaching
the service, and a check of a package that is not installed refused.
forgectrl's wizpkg_test holds each step to its form and runs a check
end to end on stand-ins; forgeext's sdk_test runs the command against a
service.
A package's camera¶
camera.lid and camera.head are separate capabilities, and the one a
package asks for is the one it must hold: a package granted the lid's
camera is refused the head's in words.
The answer is a frame, not JSON. POST /v0/camera comes back as
image/jpeg with the bytes, or as a JSON error with a status. It is a
POST for the same reason the other calls are: the request reader
refuses a query string on purpose, so the body carries the parameters.
The privacy gate stands as it does for anyone else, and a package is told
so in the machine's own words: the lid camera captures nothing while the
lid is open. The head camera looks down at the bed, and the host asks
for a package's frames as a local viewer, so a package holding
camera.head has it with the lid open too
(the privacy gate).
A page can have the video. The bridge's camera.stream hands a page
the camera's pictures as they come, paced at the fps it asks for, so a
page can show the operator a live picture rather than a frame at a time.
The panel reads the stream under its own session: it is the operator's
own viewing, so it is not a background capture, and a package's own
frames yield to it like to any other viewer.
The lamp is the camera's own light, for one frame. lamp sets the
head camera's LED, or the lid camera's lamp, for the capture it is asked
with, and the machine puts its level back after it. A head-camera picture
of light wood wants little light and a dark material a lot, so the level
is the package's to choose; without it, the lamp stays where the machine
has it.
A package's capture always yields to somebody watching. A capture
borrows the camera mux for a frame and stutters a running stream while it
does, which is a fair price when a person asked for the picture and none
worth paying when a program did. Every capture a package makes is marked
as one nobody is waiting for, so while a stream has a client the package
is refused (409, again in the machine's words) and the operator's own
viewing is untouched. The package retries when they stop.
No package takes a picture during a cut. A capture costs kernel-side
work beside the step stream that a thread priority does not cover, which
is the same reason a service is frozen for the window. So a capture
marked as one nobody is waiting for - which is every capture a package
makes, through its API socket or through the panel's bridge - is refused
with 409 while a job is armed, and served again once the window closes.
The operator's own snapshot is unmarked and is not refused: they are
standing at the machine. The refusal follows the armed field of
GET /cool/status, the same flag the freeze follows.
One capture runs at a time, on a thread of its own. A capture takes
seconds, and the host's broker also carries holds and event polls that
cannot wait behind it; a second package asking while one is under way is
told so (503) rather than queued behind it. A capture that never comes
back frees its connection after 25 s.
Captures are request-driven: a package gets a frame because it asked for one.
A package keeps the Grbl sender out¶
A package that moves the head for its own reasons - the alignment tool
jogs it so the head camera shows where the laser will hit - leaves the
machine where no job should start. sender.keep_out lets it make sure none
does: while the package uses the machine, the Grbl sender is
disconnected and kept out. It is a capability the operator grants by
hand, and it belongs to a service.
Only an idle machine is taken. The service asks with
POST /v0/sender {"out": true}, and the GRBL controller decides: it
refuses while a program, a jog, a hold, an armed or arming laser, or an
alarm is in progress, and while the sender is using the machine (a line
open, bytes waiting, or a line within the last 2 s). Otherwise it
disconnects the sender, telling it first
([MSG:The machine is in use: senders are kept out for now]), and turns
away every sender that connects from the network with the same message
until it is told otherwise. A client on the machine itself still
connects, which is how the machine's own job runner and a package's job
keep working (the controller port).
The package holds the machine lease as ext:<id>, of the extension
kind, for as long as it keeps the sender out, so the panel's motion
controls lock and name it, and only the package's own jogs and its own
job pass. One package keeps the sender out at a time; a second is refused
in words.
Nothing keeps the sender out past its keeper. The host keeps the
claim fresh for forgectrl, /run/forgefirm/sender-out/<id>.json in the
holds' own form (raised is the claim), rewritten twice a second while
the package's service runs and keeps the sender out. When the service
stops, crashes, or is turned off, when extensions go off, or when the host
goes, the file goes stale, and within a few seconds forgectrl ends the claim:
the lease is released and senders are let back in. Nothing moves when
it ends that way, and the panel says so in a notice that names the
package, until the operator clears it. A package that moved the head puts
it back itself, before it asks {"out": false}.
The operator can always let the sender back in. The panel shows a
banner while a package keeps the sender out, with a button that ends it
(POST /motion/sender out=0). The package reads released: true from
GET /v0/sender and cannot claim again until it lets the sender in
itself; that is its cue to finish, put the head back, and say
{"out": false}.
forgectrl's senderout_test holds the claim, the staleness, the
operator's release, and the notice; the GRBL controller's serial_test
the keep-out at the port; exthost.sender-keep-out proves it on the
image with a sender from the network.
The firmware a package needs¶
A manifest's core names the firmware versions the package works on, and
a package outside that range is refused - at inspect, so the operator is
told before they consent, and at install, so nothing installs behind the
refusal.
The range is judged only when this firmware has a version to judge it
by. The host reads /etc/forgefirm-version, the file the image build
writes, and takes its first word: a release image writes the release
(v0.0.6, the leading v being the file's form and not part of the
version), and a dev image writes its build stamp, which is no version
at all, and then there is nothing to compare against and the range is not
judged. The answer says which of the two happened, in core_checked, so
that nobody is left guessing whether the range was honored.
The storage quota¶
Every service has a data directory, and storage:<MiB> says how large it
may grow. A service that declares no storage gets 16 MiB.
The host measures each running service's data directory every 30 s, by the blocks the filesystem gave the files rather than by their apparent sizes: a package that makes one enormous sparse file has taken nothing, and a package that makes ten thousand small ones has taken more than their bytes. A symbolic link is counted as a link and never followed, so a link out of the directory adds nothing.
A service over its quota is quarantined: stopped, remembered as stopped across restarts, and shown with the reason in the panel. The host does not delete a package's data - that is the operator's, and there are two ways out: remove the package, which takes its data with it, or clear the data and enable the package again.
A package's own settings¶
A package's data directory is its own and it could keep a file there
without asking anybody. These are the settings that are not only its own
business: the ones that must outlive the package being updated or
reinstalled, and the ones something other than the package may read and
change. They are never keys of forgefirm.conf, and a package can reach
no key but the ones its own manifest declares.
Two things reach them besides the package: forgeext settings <id> at the
console, and the panel's /ext/settings, which today serves a package's
own page through the bridge. The panel has no editor of its own for them,
so a package that wants the operator to set something ships a page.
The manifest is the schema. A settings object names at most 16 of them;
each key is lower-case letters, digits and _, starting with a letter, at
most 32 bytes:
"settings": {
"webhook": { "type": "string", "default": "", "max": 128, "label": "Where to post" },
"threshold": { "type": "number", "default": 40, "min": 0, "max": 100 },
"loud": { "type": "bool", "default": false },
"when": { "type": "choice", "default": "end", "choices": ["start", "end", "never"] }
}
| Type | Takes | Also |
|---|---|---|
string |
Printable text with no control characters, at most max bytes (128 unless it says otherwise, and never more) |
|
number |
A number, within min and max when either is given |
|
bool |
true or false |
|
choice |
One of choices: 2 to 6 short tokens |
choices is required |
default is required and is held to the setting's own rule, so a package
cannot declare a default its schema would refuse. label is what the
operator is shown, and is the key's name when it says nothing. Anything
else in a setting, a type there is none of, a min on a string, or
choices on anything but a choice is a manifest refused with words.
The values live in one file per package under the extension root, owned by root at 0600. The package never touches that file: it reads and writes through its API socket, so it cannot put in what its own schema refuses. A write is all or nothing - a patch naming one key the schema does not declare, or one value that does not fit, changes none of them, because half an applied patch is a state the package never asked for. A patch leaves alone every key it does not name.
The schema is read from the installed manifest each time, so an update that changes it changes what the settings are from that moment. A value the new schema no longer takes reads as its default, and a key it no longer declares is gone. Removing a package removes its settings.
The events a package reads¶
forgectrl publishes the machine's edges as a stream and caps the streams,
because each one holds a thread of the daemon for hours
(The event stream). The host takes one
subscription, in the slot forgectrl keeps for it, and every package reads
from the ring it fills: a machine with ten packages still costs forgectrl one
stream. The host holds that subscription only while some running service
holds events, because forgectrl's sampler sleeps when nobody listens.
A package reads the ring with POST /v0/events, which is a poll and not a
stream. It says the sequence number it has and is answered with what came
after it. It is a POST because the request reader above refuses a query
string on purpose and a poll needs its numbers; the body carries them
instead.
| The body | Asks for |
|---|---|
{} or {"wait": s} |
Where the present is: the answer is next at the head, no events. A package that does not want the past starts here |
{"since": n} |
What came after n, at once |
{"since": n, "wait": s} |
The same, but when n is the head the answer waits up to s seconds (at most 30) for an event, and comes back with an empty list if none comes |
since: 0 is the beginning of what the host still holds, which is a
different question from asking where the present is: a package that started
before the first event stands at 0, and it has to be able to be told of the
events it was there for. A since past the head is a feed that started over
under the reader (the host was restarted): the answer comes at once with
next back at the head, and a reader that compares the two sees the rewind.
The host puts events of its own into the same feed, named ext.:
| Event | Data | When |
|---|---|---|
ext.will_freeze |
The armed window opens. A service with job_time.run reads it at once; a frozen one reads it when it thaws |
|
ext.thawed |
The armed window closes | |
ext.shutdown |
reason |
Every service is about to be stopped: extensions off, safe mode, or the host stopping. The services are stopped one second later, so that each can read it; the holds do not wait for that second |
At most 32 events come back at a time and the rest waits for the next call.
The host holds the last 64; a reader that falls a whole ring behind is told
how many it lost in dropped and handed the oldest still held, never a stale
event as if it were new. connected says whether the host has the machine's
stream at that moment.
Each event's data is forgectrl's own JSON, passed on as it was written,
within what the ring holds: a name is kept to 31 bytes and data to 223.
Neither is the binding limit, because forgectrl writes a whole event into
256 bytes before it leaves the daemon, so nothing it publishes reaches
either. Should one ever be cut, what no longer reads as JSON is passed on
as a string rather than dropped, so that one odd event never costs a
package the rest of them.
The command line¶
forgeext answers every command with one JSON object and exits 0 when
"ok" is true. A command it does not know, or one missing an argument, is
the exception: that is a usage error, printed as plain text on standard
error with exit 2. key-add <name> - reads the key from standard input.
forgeext --help prints the whole of it, the global options (--root,
--fwup, --official-key, --firmware-key, --nft, --budget-mib,
--core-version, --no-reserve) and run's own included.
| Command | Does |
|---|---|
inspect <file.ffx> |
Everything an install checks short of consent and grants, with nothing left behind: the tier, the manifest, what needs a grant, what is new against the installed version, whether it is a downgrade |
install <file.ffx> [--grant <capability>]... [--consent-community] [--consent-unverified] |
Installs, or refuses in words |
list |
What is installed, with what the kept index says was withdrawn of each |
check [<id>] |
The integrity check |
remove <id> [--keep-data] |
Removes the package and, unless told otherwise, its data |
wipe |
Every package, everything under data/, and the owner's keys: a change of owner, and nothing an operator reaches |
enable <id>, disable <id> |
The operator's switch for one package. Disabled, it keeps its files, its data, its grants, and its account, and its service and its hold are gone; enabling it also lets it out of quarantine |
hold <id> required\|advisory |
What the package's hold does when the package cannot speak for itself: stand, or drop |
dest <id> add\|remove <host>:<port> |
A destination the operator names for a package that asks for them, or takes away (The operator's destinations) |
keys, key-add <name> <file.pub>, key-remove <name> |
The owner's keys. key-add parses the file as an Ed25519 public key before it is written |
ui <id> |
The package's own page, as a JSON string (A package's own page) |
settings <id> [<json>] |
The package's own settings and their schema; with a patch, applied whole or not at all (A package's own settings) |
call <id> GET\|POST <path> [<json>] [--call-dir <dir>] [--cg-parent <dir>] |
One call from the package's page to its own service; status and body, the service's answer (A page and its own service) |
wizard <id> state\|start\|answer\|abort [<json>] |
One step of the package's check on the Setup page, to its service: its status and body (A package's check on the Setup page) |
mcode <n> [<json>] |
M<n> of a job, with its words (an object of P, Q, and R), to the service that answers it: its status and body (A package's M-code) |
index-verify <file.ffi> |
Verifies the signed index and keeps it in place of the last, never one older than it; version and the number of packages |
index |
The index this host keeps, judged against this firmware (usable and why for each version, offer for each package), or null; and core_version and core_checked |
caps |
The capability list and the API version |
net-check |
Whether the image's deny table is loaded |
net-allow <uid> [--listen <port>] [--dns] [<host>:<port>]... |
A service's chain in the deny table, as the host installs it when a service starts |
net-revoke <uid> |
Takes that chain away again |
run |
The extension host, above |