Skip to content

naneos.uploader_settings

Settings of the naneos-uploader service from a text file on the SD card.

A customer without SSH can still change the options of the service and add a WiFi network: the boot partition of a Raspberry Pi is FAT and opens on any PC. Two files live there:

  • naneos-uploader-change.txt: the customer writes OPTIONS=--interval 60 (the same options as on the command line), WIFI_SSID= plus WIFI_PASSWORD=, and/or AUTO_UPDATE=on|off. The next boot applies the lines, then resets the file to its commented template, which also removes the password from the card.
  • naneos-uploader-current.txt: written at every boot, shows the options the service runs with and the WiFi networks the Pi knows, plus an error if the last change was rejected. Never the password.

"Applied" means, for the options, written to an environment file that the systemd unit reads (EnvironmentFile=) and expands in its ExecStart:

NANEOS_UPLOADER_OPTIONS=--interval 60

and for WiFi, a NetworkManager keyfile in /etc/NetworkManager/system-connections (root only, mode 600) followed by nmcli connection reload. The new network is added with a higher autoconnect priority; the known ones are kept. AUTO_UPDATE enables or disables the naneos_uploader_update.timer (see uploader_update.py) with systemctl.

The installer writes the naneos_uploader_settings.service unit that runs naneos-uploader-settings as root before the uploader starts. All lines are validated before anything is applied: a rejected file keeps the previous settings, so the service always comes up.

SettingsError

Bases: ValueError

The change file holds something that cannot be applied.

Source code in src/naneos/uploader_settings.py
73
74
class SettingsError(ValueError):
    """The change file holds something that cannot be applied."""

apply(boot_dir, env_file, override_dir=DEFAULT_OVERRIDE_DIR, nm_dir=DEFAULT_NM_DIR, nm_reload=nmcli_reload, set_auto_update=systemctl_auto_update, auto_update_state=systemctl_auto_update_state, now=None)

One boot: consume the change file, apply what it holds, write the current file.

Source code in src/naneos/uploader_settings.py
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
def apply(
    boot_dir: Path,
    env_file: Path,
    override_dir: Path = DEFAULT_OVERRIDE_DIR,
    nm_dir: Path = DEFAULT_NM_DIR,
    nm_reload: Callable[[], str | None] = nmcli_reload,
    set_auto_update: Callable[[bool], str | None] = systemctl_auto_update,
    auto_update_state: Callable[[], bool | None] = systemctl_auto_update_state,
    now: datetime | None = None,
) -> Result:
    """One boot: consume the change file, apply what it holds, write the current file."""
    change_file = boot_dir / CHANGE_FILE
    result = Result(current=read_env(env_file))

    if not change_file.exists():
        change_file.write_text(template(), encoding="utf-8")
    else:
        _consume_change_file(change_file, env_file, nm_dir, nm_reload, set_auto_update, result)

    warning = override_warning(override_dir)
    if warning is not None:
        result.warnings.append(warning)
    result.known_wifi = known_wifi(nm_dir)
    result.auto_update_state = auto_update_state()
    (boot_dir / CURRENT_FILE).write_text(_current_text(result, now), encoding="utf-8")
    return result

known_wifi(nm_dir)

The ids of the WiFi profiles NetworkManager has (needs root to read).

Source code in src/naneos/uploader_settings.py
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
def known_wifi(nm_dir: Path) -> list[str]:
    """The ids of the WiFi profiles NetworkManager has (needs root to read)."""
    names: list[str] = []
    if not nm_dir.is_dir():
        return names
    for path in sorted(nm_dir.glob("*.nmconnection")):
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        if not re.search(r"^type=(wifi|802-11-wireless)$", text, re.MULTILINE):
            continue
        match = re.search(r"^id=(.*)$", text, re.MULTILINE)
        names.append(match.group(1) if match else path.stem)
    return names

nmcli_reload()

Tell a running NetworkManager about the new profile; a warning if that was not possible.

Source code in src/naneos/uploader_settings.py
304
305
306
307
308
309
310
311
312
313
314
315
316
def nmcli_reload() -> str | None:
    """Tell a running NetworkManager about the new profile; a warning if that was not possible."""
    if shutil.which("nmcli") is None:
        return "nmcli not found: the WiFi profile is picked up at the next boot"
    try:
        subprocess.run(
            ["nmcli", "connection", "reload"], check=True, capture_output=True, timeout=30
        )
    except (OSError, subprocess.SubprocessError) as e:
        return (
            f"nmcli connection reload failed ({e}): the WiFi profile is picked up at the next boot"
        )
    return None

override_warning(override_dir)

A note if a systemctl edit drop-in replaces ExecStart, since that wins over the file.

Source code in src/naneos/uploader_settings.py
336
337
338
339
340
341
342
343
344
345
def override_warning(override_dir: Path) -> str | None:
    """A note if a `systemctl edit` drop-in replaces ExecStart, since that wins over the file."""
    if not override_dir.is_dir():
        return None
    for conf in sorted(override_dir.glob("*.conf")):
        with contextlib.suppress(OSError):
            for line in conf.read_text(encoding="utf-8", errors="replace").splitlines():
                if line.strip().startswith("ExecStart=") and line.strip() != "ExecStart=":
                    return f"{conf} replaces the command, the options above are not in effect"
    return None

read_settings(text)

The settings lines as {KEY: value}; comments and blank lines are skipped.

Raises SettingsError for an unknown key, a repeated key or any other content. Error messages never echo a value, since they end up in a world-readable file.

Source code in src/naneos/uploader_settings.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def read_settings(text: str) -> dict[str, str]:
    """The settings lines as {KEY: value}; comments and blank lines are skipped.

    Raises SettingsError for an unknown key, a repeated key or any other content.
    Error messages never echo a value, since they end up in a world-readable file.
    """
    found: dict[str, str] = {}
    for number, raw in enumerate(text.splitlines(), start=1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        match = _SETTING_LINE.match(line)
        if match is None:
            raise SettingsError(f"line {number} is not a KEY=value line")
        key = match.group("key").upper()
        if key not in KEYS:
            raise SettingsError(f"line {number}: unknown setting {key}, allowed: {', '.join(KEYS)}")
        if key in found:
            raise SettingsError(f"line {number}: {key} is given twice")
        value = match.group("value").strip()
        if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
            value = value[1:-1].strip()
        found[key] = value
    return found

systemctl_auto_update(enabled)

Switch the update timer; a warning if systemctl could not do it.

Source code in src/naneos/uploader_settings.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def systemctl_auto_update(enabled: bool) -> str | None:
    """Switch the update timer; a warning if systemctl could not do it."""
    action = "enable" if enabled else "disable"
    try:
        subprocess.run(
            ["systemctl", action, "--now", UPDATE_TIMER],
            check=True,
            capture_output=True,
            text=True,
            timeout=60,
        )
    except FileNotFoundError:
        return "systemctl not found: automatic updates could not be switched"
    except subprocess.CalledProcessError as e:
        detail = (e.stderr or "").strip().splitlines()
        reason = detail[-1] if detail else str(e)
        return f"could not {action} automatic updates ({reason}), re-run the installer"
    except subprocess.SubprocessError as e:
        return f"could not {action} automatic updates ({e})"
    return None

systemctl_auto_update_state()

Whether the update timer is enabled, None if it is not installed.

Source code in src/naneos/uploader_settings.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
def systemctl_auto_update_state() -> bool | None:
    """Whether the update timer is enabled, None if it is not installed."""
    try:
        result = subprocess.run(
            ["systemctl", "is-enabled", UPDATE_TIMER], capture_output=True, text=True, timeout=30
        )
    except (OSError, subprocess.SubprocessError):
        return None
    state = result.stdout.strip()
    if state in ("enabled", "enabled-runtime", "static"):
        return True
    if state in ("disabled", "masked"):
        return False
    return None

validate_auto_update(settings)

True/False for AUTO_UPDATE=on/off, None if the line is absent.

Source code in src/naneos/uploader_settings.py
186
187
188
189
190
191
192
193
194
195
def validate_auto_update(settings: dict[str, str]) -> bool | None:
    """True/False for AUTO_UPDATE=on/off, None if the line is absent."""
    value = settings.get("AUTO_UPDATE")
    if value is None:
        return None
    if value.lower() in _ON:
        return True
    if value.lower() in _OFF:
        return False
    raise SettingsError("AUTO_UPDATE must be on or off")

validate_options(options)

The options normalized to single spaces, after the uploader's parser accepted them.

Source code in src/naneos/uploader_settings.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def validate_options(options: str) -> str:
    """The options normalized to single spaces, after the uploader's parser accepted them."""
    if not _ALLOWED_OPTIONS.match(options):
        raise SettingsError(f"unexpected character in OPTIONS: {options}")
    argv = options.split()
    stderr = io.StringIO()
    try:
        with contextlib.redirect_stderr(stderr), contextlib.redirect_stdout(io.StringIO()):
            parse_uploader_args(argv)
    except SystemExit as e:
        if e.code == 0:
            raise SettingsError("OPTIONS: --help and --version are not settings") from None
        lines = [line for line in stderr.getvalue().splitlines() if line.strip()]
        message = lines[-1] if lines else "invalid options"
        raise SettingsError("OPTIONS: " + message.split("error: ", 1)[-1]) from None
    return " ".join(argv)

validate_wifi(settings)

(ssid, password) if the file adds a network, None if both lines are absent.

Source code in src/naneos/uploader_settings.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
def validate_wifi(settings: dict[str, str]) -> tuple[str, str] | None:
    """(ssid, password) if the file adds a network, None if both lines are absent."""
    ssid = settings.get("WIFI_SSID")
    password = settings.get("WIFI_PASSWORD")
    if ssid is None and password is None:
        return None
    if not ssid:
        raise SettingsError("WIFI_SSID is missing or empty")
    if len(ssid.encode("utf-8")) > 32:
        raise SettingsError("WIFI_SSID is longer than 32 bytes")
    if password is None:
        raise SettingsError("WIFI_PASSWORD is missing")
    if not _ALLOWED_PASSWORD.match(password):
        raise SettingsError("WIFI_PASSWORD must have 8 to 63 characters (letters, digits, symbols)")
    return ssid, password

write_wifi_profile(nm_dir, ssid, password)

Write the keyfile root-only (NetworkManager ignores it otherwise).

Source code in src/naneos/uploader_settings.py
293
294
295
296
297
298
299
300
301
def write_wifi_profile(nm_dir: Path, ssid: str, password: str) -> Path:
    """Write the keyfile root-only (NetworkManager ignores it otherwise)."""
    nm_dir.mkdir(parents=True, exist_ok=True)
    path = wifi_profile_path(nm_dir, ssid)
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w", encoding="utf-8") as f:
        f.write(wifi_keyfile(ssid, password))
    os.chmod(path, 0o600)
    return path