Compare commits

...

11 Commits

Author SHA1 Message Date
gitea-actions[bot]
587a9eee84 Update nixpkgs-dev in devFlake/private 2025-07-14 18:33:33 +00:00
Mic92
faa3497eeb Merge pull request 'update-flake-inputs: drop gitea vars' (#4338) from flakes into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4338
2025-07-14 15:45:21 +00:00
Jörg Thalheim
970a168c2a update-flake-inputs: drop gitea vars 2025-07-14 17:41:48 +02:00
Mic92
ab067e3466 Merge pull request 'drop renovate' (#4337) from merge-when-green-joerg into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4337
2025-07-14 15:41:00 +00:00
Jörg Thalheim
c673c07164 drop renovate
we now use gitea actions for it.
2025-07-14 17:37:32 +02:00
Mic92
0524aadd50 Merge pull request 'add new workflow to do flake updates' (#4336) from flakes into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4336
2025-07-14 15:14:42 +00:00
Jörg Thalheim
d9e5db2596 add new workflow to do flake updates 2025-07-14 17:11:22 +02:00
Luis Hebendanz
2ded6cbac4 Merge pull request 'clan-cli: Make 'clan ssh' read out the targetHost to connect to' (#4335) from Qubasa/clan-core:fix_clan_ssh into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4335
2025-07-14 13:57:45 +00:00
Qubasa
a4823c3ffa clan-cli: Fixup clan install which depends on ssh_parseargs.
clan-cli: Remove --ssh-option for now, as it can't work in current state

clan-cli: Remove nix_config from test as its impure
2025-07-14 20:47:49 +07:00
Qubasa
7413d3620b clan-cli: Make 'clan ssh' read out the targetHost to connect to 2025-07-14 19:35:48 +07:00
DavHau
6fe2b195a9 vars: cleanup nix interface 2025-07-14 18:20:04 +07:00
9 changed files with 218 additions and 73 deletions

View File

@@ -0,0 +1,27 @@
name: Update Flake Inputs
on:
schedule:
# Run weekly on Sunday at 4:00 AM UTC
- cron: "0 4 * * 0"
workflow_dispatch:
repository_dispatch:
jobs:
update-flake-inputs:
runs-on: nix
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure git
run: |
git config --global user.email "clan-bot@clan.lol"
git config --global user.name "clan-bot"
- name: Update flake inputs
uses: Mic92/update-flake-inputs-gitea@main
env:
# Exclude private flakes and update-clan-core checks flake
EXCLUDE_PATTERNS: "devFlake/private/flake.nix,checks/impure/flake.nix"

View File

@@ -66,11 +66,11 @@
}, },
"nixpkgs-dev": { "nixpkgs-dev": {
"locked": { "locked": {
"lastModified": 1752039390, "lastModified": 1752467518,
"narHash": "sha256-DTHMN6kh1cGoc5hc9O0pYN+VAOnjsyy0wxq4YO5ZRvg=", "narHash": "sha256-7SSvjNlM5ZsFZMP7Nw2uUa7EKYhB6Ny9iNtxtPPhWYY=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "6ec4d5f023c3c000cda569255a3486e8710c39bf", "rev": "2f21cef1d1dc734a2dd89f535427cf291aebc8ef",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -22,6 +22,7 @@ let
package package
path path
str str
strMatching
submoduleWith submoduleWith
; ;
# the original types.submodule has strange behavior # the original types.submodule has strange behavior
@@ -47,7 +48,7 @@ in
imports = [ ./generator.nix ]; imports = [ ./generator.nix ];
options = { options = {
name = lib.mkOption { name = lib.mkOption {
type = lib.types.str; type = str;
description = '' description = ''
The name of the generator. The name of the generator.
This name will be used to refer to the generator in other generators. This name will be used to refer to the generator in other generators.
@@ -153,7 +154,7 @@ in
options = options =
{ {
name = lib.mkOption { name = lib.mkOption {
type = lib.types.str; type = str;
description = '' description = ''
name of the public fact name of the public fact
''; '';
@@ -162,7 +163,7 @@ in
defaultText = "Name of the file"; defaultText = "Name of the file";
}; };
generatorName = lib.mkOption { generatorName = lib.mkOption {
type = lib.types.str; type = str;
description = '' description = ''
name of the generator name of the generator
''; '';
@@ -171,7 +172,7 @@ in
defaultText = "Name of the generator that generates this file"; defaultText = "Name of the generator that generates this file";
}; };
share = lib.mkOption { share = lib.mkOption {
type = lib.types.bool; type = bool;
description = '' description = ''
Whether the generated vars should be shared between machines. Whether the generated vars should be shared between machines.
Shared vars are only generated once, when the first machine using it is deployed. Shared vars are only generated once, when the first machine using it is deployed.
@@ -233,7 +234,7 @@ in
By setting this to `user`, the secret will be deployed prior to users and groups are created, allowing By setting this to `user`, the secret will be deployed prior to users and groups are created, allowing
users' passwords to be managed by vars. The secret will be stored in `/run/secrets-for-users` and `owner` and `group` must be `root`. users' passwords to be managed by vars. The secret will be stored in `/run/secrets-for-users` and `owner` and `group` must be `root`.
''; '';
type = lib.types.enum [ type = enum [
"partitioning" "partitioning"
"activation" "activation"
"users" "users"
@@ -251,7 +252,7 @@ in
defaultText = lib.literalExpression ''if _class == "darwin" then "wheel" else "root"''; defaultText = lib.literalExpression ''if _class == "darwin" then "wheel" else "root"'';
}; };
mode = lib.mkOption { mode = lib.mkOption {
type = lib.types.strMatching "^[0-7]{4}$"; type = strMatching "^[0-7]{4}$";
description = "The unix file mode of the file. Must be a 4-digit octal number."; description = "The unix file mode of the file. Must be a 4-digit octal number.";
default = "0400"; default = "0400";
}; };
@@ -375,7 +376,7 @@ in
- all required programs are in PATH - all required programs are in PATH
- sandbox is set up correctly - sandbox is set up correctly
''; '';
type = lib.types.path; type = path;
readOnly = true; readOnly = true;
internal = true; internal = true;
}; };

View File

@@ -250,12 +250,12 @@ This subcommand allows seamless ssh access to the nixos-image builders or a mach
Examples: Examples:
$ clan ssh [ssh_args ...] berlin` $ clan ssh berlin
Will ssh in to the machine called `berlin`, using the Will ssh in to the machine called `berlin`, using the
`clan.core.networking.targetHost` specified in its configuration `clan.core.networking.targetHost` specified in its configuration
$ clan ssh [ssh_args ...] --json [JSON] $ clan ssh --json [JSON] --host-key-check none
Will ssh in to the machine based on the deployment information contained in Will ssh in to the machine based on the deployment information contained in
the json string. [JSON] can either be a json formatted string itself, or point the json string. [JSON] can either be a json formatted string itself, or point
towards a file containing the deployment information towards a file containing the deployment information

View File

@@ -24,12 +24,14 @@ def install_command(args: argparse.Namespace) -> None:
# Only if the caller did not specify a target_host via args.target_host # Only if the caller did not specify a target_host via args.target_host
# Find a suitable target_host that is reachable # Find a suitable target_host that is reachable
target_host_str = args.target_host target_host_str = args.target_host
deploy_info: DeployInfo | None = ssh_command_parse(args) deploy_info: DeployInfo | None = (
ssh_command_parse(args) if target_host_str is None else None
)
use_tor = False use_tor = False
if deploy_info and not args.target_host: if deploy_info:
host = find_reachable_host(deploy_info) host = find_reachable_host(deploy_info)
if host is None: if host is None or host.tor_socks:
use_tor = True use_tor = True
target_host_str = deploy_info.tor.target target_host_str = deploy_info.tor.target
else: else:

View File

@@ -1,12 +1,14 @@
import argparse import argparse
import json import json
import logging import logging
import textwrap
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from clan_lib.cmd import run from clan_lib.cmd import run
from clan_lib.errors import ClanError from clan_lib.errors import ClanError
from clan_lib.machines.machines import Machine
from clan_lib.nix import nix_shell from clan_lib.nix import nix_shell
from clan_lib.ssh.remote import HostKeyCheck, Remote from clan_lib.ssh.remote import HostKeyCheck, Remote
@@ -37,20 +39,23 @@ class DeployInfo:
raise ClanError(msg) raise ClanError(msg)
return addrs[0] return addrs[0]
@staticmethod def overwrite_remotes(
def from_hostnames( self,
hostname: list[str], host_key_check: HostKeyCheck host_key_check: HostKeyCheck | None = None,
private_key: Path | None = None,
ssh_options: dict[str, str] | None = None,
) -> "DeployInfo": ) -> "DeployInfo":
remotes = [] """Return a new DeployInfo with all Remotes overridden with the given host_key_check."""
for host in hostname: return DeployInfo(
if not host: addrs=[
msg = "Hostname cannot be empty." addr.override(
raise ClanError(msg) host_key_check=host_key_check,
remote = Remote.from_ssh_uri( private_key=private_key,
machine_name="clan-installer", address=host ssh_options=ssh_options,
).override(host_key_check=host_key_check) )
remotes.append(remote) for addr in self.addrs
return DeployInfo(addrs=remotes) ]
)
@staticmethod @staticmethod
def from_json(data: dict[str, Any], host_key_check: HostKeyCheck) -> "DeployInfo": def from_json(data: dict[str, Any], host_key_check: HostKeyCheck) -> "DeployInfo":
@@ -103,9 +108,22 @@ def find_reachable_host(deploy_info: DeployInfo) -> Remote | None:
return None return None
def ssh_shell_from_deploy(deploy_info: DeployInfo) -> None: def ssh_shell_from_deploy(
deploy_info: DeployInfo, command: list[str] | None = None
) -> None:
if command and len(command) == 1 and command[0].count(" ") > 0:
msg = (
textwrap.dedent("""
It looks like you quoted the remote command.
The first argument should be the command to run, not a quoted string.
""")
.lstrip("\n")
.rstrip("\n")
)
raise ClanError(msg)
if host := find_reachable_host(deploy_info): if host := find_reachable_host(deploy_info):
host.interactive_ssh() host.interactive_ssh(command)
return return
log.info("Could not reach host via clearnet 'addrs'") log.info("Could not reach host via clearnet 'addrs'")
@@ -127,7 +145,7 @@ def ssh_shell_from_deploy(deploy_info: DeployInfo) -> None:
log.info( log.info(
"Host reachable via tor address, starting interactive ssh session." "Host reachable via tor address, starting interactive ssh session."
) )
tor_addr.interactive_ssh() tor_addr.interactive_ssh(command)
return return
log.error("Could not reach host via tor address.") log.error("Could not reach host via tor address.")
@@ -135,19 +153,35 @@ def ssh_shell_from_deploy(deploy_info: DeployInfo) -> None:
def ssh_command_parse(args: argparse.Namespace) -> DeployInfo | None: def ssh_command_parse(args: argparse.Namespace) -> DeployInfo | None:
host_key_check = args.host_key_check host_key_check = args.host_key_check
deploy = None
if args.json: if args.json:
json_file = Path(args.json) json_file = Path(args.json)
if json_file.is_file(): if json_file.is_file():
data = json.loads(json_file.read_text()) data = json.loads(json_file.read_text())
return DeployInfo.from_json(data, host_key_check) return DeployInfo.from_json(data, host_key_check)
data = json.loads(args.json) data = json.loads(args.json)
return DeployInfo.from_json(data, host_key_check) deploy = DeployInfo.from_json(data, host_key_check)
if args.png: elif args.png:
return DeployInfo.from_qr_code(Path(args.png), host_key_check) deploy = DeployInfo.from_qr_code(Path(args.png), host_key_check)
elif hasattr(args, "machine") and args.machine:
machine = Machine(args.machine, args.flake)
target = machine.target_host().override(
command_prefix=machine.name, host_key_check=host_key_check
)
deploy = DeployInfo(addrs=[target])
else:
return None
if hasattr(args, "machines"): ssh_options = None
return DeployInfo.from_hostnames(args.machines, host_key_check) if hasattr(args, "ssh_option") and args.ssh_option:
return None for name, value in args.ssh_option:
ssh_options = {}
ssh_options[name] = value
deploy = deploy.overwrite_remotes(ssh_options=ssh_options)
return deploy
def ssh_command(args: argparse.Namespace) -> None: def ssh_command(args: argparse.Namespace) -> None:
@@ -155,36 +189,63 @@ def ssh_command(args: argparse.Namespace) -> None:
if not deploy_info: if not deploy_info:
msg = "No MACHINE, --json or --png data provided" msg = "No MACHINE, --json or --png data provided"
raise ClanError(msg) raise ClanError(msg)
ssh_shell_from_deploy(deploy_info, args.remote_command)
ssh_shell_from_deploy(deploy_info)
def register_parser(parser: argparse.ArgumentParser) -> None: def register_parser(parser: argparse.ArgumentParser) -> None:
group = parser.add_mutually_exclusive_group(required=True) group = parser.add_mutually_exclusive_group()
machines_parser = group.add_argument( group.add_argument(
"machines", "machine",
type=str, type=str,
nargs="*", nargs="?",
default=[],
metavar="MACHINE", metavar="MACHINE",
help="Machine to ssh into.", help="Machine to ssh into (uses clan.core.networking.targetHost from configuration).",
) )
add_dynamic_completer(machines_parser, complete_machines)
group.add_argument( group.add_argument(
"-j", "-j",
"--json", "--json",
help="specify the json file for ssh data (generated by starting the clan installer)", type=str,
help=(
"Deployment information as a JSON string or path to a JSON file "
"(generated by starting the clan installer)."
),
) )
group.add_argument( group.add_argument(
"-P", "-P",
"--png", "--png",
help="specify the json file for ssh data as the qrcode image (generated by starting the clan installer)", type=str,
help="Deployment information as a QR code image file (generated by starting the clan installer).",
) )
parser.add_argument( parser.add_argument(
"--host-key-check", "--host-key-check",
choices=["strict", "ask", "tofu", "none"], choices=["strict", "ask", "tofu", "none"],
default="tofu", default="tofu",
help="Host key (.ssh/known_hosts) check mode.", help="Host key (.ssh/known_hosts) check mode.",
) )
parser.add_argument(
"--ssh-option",
help="SSH option to set (can be specified multiple times)",
nargs=2,
metavar=("name", "value"),
action="append",
default=[],
)
parser.add_argument(
"-c",
"--remote-command",
type=str,
metavar="COMMAND",
nargs=argparse.REMAINDER,
help="Command to execute on the remote host, needs to be the LAST argument as it takes all remaining arguments.",
)
add_dynamic_completer(
parser._actions[1], # noqa: SLF001
complete_machines,
) # assumes 'machine' is the first positional
parser.set_defaults(func=ssh_command) parser.set_defaults(func=ssh_command)

View File

@@ -7,6 +7,8 @@ from clan_lib.nix import nix_shell
from clan_lib.ssh.remote import Remote from clan_lib.ssh.remote import Remote
from clan_cli.ssh.deploy_info import DeployInfo, find_reachable_host from clan_cli.ssh.deploy_info import DeployInfo, find_reachable_host
from clan_cli.tests.fixtures_flakes import ClanFlake
from clan_cli.tests.helpers import cli
def test_qrcode_scan(temp_dir: Path) -> None: def test_qrcode_scan(temp_dir: Path) -> None:
@@ -69,7 +71,10 @@ def test_from_json() -> None:
@pytest.mark.with_core @pytest.mark.with_core
def test_find_reachable_host(hosts: list[Remote]) -> None: def test_find_reachable_host(hosts: list[Remote]) -> None:
host = hosts[0] host = hosts[0]
deploy_info = DeployInfo.from_hostnames(["172.19.1.2", host.ssh_url()], "none")
uris = ["172.19.1.2", host.ssh_url()]
remotes = [Remote.from_ssh_uri(machine_name="some", address=uri) for uri in uris]
deploy_info = DeployInfo(addrs=remotes)
assert deploy_info.addrs[0].address == "172.19.1.2" assert deploy_info.addrs[0].address == "172.19.1.2"
@@ -77,3 +82,40 @@ def test_find_reachable_host(hosts: list[Remote]) -> None:
assert remote is not None assert remote is not None
assert remote.ssh_url() == host.ssh_url() assert remote.ssh_url() == host.ssh_url()
@pytest.mark.with_core
def test_ssh_shell_from_deploy(
hosts: list[Remote],
flake: ClanFlake,
) -> None:
host = hosts[0]
machine1_config = flake.machines["m1_machine"]
machine1_config["nixpkgs"]["hostPlatform"] = "x86_64-linux"
machine1_config["clan"]["networking"]["targetHost"] = host.ssh_url()
flake.refresh()
assert host.private_key
success_txt = flake.path / "success.txt"
assert not success_txt.exists()
cli.run(
[
"ssh",
"--flake",
str(flake.path),
"m1_machine",
"--host-key-check=none",
"--ssh-option",
"IdentityFile",
str(host.private_key),
"--remote-command",
"touch",
str(success_txt),
"&&",
"exit 0",
]
)
assert success_txt.exists()

View File

@@ -16,7 +16,7 @@ from tempfile import TemporaryDirectory
from clan_lib.api import API from clan_lib.api import API
from clan_lib.cmd import ClanCmdError, ClanCmdTimeoutError, CmdOut, RunOpts, run from clan_lib.cmd import ClanCmdError, ClanCmdTimeoutError, CmdOut, RunOpts, run
from clan_lib.colors import AnsiColor from clan_lib.colors import AnsiColor
from clan_lib.errors import ClanError # Assuming these are available from clan_lib.errors import ClanError, indent_command # Assuming these are available
from clan_lib.nix import nix_shell from clan_lib.nix import nix_shell
from clan_lib.ssh.host_key import HostKeyCheck, hostkey_to_ssh_opts from clan_lib.ssh.host_key import HostKeyCheck, hostkey_to_ssh_opts
from clan_lib.ssh.parse import parse_ssh_uri from clan_lib.ssh.parse import parse_ssh_uri
@@ -61,6 +61,9 @@ class Remote:
private_key: Path | None = None, private_key: Path | None = None,
password: str | None = None, password: str | None = None,
tor_socks: bool | None = None, tor_socks: bool | None = None,
command_prefix: str | None = None,
port: int | None = None,
ssh_options: dict[str, str] | None = None,
) -> "Remote": ) -> "Remote":
""" """
Returns a new Remote instance with the same data but with a different host_key_check. Returns a new Remote instance with the same data but with a different host_key_check.
@@ -68,8 +71,8 @@ class Remote:
return Remote( return Remote(
address=self.address, address=self.address,
user=self.user, user=self.user,
command_prefix=self.command_prefix, command_prefix=command_prefix or self.command_prefix,
port=self.port, port=port or self.port,
private_key=private_key if private_key is not None else self.private_key, private_key=private_key if private_key is not None else self.private_key,
password=password if password is not None else self.password, password=password if password is not None else self.password,
forward_agent=self.forward_agent, forward_agent=self.forward_agent,
@@ -77,7 +80,7 @@ class Remote:
host_key_check if host_key_check is not None else self.host_key_check host_key_check if host_key_check is not None else self.host_key_check
), ),
verbose_ssh=self.verbose_ssh, verbose_ssh=self.verbose_ssh,
ssh_options=self.ssh_options, ssh_options=ssh_options or self.ssh_options,
tor_socks=tor_socks if tor_socks is not None else self.tor_socks, tor_socks=tor_socks if tor_socks is not None else self.tor_socks,
_control_path_dir=self._control_path_dir, _control_path_dir=self._control_path_dir,
_askpass_path=self._askpass_path, _askpass_path=self._askpass_path,
@@ -418,11 +421,31 @@ class Remote:
msg = f"SSH command failed with return code {res.returncode}" msg = f"SSH command failed with return code {res.returncode}"
raise ClanError(msg) raise ClanError(msg)
def interactive_ssh(self) -> None: def interactive_ssh(self, command: list[str] | None = None) -> None:
cmd_list = self.ssh_cmd(tty=True, control_master=False) ssh_cmd = self.ssh_cmd(tty=True, control_master=False)
res = subprocess.run(cmd_list, check=False) if command:
ssh_cmd = [
*self.ssh_cmd(tty=True, control_master=False),
"--",
"bash",
"-c",
quote('exec "$@"'),
"--",
" ".join(map(quote, command)),
]
cmdlog.info(
f"{indent_command(ssh_cmd)}",
extra={
"command_prefix": self.command_prefix,
"color": AnsiColor.GREEN.value,
},
)
res = subprocess.run(ssh_cmd, check=False)
self.check_sshpass_errorcode(res) # We only check the error code if a password is set, as sshpass is used.
# AS sshpass swallows all output.
if self.password:
self.check_sshpass_errorcode(res)
def check_machine_ssh_reachable(self) -> bool: def check_machine_ssh_reachable(self) -> bool:
return check_machine_ssh_reachable(self).ok return check_machine_ssh_reachable(self).ok
@@ -431,7 +454,7 @@ class Remote:
@dataclass(frozen=True) @dataclass(frozen=True)
class ConnectionOptions: class ConnectionOptions:
timeout: int = 2 timeout: int = 2
retries: int = 10 retries: int = 5
@dataclass @dataclass
@@ -504,6 +527,10 @@ def check_machine_ssh_reachable(
if opts is None: if opts is None:
opts = ConnectionOptions() opts = ConnectionOptions()
cmdlog.debug(
f"Checking SSH reachability for {remote.target} on port {remote.port or 22}",
)
address_family = socket.AF_INET6 if ":" in remote.address else socket.AF_INET address_family = socket.AF_INET6 if ":" in remote.address else socket.AF_INET
for _ in range(opts.retries): for _ in range(opts.retries):
with socket.socket(address_family, socket.SOCK_STREAM) as sock: with socket.socket(address_family, socket.SOCK_STREAM) as sock:

View File

@@ -1,15 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"lockFileMaintenance": { "enabled": true },
"nix": {
"enabled": true
},
"packageRules": [
{
"matchManagers": ["npm"],
"matchPaths": ["pkgs/clan-app/ui/**"],
"enabled": false
}
]
}