feat(monero): minage planifie selon le calendrier Nextcloud

Le mineur ne tourne que 01:00-09:00, plus en semaine 09:00-18:00
quand un evenement du calendrier sg est actif. Le wallet vient de
secrets/monero.yml via LoadCredential (plus dans la ligne de
commande) ; meme planning sur le Deck.
This commit is contained in:
Hermes Agent
2026-10-19 02:07:32 -04:00
parent de94c3e4bf
commit 4f5acfaf61
5 changed files with 450 additions and 9 deletions
+25 -1
View File
@@ -72,6 +72,25 @@
nixpkgs.config.allowUnfree = true;
sops.defaultSopsFile = ../../secrets/common.yml;
sops.secrets."monero/wallet" = {
sopsFile = ../../secrets/monero.yml;
key = "wallet";
owner = "root";
mode = "0400";
};
sops.secrets."monero/nextcloud-user" = {
sopsFile = ../../secrets/common.yml;
key = "nextcloud_user";
owner = "root";
mode = "0400";
};
sops.secrets."monero/nextcloud-password" = {
sopsFile = ../../secrets/common.yml;
key = "nextcloud_password";
owner = "root";
mode = "0400";
};
programs.zsh.enable = true; # requis par users.nix
services.openssh.enable = true;
services.automatic-timezoned.enable = false;
@@ -192,7 +211,7 @@
# charge protegent la machine sur le long terme.
services.monero-miner = {
enable = true;
wallet = "46WTCDFTfzZ7uxKToJ27nN3v1RcGqEGACHWjVxgdcJLMGfZZLdyeTo2JoGksFCrpS7HMEm8yT8iKHPNdiMHXGEY3CS8drV6";
walletFile = config.sops.secrets."monero/wallet".path;
pool = "gulf.moneroocean.stream:10008";
tls = false;
threads = 6;
@@ -202,6 +221,11 @@
thermalGuard.enable = true;
thermalGuard.maxTemperature = 88;
thermalGuard.resumeTemperature = 84;
schedule.enable = true;
calendar.enable = true;
calendar.userFile = config.sops.secrets."monero/nextcloud-user".path;
calendar.passwordFile = config.sops.secrets."monero/nextcloud-password".path;
calendar.calendars = [ "sg" ];
};
networking.hostName = "deck";
+40
View File
@@ -13,6 +13,7 @@ in {
../../hosts/tower/disko.nix
../../hosts/laptop/hardware-configuration.nix
../../modules/nixos/herdr
../../services/monero-miner
];
# Sessions hermes lancees par marc (panes herdr, HERMES_HOME partage, cf
@@ -460,6 +461,45 @@ in {
};
# Aucun port UDP à ouvrir : wg-quick en mode client n'écoute pas de port fixe.
# ========================================================================
# MINEUR MONERO - planning sommeil / travail Nextcloud
# ========================================================================
sops.secrets."monero/wallet" = {
sopsFile = ../../secrets/monero.yml;
key = "wallet";
owner = "root";
mode = "0400";
};
sops.secrets."monero/nextcloud-user" = {
sopsFile = ../../secrets/common.yml;
key = "nextcloud_user";
owner = "root";
mode = "0400";
};
sops.secrets."monero/nextcloud-password" = {
sopsFile = ../../secrets/common.yml;
key = "nextcloud_password";
owner = "root";
mode = "0400";
};
services.monero-miner = {
enable = true;
walletFile = config.sops.secrets."monero/wallet".path;
pool = "gulf.moneroocean.stream:10008";
tls = false;
threads = 8;
cpuQuota = 800;
rigId = "tower";
thermalGuard.enable = true;
thermalGuard.maxTemperature = 88;
thermalGuard.resumeTemperature = 82;
schedule.enable = true;
calendar.enable = true;
calendar.userFile = config.sops.secrets."monero/nextcloud-user".path;
calendar.passwordFile = config.sops.secrets."monero/nextcloud-password".path;
calendar.calendars = [ "sg" ];
};
# ========================================================================
}
+26
View File
@@ -0,0 +1,26 @@
wallet: ENC[AES256_GCM,data:bvxmwN3aV/UYepLlE4enAtwMy2PMwxOw3tQY5wtqUdTco6Uj6RTVqWOjnzfCE2HnQwEDxJ61fSEVELpf64+RVvcyFcias0lbwmsBCA5trJrDLIio8Dbifn6BO1D/5Zo=,iv:gl4+K2f1HxLnOI3MfkFX1KPp/hUzY6cuHOefpNCwIyU=,tag:krTcZmZg18j2t9uPAq4Hxw==,type:str]
pool_password: ENC[AES256_GCM,data:YA==,iv:MSc20uI2nLhI/hMDP5Jsdxp1197dvkWDTT/QW6ydVlk=,tag:27vrXRWatWhEdkmibzpH7A==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6N1AzWnUrcVZQRU43aWZJ
NGpuOFJBc1UvOWlVcWFQV0VIdHFpMTFWZUV3CmZpbElqbXFJSWFUUU5IL2Q5dHNt
enExSnVtcW9XUzdPdVUxaUFLRElFUlUKLS0tIHAzbTZNU3UzNGIyV2JKOE5iQjFY
aGtacDdpY2VsNXBvUW8wbWtiU1hnc28KsFzoLNKR7yDQaJCXtHIxZq5P6lbhdkR5
O1Z+qN8z4nPx4IEXg8zM2aDm9H+jBBMR5MlRVzUNqCpRBkUFlvJdsA==
-----END AGE ENCRYPTED FILE-----
recipient: age1usm3v0ugducrzcwud60dda7xfnj49fdhecj7kcsfm7dzwzrzeyqq2jc2hm
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKY2FUMWdVQ1AzUjBMUjFO
QkdIaW80dHpMdWZtRS9iY0ZzeGRNQ0tBNG1JCmwremlNL1JZZlVMRVNqUGdMTWxU
UzBOYWJFc0RvVlhoYWs2bHVjb1JBRVUKLS0tIDdySFhIY0J1RnJRS3JIUXI1Tng0
aXFQa2U4TDFQV2xmdWViZG5ENFF3OXcKuTjZiX68d1jJLxItYfefuy4HE91kUOVV
DW9bBzHqK/nTkMWRTKyc3P2giG3dYPDiFy0/cMsUxYqPQWLne0Krog==
-----END AGE ENCRYPTED FILE-----
recipient: age154uz4pavaj5qth25ytj0q8chsya4y65pgel04zcrm02je9w6uc9sug7dp2
lastmodified: "2026-10-19T05:56:58Z"
mac: ENC[AES256_GCM,data:VBPkrMB+mM72Oid8CTspuptrqDsqKxvCRulKpF7YPvTbeuzv1hVSZs83QhGom120vhOT8W2PyXk/F14xFWFKK/PLybnz7oz048SPXSvfxBQ+ZMWY+JccEVahqATWCFqGSC7zu0rWR+k96DbBoio98vIfau6iMPQW8csgujxC0Ng=,iv:OkFCCUjqOcTXVFCuCOczUVDpG2367wIBUJmj0RpAiqc=,tag:FpMOYmTkPLXv04JzCHqS5g==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3
+175 -8
View File
@@ -7,12 +7,13 @@
let
cfg = config.services.monero-miner;
isTime = value: (builtins.match "(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]" value) != null;
# XMRig est pilote par la ligne de commande : pas de fichier de config a
# generer, donc rien a synchroniser dans /etc et aucun secret sur disque.
args =
# generer, donc rien a synchroniser dans /etc et aucun secret dans le store.
baseArgs =
[
"--url=${cfg.pool}"
"--user=${cfg.wallet}"
"--pass=${cfg.poolPassword}"
"--coin=monero"
"--threads=${toString cfg.threads}"
@@ -26,6 +27,29 @@ let
++ lib.optionals cfg.pauseOnBattery [ "--pause-on-battery" ]
++ lib.optionals (cfg.rigId != "") [ "--rig-id=${cfg.rigId}" ];
args = baseArgs ++ [ "--user=${cfg.wallet}" ];
# Quand le wallet vient de sops, il est lu dans le credential systemd et
# n'apparait jamais dans la ligne de commande ni dans le store.
startScript = pkgs.writeShellApplication {
name = "monero-miner-start";
runtimeInputs = [ cfg.package pkgs.coreutils ];
text = ''
wallet="$(cat "''${CREDENTIALS_DIRECTORY}/wallet")"
exec ${cfg.package}/bin/xmrig ${lib.escapeShellArgs baseArgs} --user="$wallet"
'';
};
schedulerPython = pkgs.python3.withPackages (ps: [ ps.caldav ]);
schedulerScript = pkgs.writeShellApplication {
name = "monero-miner-scheduler";
runtimeInputs = [ schedulerPython pkgs.systemd pkgs.coreutils ];
text = ''
exec ${schedulerPython}/bin/python3 ${./scheduler.py}
'';
};
# Plafond de charge batterie : le controleur steamdeck_hwmon expose
# max_battery_charge_level (0 = pas de plafond, 80 = charge a 80 %). Le
# chemin hwmonN varie selon l'ordre de detection, d'ou la recherche par nom.
@@ -95,13 +119,92 @@ in
wallet = lib.mkOption {
type = lib.types.str;
default = "";
example = "48edfHu7V9Z84YzzMa6fUueoELZ9ZRXq9VetWZgDJRh3jpbZ7knX2aMVJpXyF5bZ1...";
description = ''
Adresse Monero qui recoit les gains. Une adresse principale (commence
par 4) ou une sous-adresse (commence par 8) fait 95 caracteres.
Laisser vide si walletFile est utilise.
'';
};
walletFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Chemin d'un fichier contenant l'adresse Monero, typiquement un secret
sops lu par systemd via LoadCredential. Prioritaire sur wallet.
'';
};
schedule = {
enable = lib.mkEnableOption "la planification du minage";
weekdayStart = lib.mkOption {
type = lib.types.str;
default = "01:00";
description = "Debut de la fenetre de sommeil en semaine (HH:MM).";
};
weekdayWorkStart = lib.mkOption {
type = lib.types.str;
default = "09:00";
description = "Heure a partir de laquelle le calendrier decide en semaine (HH:MM).";
};
weekdayEnd = lib.mkOption {
type = lib.types.str;
default = "18:00";
description = "Fin de la fenetre de semaine (HH:MM).";
};
weekendStart = lib.mkOption {
type = lib.types.str;
default = "01:00";
description = "Debut de la fenetre du week-end (HH:MM).";
};
weekendEnd = lib.mkOption {
type = lib.types.str;
default = "09:00";
description = "Fin de la fenetre du week-end (HH:MM).";
};
};
calendar = {
enable = lib.mkEnableOption "la restriction aux evenements de travail Nextcloud";
url = lib.mkOption {
type = lib.types.str;
default = "https://cloud.marcpartensky.com";
description = "URL de base CalDAV Nextcloud.";
};
userFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Fichier contenant le nom d'utilisateur Nextcloud.";
};
passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Fichier contenant le mot de passe ou app password Nextcloud.";
};
calendars = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "sg" ];
description = "Calendriers dont un evenement actif autorise le minage.";
};
failOpen = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Miner pendant la plage travail si le calendrier est indisponible.";
};
};
pool = lib.mkOption {
type = lib.types.str;
default = "gulf.moneroocean.stream:10008";
@@ -218,16 +321,34 @@ in
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.wallet != "";
message = "services.monero-miner.wallet doit contenir une adresse Monero.";
assertion = cfg.wallet != "" || cfg.walletFile != null;
message = "services.monero-miner.wallet ou walletFile doit etre renseigne.";
}
{
assertion = (builtins.match "[48][1-9A-HJ-NP-Za-km-z]{94}" cfg.wallet) != null;
assertion = cfg.wallet == "" || (builtins.match "[48][1-9A-HJ-NP-Za-km-z]{94}" cfg.wallet) != null;
message = ''
services.monero-miner.wallet n'a pas la forme d'une adresse Monero
standard ou de sous-adresse (95 caracteres, commence par 4 ou 8).
'';
}
{
assertion = lib.all isTime [
cfg.schedule.weekdayStart
cfg.schedule.weekdayWorkStart
cfg.schedule.weekdayEnd
cfg.schedule.weekendStart
cfg.schedule.weekendEnd
];
message = "Les horaires de services.monero-miner.schedule doivent etre au format HH:MM.";
}
{
assertion = !cfg.schedule.enable || !cfg.calendar.enable
|| (cfg.calendar.userFile != null && cfg.calendar.passwordFile != null);
message = ''
services.monero-miner.calendar.enable exige userFile et passwordFile
(secrets sops) pour interroger Nextcloud.
'';
}
{
assertion = cfg.thermalGuard.resumeTemperature < cfg.thermalGuard.maxTemperature;
message = "services.monero-miner.thermalGuard.resumeTemperature doit etre inferieure a maxTemperature.";
@@ -240,12 +361,15 @@ in
systemd.services.monero-miner = {
description = "XMRig Monero CPU miner";
wantedBy = [ "multi-user.target" ];
wantedBy = lib.optionals (!cfg.schedule.enable) [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = {
ExecStart = "${cfg.package}/bin/xmrig ${lib.escapeShellArgs args}";
ExecStart =
if cfg.walletFile != null
then "${startScript}/bin/monero-miner-start"
else "${cfg.package}/bin/xmrig ${lib.escapeShellArgs args}";
# Utilisateur jetable : aucun fichier, aucun home, aucun droit persistant.
DynamicUser = true;
@@ -253,6 +377,9 @@ in
RestartSec = 15;
KillSignal = "SIGINT";
TimeoutStopSec = 30;
LoadCredential = lib.optionals (cfg.walletFile != null) [
"wallet:${cfg.walletFile}"
];
# Priorite basse et plafond : le Deck reste utilisable pendant le minage.
Nice = cfg.nice;
@@ -309,6 +436,46 @@ in
};
};
systemd.services.monero-miner-scheduler = lib.mkIf cfg.schedule.enable {
description = "Planificateur du mineur Monero (calendrier Nextcloud)";
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
environment = {
MONERO_SCHEDULE_WEEKDAY_START = cfg.schedule.weekdayStart;
MONERO_SCHEDULE_WEEKDAY_WORK_START = cfg.schedule.weekdayWorkStart;
MONERO_SCHEDULE_WEEKDAY_END = cfg.schedule.weekdayEnd;
MONERO_SCHEDULE_WEEKEND_START = cfg.schedule.weekendStart;
MONERO_SCHEDULE_WEEKEND_END = cfg.schedule.weekendEnd;
MONERO_CALENDAR_ENABLE = if cfg.calendar.enable then "1" else "0";
MONERO_CALENDAR_URL = cfg.calendar.url;
MONERO_CALENDAR_USER_FILE =
if cfg.calendar.userFile != null then cfg.calendar.userFile else "";
MONERO_CALENDAR_PASSWORD_FILE =
if cfg.calendar.passwordFile != null then cfg.calendar.passwordFile else "";
MONERO_CALENDAR_NAMES = lib.concatStringsSep "," cfg.calendar.calendars;
MONERO_CALENDAR_FAIL_OPEN = if cfg.calendar.failOpen then "1" else "0";
MONERO_MINER_UNIT = "monero-miner.service";
MONERO_SYSTEMCTL = "${pkgs.systemd}/bin/systemctl";
};
serviceConfig = {
Type = "oneshot";
ExecStart = "${schedulerScript}/bin/monero-miner-scheduler";
TimeoutStartSec = 90;
};
};
systemd.timers.monero-miner-scheduler = lib.mkIf cfg.schedule.enable {
description = "Application periodique du planning du mineur Monero";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "2min";
OnUnitActiveSec = "5min";
AccuracySec = "30s";
Unit = "monero-miner-scheduler.service";
};
};
# Permet de lancer `xmrig --bench=1M` a la main pour comparer les reglages.
environment.systemPackages = [ cfg.package ];
};
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Planificateur du mineur Monero.
Le service tourne en root et applique la regle :
- semaine : 01:00-09:00 toujours, 09:00-18:00 seulement pendant un
evenement du calendrier de travail Nextcloud ;
- week-end : 01:00-09:00.
Si le calendrier est indisponible, le comportement depend de
MONERO_CALENDAR_FAIL_OPEN (defaut : ne pas miner pendant la plage travail).
"""
from __future__ import annotations
import datetime as dt
import os
import subprocess
import sys
def env(name: str, default: str | None = None) -> str | None:
value = os.environ.get(name)
return default if value is None or value == "" else value
def parse_time(name: str) -> dt.time:
raw = env(name)
if raw is None:
raise SystemExit(f"{name} manquant")
hour, minute = raw.split(":", 1)
return dt.time(int(hour), int(minute))
def read_secret(path: str) -> str:
with open(path, "r", encoding="utf-8") as handle:
return handle.read().strip()
def calendar_event_active(now: dt.datetime) -> bool | None:
"""True si un evenement de travail est actif, None si erreur."""
url = env("MONERO_CALENDAR_URL")
user_file = env("MONERO_CALENDAR_USER_FILE")
password_file = env("MONERO_CALENDAR_PASSWORD_FILE")
names = [
item.strip().lower()
for item in (env("MONERO_CALENDAR_NAMES", "sg") or "").split(",")
if item.strip()
]
if not url or not user_file or not password_file:
print("calendar: configuration incomplete", flush=True)
return None
try:
import caldav
client = caldav.DAVClient(
url=url,
username=read_secret(user_file),
password=read_secret(password_file),
)
try:
principal = client.principal()
for calendar in principal.calendars():
calendar_url = str(calendar.url).rstrip("/")
calendar_name = (calendar.name or "").lower()
if not any(
calendar_name == name or calendar_url.endswith("/" + name)
for name in names
):
continue
events = calendar.search(
start=now - dt.timedelta(days=1),
end=now + dt.timedelta(days=1),
event=True,
expand=True,
)
for event in events:
for component in event.icalendar_instance.walk("VEVENT"):
if str(component.get("STATUS", "")).upper() == "CANCELLED":
continue
start_prop = component.get("DTSTART")
if start_prop is None:
continue
start = start_prop.dt
if not isinstance(start, dt.datetime):
continue
end_prop = component.get("DTEND")
end = (
end_prop.dt
if end_prop is not None
else start + dt.timedelta(hours=1)
)
if not isinstance(end, dt.datetime):
continue
if start.tzinfo is None:
start = start.replace(tzinfo=now.tzinfo)
if end.tzinfo is None:
end = end.replace(tzinfo=now.tzinfo)
if start <= now < end:
return True
print(
"calendar: aucun evenement actif dans "
+ ",".join(names),
flush=True,
)
return False
finally:
close = getattr(client, "close", None)
if close is not None:
close()
except Exception as exc: # noqa: BLE001 - le detail part dans le journal
print(f"calendar: erreur {type(exc).__name__}: {exc}", flush=True)
return None
def main() -> int:
override = env("MONERO_SCHEDULE_NOW")
if override is not None:
now = dt.datetime.fromisoformat(override)
if now.tzinfo is None:
now = now.astimezone()
else:
now = dt.datetime.now().astimezone()
weekday = now.isoweekday() <= 5
current = now.time().replace(tzinfo=None)
weekday_start = parse_time("MONERO_SCHEDULE_WEEKDAY_START")
work_start = parse_time("MONERO_SCHEDULE_WEEKDAY_WORK_START")
weekday_end = parse_time("MONERO_SCHEDULE_WEEKDAY_END")
weekend_start = parse_time("MONERO_SCHEDULE_WEEKEND_START")
weekend_end = parse_time("MONERO_SCHEDULE_WEEKEND_END")
calendar_enabled = env("MONERO_CALENDAR_ENABLE", "0") == "1"
fail_open = env("MONERO_CALENDAR_FAIL_OPEN", "0") == "1"
if weekday:
if weekday_start <= current < work_start:
desired, reason = True, "sommeil"
elif work_start <= current < weekday_end:
if calendar_enabled:
active = calendar_event_active(now)
if active is None:
desired = fail_open
reason = "calendrier indisponible"
else:
desired = active
reason = "travail" if active else "hors travail"
else:
desired, reason = True, "fenetre semaine"
else:
desired, reason = False, "hors fenetre semaine"
else:
desired = weekend_start <= current < weekend_end
reason = "fenetre week-end" if desired else "hors fenetre week-end"
unit = env("MONERO_MINER_UNIT", "monero-miner.service") or "monero-miner.service"
print(
f"{now.isoformat()} desired={desired} reason={reason} unit={unit}",
flush=True,
)
if env("MONERO_SCHEDULE_DRY_RUN", "0") == "1":
return 0
systemctl = env("MONERO_SYSTEMCTL", "systemctl") or "systemctl"
active = (
subprocess.run(
[systemctl, "is-active", "--quiet", unit],
check=False,
).returncode
== 0
)
if desired and not active:
rc = subprocess.run([systemctl, "start", unit], check=False).returncode
print(f"systemctl start {unit} -> {rc}", flush=True)
elif not desired and active:
rc = subprocess.run([systemctl, "stop", unit], check=False).returncode
print(f"systemctl stop {unit} -> {rc}", flush=True)
else:
print(f"{unit} deja {'actif' if active else 'inactif'}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())