初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
try:
|
||||
import pwd
|
||||
except ImportError: # pragma: no cover - allows Windows unit tests to import this module
|
||||
pwd = None # type: ignore[assignment]
|
||||
|
||||
from scripts.image_config import read_config_path
|
||||
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
SSH_POLICY_SOURCE = SOURCE_ROOT / "systemd" / "10-walnutpi-screen-hardening.conf"
|
||||
SSH_POLICY_TARGET = Path("/etc/ssh/sshd_config.d/10-walnutpi-screen-hardening.conf")
|
||||
SSH_RUNTIME_DIRECTORY = Path("/run/sshd")
|
||||
SUDOERS_TARGET = Path("/etc/sudoers.d/90-matrix-screen-controller-account")
|
||||
SUDOERS_MAIN = Path("/etc/sudoers")
|
||||
HOSTS_PATH = Path("/etc/hosts")
|
||||
SSH_EXPECTED = {
|
||||
"permitrootlogin": "no",
|
||||
"passwordauthentication": "yes",
|
||||
"kbdinteractiveauthentication": "no",
|
||||
"permitemptypasswords": "no",
|
||||
}
|
||||
|
||||
|
||||
def run(args: list[str], *, input_text: str | None = None) -> None:
|
||||
subprocess.run(args, input=input_text, text=True, check=True)
|
||||
|
||||
|
||||
def _nm_escape(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace(";", "\\;")
|
||||
|
||||
|
||||
def _rooted(root: Path, path: Path) -> Path:
|
||||
return root / path.relative_to("/")
|
||||
|
||||
|
||||
def _remove_temporary(path: Path) -> None:
|
||||
if os.name == "nt" and path.exists():
|
||||
path.chmod(0o600)
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: str, mode: int) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
temporary.write_text(data, encoding="utf-8", newline="\n")
|
||||
temporary.chmod(mode)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
_remove_temporary(temporary)
|
||||
|
||||
|
||||
def _sanitize_vendor_sudoers(data: str) -> str:
|
||||
forbidden = (
|
||||
re.compile(r"^\s*Defaults\s+rootpw\s*(?:#.*)?$"),
|
||||
re.compile(r"^\s*pi\s+ALL\s*=\s*\(ALL\)\s+NOPASSWD\s*:\s*ALL\s*(?:#.*)?$"),
|
||||
)
|
||||
lines = [line for line in data.splitlines() if not any(pattern.fullmatch(line) for pattern in forbidden)]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _install_sudoers(path: Path, main_path: Path, username: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||||
main_temporary = main_path.with_name(f".{main_path.name}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
temporary.write_text(f"{username} ALL=(ALL:ALL) ALL\n", encoding="utf-8", newline="\n")
|
||||
temporary.chmod(0o440)
|
||||
main_temporary.write_text(
|
||||
_sanitize_vendor_sudoers(main_path.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
main_temporary.chmod(0o440)
|
||||
run(["visudo", "-cf", str(temporary)])
|
||||
run(["visudo", "-cf", str(main_temporary)])
|
||||
os.replace(main_temporary, main_path)
|
||||
os.replace(temporary, path)
|
||||
run(["visudo", "-c"])
|
||||
finally:
|
||||
_remove_temporary(temporary)
|
||||
_remove_temporary(main_temporary)
|
||||
|
||||
|
||||
def _configure_hostname_mapping(path: Path, hostname: str) -> None:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
replacement = f"127.0.1.1\t{hostname}"
|
||||
replaced = False
|
||||
for index, line in enumerate(lines):
|
||||
fields = line.split()
|
||||
if fields and fields[0] == "127.0.1.1":
|
||||
lines[index] = replacement
|
||||
replaced = True
|
||||
break
|
||||
if not replaced:
|
||||
lines.append(replacement)
|
||||
_atomic_write(path, "\n".join(lines) + "\n", 0o644)
|
||||
|
||||
|
||||
def configure_network(config: dict) -> None:
|
||||
wifi = config["wifi"]
|
||||
ipv4 = config["ipv4"]
|
||||
connection_uuid = str(uuid.uuid4())
|
||||
lines = [
|
||||
"[connection]",
|
||||
"id=matrix-screen",
|
||||
f"uuid={connection_uuid}",
|
||||
"type=wifi",
|
||||
"interface-name=wlan0",
|
||||
"autoconnect=true",
|
||||
"",
|
||||
"[wifi]",
|
||||
"mode=infrastructure",
|
||||
f"ssid={_nm_escape(wifi['ssid'])}",
|
||||
"",
|
||||
"[wifi-security]",
|
||||
"key-mgmt=wpa-psk",
|
||||
f"psk={_nm_escape(wifi['password'])}",
|
||||
"",
|
||||
"[ipv4]",
|
||||
]
|
||||
if ipv4["mode"] == "dhcp":
|
||||
lines.extend(("method=auto", ""))
|
||||
else:
|
||||
dns = ";".join(ipv4["dns"]) + ";"
|
||||
lines.extend(
|
||||
(
|
||||
f"address1={ipv4['address']}/{ipv4['prefix']},{ipv4['gateway']}",
|
||||
f"dns={dns}",
|
||||
"method=manual",
|
||||
"",
|
||||
)
|
||||
)
|
||||
lines.extend(("[ipv6]", "method=disabled", ""))
|
||||
root = Path("/etc/NetworkManager/system-connections")
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for candidate in root.glob("*.nmconnection"):
|
||||
candidate.unlink()
|
||||
target = root / "matrix-screen.nmconnection"
|
||||
target.write_text("\n".join(lines), encoding="utf-8", newline="\n")
|
||||
target.chmod(0o600)
|
||||
|
||||
|
||||
def configure_account(config: dict, *, root: Path = Path("/")) -> None:
|
||||
if pwd is None:
|
||||
raise RuntimeError("account provisioning requires the POSIX pwd module")
|
||||
account = config["account"]
|
||||
username = account["username"]
|
||||
try:
|
||||
pwd.getpwnam(username)
|
||||
except KeyError:
|
||||
pi = pwd.getpwnam("pi")
|
||||
groups = subprocess.check_output(["id", "-nG", "pi"], text=True).split()
|
||||
supplementary = ",".join(group for group in groups if group not in {"pi", username})
|
||||
args = ["useradd", "--create-home", "--shell", "/bin/bash"]
|
||||
if supplementary:
|
||||
args.extend(("--groups", supplementary))
|
||||
args.append(username)
|
||||
run(args)
|
||||
home = _rooted(root, Path(f"/home/{username}"))
|
||||
if Path(pi.pw_dir).is_dir() and not any(home.iterdir()):
|
||||
for source in Path(pi.pw_dir).iterdir():
|
||||
if source.name not in {".ssh", ".bash_history"}:
|
||||
destination = home / source.name
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, destination, symlinks=True)
|
||||
elif source.is_file():
|
||||
shutil.copy2(source, destination)
|
||||
run(["chown", "-R", f"{username}:{username}", str(home)])
|
||||
run(["chpasswd"], input_text=f"{username}:{account['password']}\n")
|
||||
run(["usermod", "--append", "--groups", "sudo", username])
|
||||
sudoers = _rooted(root, SUDOERS_TARGET)
|
||||
_install_sudoers(sudoers, _rooted(root, SUDOERS_MAIN), username)
|
||||
if username != "pi":
|
||||
run(["passwd", "--lock", "pi"])
|
||||
|
||||
|
||||
def reset_identity(*, root: Path = Path("/")) -> None:
|
||||
_configure_hostname_mapping(_rooted(root, HOSTS_PATH), "matrix-screen")
|
||||
run(["hostnamectl", "set-hostname", "matrix-screen"])
|
||||
run(["systemctl", "stop", "ssh.service"])
|
||||
for key in _rooted(root, Path("/etc/ssh")).glob("ssh_host_*"):
|
||||
if key.is_file():
|
||||
key.unlink()
|
||||
_rooted(root, Path("/etc/machine-id")).write_text("", encoding="ascii")
|
||||
dbus = _rooted(root, Path("/var/lib/dbus/machine-id"))
|
||||
if dbus.exists() or dbus.is_symlink():
|
||||
dbus.unlink()
|
||||
run(["systemd-machine-id-setup"])
|
||||
run(["ssh-keygen", "-A"])
|
||||
|
||||
|
||||
def configure_ssh(*, root: Path = Path("/")) -> None:
|
||||
policy = SSH_POLICY_SOURCE.read_text(encoding="utf-8")
|
||||
target = _rooted(root, SSH_POLICY_TARGET)
|
||||
_atomic_write(target, policy, 0o644)
|
||||
runtime = _rooted(root, SSH_RUNTIME_DIRECTORY)
|
||||
runtime.mkdir(parents=True, exist_ok=True)
|
||||
runtime.chmod(0o755)
|
||||
run(["/usr/sbin/sshd", "-t"])
|
||||
effective = subprocess.check_output(["/usr/sbin/sshd", "-T"], text=True)
|
||||
observed = {}
|
||||
for line in effective.splitlines():
|
||||
key, separator, value = line.partition(" ")
|
||||
if separator and key in SSH_EXPECTED:
|
||||
observed[key] = value.strip()
|
||||
if observed != SSH_EXPECTED:
|
||||
raise RuntimeError(f"effective SSH policy is invalid: {observed!r}")
|
||||
run(["systemctl", "unmask", "ssh.service"])
|
||||
run(["systemctl", "enable", "ssh.service"])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("command", choices=("network", "account", "identity", "ssh", "print-public"))
|
||||
parser.add_argument("--config", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
config = read_config_path(args.config)
|
||||
if args.command == "network":
|
||||
configure_network(config)
|
||||
elif args.command == "account":
|
||||
configure_account(config)
|
||||
elif args.command == "identity":
|
||||
reset_identity()
|
||||
elif args.command == "ssh":
|
||||
configure_ssh()
|
||||
else:
|
||||
public = {
|
||||
"software_version": config["software_version"],
|
||||
"username": config["account"]["username"],
|
||||
"ssid": config["wifi"]["ssid"],
|
||||
"ipv4": config["ipv4"],
|
||||
}
|
||||
print(json.dumps(public, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user