fling-remote 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: fling-remote
3
+ Version: 0.1.0
4
+ Summary: Lightweight SSH orchestration CLI with encrypted credential vault
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: asyncssh>=2.17
7
+ Requires-Dist: cryptography>=42.0
8
+ Requires-Dist: typer>=0.12
9
+ Requires-Dist: rich>=13.0
10
+ Requires-Dist: python-dotenv>=1.0
File without changes
@@ -0,0 +1,247 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv(Path(__file__).resolve().parent.parent / ".env")
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from fling.models import ServerProfile
15
+ from fling.ssh import run_command, run_on_all, transfer_files
16
+ from fling.vault import (
17
+ create_vault,
18
+ get_master_password,
19
+ load_profiles,
20
+ remove_profile,
21
+ save_profile,
22
+ vault_exists,
23
+ )
24
+
25
+ app = typer.Typer(help="fling — lightweight SSH orchestration with encrypted credentials")
26
+ console = Console()
27
+
28
+
29
+ @app.command()
30
+ def setup():
31
+ """Interactive setup wizard — discover hosts from SSH config and probe connectivity."""
32
+ from fling.setup import run_setup_wizard
33
+ run_setup_wizard()
34
+
35
+
36
+ @app.command()
37
+ def init():
38
+ """Initialize the encrypted vault with a master password."""
39
+ if vault_exists():
40
+ console.print("[yellow]Vault already exists.[/yellow] Use 'fling add' to add servers.")
41
+ raise typer.Exit()
42
+ pw = typer.prompt("Set master password", hide_input=True)
43
+ pw2 = typer.prompt("Confirm master password", hide_input=True)
44
+ if pw != pw2:
45
+ console.print("[red]Passwords do not match.[/red]")
46
+ raise typer.Exit(1)
47
+ create_vault(pw)
48
+ console.print("[green]Vault created.[/green] Use 'fling add <alias>' to add servers.")
49
+
50
+
51
+ @app.command()
52
+ def add(alias: str):
53
+ """Add a server profile to the vault."""
54
+ if not vault_exists():
55
+ console.print("[red]No vault found.[/red] Run 'fling init' first.")
56
+ raise typer.Exit(1)
57
+ pw = get_master_password()
58
+ host = typer.prompt("Host")
59
+ port = typer.prompt("Port", default=22, type=int)
60
+ username = typer.prompt("Username", default="root")
61
+ password = typer.prompt("SSH password (leave empty for key auth)", default="", hide_input=True)
62
+ auth = "password" if password else "key"
63
+ profile = ServerProfile(
64
+ alias=alias, host=host, port=port, username=username,
65
+ password=password, auth_method=auth,
66
+ )
67
+ try:
68
+ save_profile(pw, profile)
69
+ except ValueError as e:
70
+ console.print(f"[red]{e}[/red]")
71
+ raise typer.Exit(1)
72
+ console.print(f"[green]Added '{alias}' ({auth} auth).[/green]")
73
+
74
+
75
+ @app.command()
76
+ def remove(alias: str):
77
+ """Remove a server profile from the vault."""
78
+ if not vault_exists():
79
+ console.print("[red]No vault found.[/red]")
80
+ raise typer.Exit(1)
81
+ pw = get_master_password()
82
+ try:
83
+ if remove_profile(pw, alias):
84
+ console.print(f"[green]Removed '{alias}'.[/green]")
85
+ else:
86
+ console.print(f"[yellow]'{alias}' not found.[/yellow]")
87
+ except ValueError as e:
88
+ console.print(f"[red]{e}[/red]")
89
+ raise typer.Exit(1)
90
+
91
+
92
+ @app.command(name="list")
93
+ def list_servers():
94
+ """List all server profiles (passwords hidden)."""
95
+ if not vault_exists():
96
+ console.print("[red]No vault found.[/red] Run 'fling init' first.")
97
+ raise typer.Exit(1)
98
+ pw = get_master_password()
99
+ try:
100
+ profiles = load_profiles(pw)
101
+ except ValueError as e:
102
+ console.print(f"[red]{e}[/red]")
103
+ raise typer.Exit(1)
104
+ if not profiles:
105
+ console.print("[yellow]No servers configured.[/yellow] Use 'fling setup' or 'fling add <alias>'.")
106
+ return
107
+ table = Table(title="Servers")
108
+ table.add_column("Alias", style="cyan")
109
+ table.add_column("Host")
110
+ table.add_column("Port")
111
+ table.add_column("User")
112
+ table.add_column("Auth")
113
+ table.add_column("ProxyJump")
114
+ for p in profiles.values():
115
+ auth_display = "[green]key[/green]" if p.auth_method == "key" else "[yellow]password[/yellow]"
116
+ table.add_row(p.alias, p.host, str(p.port), p.username, auth_display, p.proxy_jump or "-")
117
+ console.print(table)
118
+
119
+
120
+ @app.command()
121
+ def run(
122
+ alias: str,
123
+ command: str,
124
+ sudo: bool = typer.Option(False, "--sudo", help="Run with sudo"),
125
+ timeout: int = typer.Option(30, "--timeout", help="Timeout in seconds"),
126
+ ):
127
+ """Run a command on a single server."""
128
+ if not vault_exists():
129
+ console.print("[red]No vault found.[/red]")
130
+ raise typer.Exit(1)
131
+ pw = get_master_password()
132
+ try:
133
+ profiles = load_profiles(pw)
134
+ except ValueError as e:
135
+ console.print(f"[red]{e}[/red]")
136
+ raise typer.Exit(1)
137
+ if alias not in profiles:
138
+ console.print(f"[red]Server '{alias}' not found.[/red]")
139
+ raise typer.Exit(1)
140
+ with console.status(f"Running on {alias}..."):
141
+ result = asyncio.run(
142
+ run_command(profiles[alias], command, sudo=sudo, timeout=timeout, all_profiles=profiles)
143
+ )
144
+ if result.stdout:
145
+ console.print(result.stdout, end="")
146
+ if result.stderr:
147
+ console.print(f"[red]{result.stderr}[/red]", end="")
148
+ raise typer.Exit(result.exit_code)
149
+
150
+
151
+ @app.command()
152
+ def run_all(
153
+ command: str,
154
+ sudo: bool = typer.Option(False, "--sudo", help="Run with sudo"),
155
+ timeout: int = typer.Option(30, "--timeout", help="Timeout in seconds"),
156
+ ):
157
+ """Run a command on ALL servers in parallel."""
158
+ if not vault_exists():
159
+ console.print("[red]No vault found.[/red]")
160
+ raise typer.Exit(1)
161
+ pw = get_master_password()
162
+ try:
163
+ profiles = load_profiles(pw)
164
+ except ValueError as e:
165
+ console.print(f"[red]{e}[/red]")
166
+ raise typer.Exit(1)
167
+ if not profiles:
168
+ console.print("[yellow]No servers configured.[/yellow]")
169
+ raise typer.Exit(1)
170
+ with console.status(f"Running on {len(profiles)} server(s)..."):
171
+ results = asyncio.run(
172
+ run_on_all(list(profiles.values()), command, sudo=sudo, timeout=timeout, all_profiles=profiles)
173
+ )
174
+ for r in results:
175
+ header = f"[cyan]{r.alias}[/cyan]"
176
+ if r.exit_code == 0:
177
+ header += " [green](ok)[/green]"
178
+ else:
179
+ header += f" [red](exit {r.exit_code})[/red]"
180
+ console.print(header)
181
+ if r.stdout:
182
+ console.print(r.stdout, end="")
183
+ if r.stderr:
184
+ console.print(f"[red]{r.stderr}[/red]", end="")
185
+ console.print()
186
+
187
+
188
+ def _parse_remote_path(spec: str) -> tuple[str, str]:
189
+ """Parse 'alias:path' into (alias, path). Raises if invalid."""
190
+ if ":" not in spec:
191
+ raise typer.BadParameter(f"Expected alias:path, got '{spec}'")
192
+ alias, path = spec.split(":", 1)
193
+ if not alias or not path:
194
+ raise typer.BadParameter(f"Expected alias:path, got '{spec}'")
195
+ return alias, path
196
+
197
+
198
+ @app.command()
199
+ def transfer(
200
+ src: str = typer.Argument(help="Source as alias:/path/to/file_or_dir"),
201
+ dst: str = typer.Argument(help="Destination as alias:/path/to/dir"),
202
+ ):
203
+ """Transfer files between servers using this machine as a relay.
204
+
205
+ Examples:
206
+ fling transfer 6000:~/script.py 4090s:~/jimmys_projects/
207
+ fling transfer e6000:~/jimmys_projects/myapp 6000:~/jimmys_projects/
208
+ fling transfer 4090s:~/jimmys_projects/*.py A100:~/scripts/
209
+ """
210
+ if not vault_exists():
211
+ console.print("[red]No vault found.[/red]")
212
+ raise typer.Exit(1)
213
+ pw = get_master_password()
214
+ try:
215
+ profiles = load_profiles(pw)
216
+ except ValueError as e:
217
+ console.print(f"[red]{e}[/red]")
218
+ raise typer.Exit(1)
219
+
220
+ src_alias, src_path = _parse_remote_path(src)
221
+ dst_alias, dst_path = _parse_remote_path(dst)
222
+
223
+ if src_alias not in profiles:
224
+ console.print(f"[red]Server '{src_alias}' not found.[/red]")
225
+ raise typer.Exit(1)
226
+ if dst_alias not in profiles:
227
+ console.print(f"[red]Server '{dst_alias}' not found.[/red]")
228
+ raise typer.Exit(1)
229
+
230
+ with console.status(f"Transferring {src_alias} → {dst_alias}..."):
231
+ stdout, stderr = asyncio.run(
232
+ transfer_files(
233
+ profiles[src_alias], src_path,
234
+ profiles[dst_alias], dst_path,
235
+ all_profiles=profiles,
236
+ )
237
+ )
238
+ if stdout:
239
+ console.print(stdout)
240
+ if stderr:
241
+ console.print(f"[red]{stderr}[/red]")
242
+ raise typer.Exit(1)
243
+ console.print(f"[green]Done.[/green]")
244
+
245
+
246
+ if __name__ == "__main__":
247
+ app()
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, asdict, field
4
+
5
+
6
+ @dataclass
7
+ class ServerProfile:
8
+ alias: str
9
+ host: str
10
+ port: int = 22
11
+ username: str = "root"
12
+ password: str = ""
13
+ auth_method: str = "password" # "key" or "password"
14
+ proxy_jump: str = "" # alias of another host to jump through
15
+
16
+ def to_dict(self) -> dict:
17
+ return asdict(self)
18
+
19
+ @classmethod
20
+ def from_dict(cls, data: dict) -> ServerProfile:
21
+ # Handle vaults created before auth_method/proxy_jump existed
22
+ data.setdefault("auth_method", "password")
23
+ data.setdefault("proxy_jump", "")
24
+ return cls(**data)
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from fling.models import ServerProfile
11
+ from fling.ssh import probe_connection
12
+ from fling.vault import create_vault, get_master_password, load_profiles, save_profile, vault_exists
13
+
14
+ console = Console()
15
+
16
+ SSH_CONFIG_PATH = Path.home() / ".ssh" / "config"
17
+
18
+
19
+ def parse_ssh_config() -> list[dict]:
20
+ """Parse ~/.ssh/config and return a list of host entries."""
21
+ if not SSH_CONFIG_PATH.exists():
22
+ return []
23
+
24
+ hosts = []
25
+ current: dict | None = None
26
+
27
+ for line in SSH_CONFIG_PATH.read_text().splitlines():
28
+ stripped = line.strip()
29
+ if not stripped or stripped.startswith("#"):
30
+ continue
31
+
32
+ # Split on first whitespace
33
+ parts = stripped.split(None, 1)
34
+ if len(parts) != 2:
35
+ continue
36
+ key, value = parts[0].lower(), parts[1]
37
+
38
+ if key == "host":
39
+ # Skip wildcard entries
40
+ if "*" in value or "?" in value:
41
+ current = None
42
+ continue
43
+ current = {"alias": value, "host": "", "port": 22, "username": "root", "proxy_jump": ""}
44
+ hosts.append(current)
45
+ elif current is not None:
46
+ if key == "hostname":
47
+ current["host"] = value
48
+ elif key == "port":
49
+ current["port"] = int(value)
50
+ elif key == "user":
51
+ current["username"] = value
52
+ elif key == "proxyjump":
53
+ current["proxy_jump"] = value
54
+
55
+ # If HostName wasn't set, use the alias as the host
56
+ for h in hosts:
57
+ if not h["host"]:
58
+ h["host"] = h["alias"]
59
+
60
+ return hosts
61
+
62
+
63
+ async def _probe_with_tunnel(
64
+ entry: dict,
65
+ jump_profile: ServerProfile | None = None,
66
+ password: str | None = None,
67
+ timeout: int = 10,
68
+ ) -> tuple[bool, str]:
69
+ """Probe a host, opening a proxy tunnel if needed — all in one async context."""
70
+ import asyncssh
71
+
72
+ tunnel = None
73
+ try:
74
+ if jump_profile:
75
+ tunnel_kwargs = dict(
76
+ host=jump_profile.host, port=jump_profile.port,
77
+ username=jump_profile.username, known_hosts=None,
78
+ )
79
+ if jump_profile.auth_method == "password" and jump_profile.password:
80
+ tunnel_kwargs["password"] = jump_profile.password
81
+ tunnel = await asyncio.wait_for(asyncssh.connect(**tunnel_kwargs), timeout=timeout)
82
+
83
+ success, method = await probe_connection(
84
+ host=entry["host"],
85
+ port=entry["port"],
86
+ username=entry["username"],
87
+ password=password,
88
+ proxy_jump_conn=tunnel,
89
+ timeout=timeout,
90
+ )
91
+ return success, method
92
+ except Exception:
93
+ return False, ""
94
+ finally:
95
+ if tunnel:
96
+ tunnel.close()
97
+
98
+
99
+ def run_setup_wizard():
100
+ """Interactive setup wizard that discovers and probes SSH hosts."""
101
+
102
+ console.print("\n[bold cyan]fling setup wizard[/bold cyan]\n")
103
+
104
+ # Step 1: Ensure vault exists
105
+ if not vault_exists():
106
+ console.print("No vault found. Let's create one first.\n")
107
+ pw = typer.prompt("Set master password", hide_input=True)
108
+ pw2 = typer.prompt("Confirm master password", hide_input=True)
109
+ if pw != pw2:
110
+ console.print("[red]Passwords do not match.[/red]")
111
+ raise typer.Exit(1)
112
+ create_vault(pw)
113
+ console.print("[green]Vault created.[/green]\n")
114
+ else:
115
+ console.print("Vault found.\n")
116
+
117
+ pw = get_master_password()
118
+
119
+ # Validate password
120
+ try:
121
+ existing = load_profiles(pw)
122
+ except ValueError:
123
+ console.print("[red]Invalid master password.[/red]")
124
+ raise typer.Exit(1)
125
+
126
+ # Step 2: Parse SSH config
127
+ entries = parse_ssh_config()
128
+ if not entries:
129
+ console.print("[yellow]No hosts found in ~/.ssh/config.[/yellow]")
130
+ else:
131
+ console.print(f"Found [cyan]{len(entries)}[/cyan] hosts in ~/.ssh/config:\n")
132
+ table = Table()
133
+ table.add_column("Alias", style="cyan")
134
+ table.add_column("Host")
135
+ table.add_column("Port")
136
+ table.add_column("User")
137
+ table.add_column("ProxyJump")
138
+ for e in entries:
139
+ table.add_row(e["alias"], e["host"], str(e["port"]), e["username"], e["proxy_jump"] or "-")
140
+ console.print(table)
141
+ console.print()
142
+
143
+ # Step 3: Probe each host
144
+ results: list[tuple[str, str]] = [] # (alias, status)
145
+ # Sort: non-proxyjump hosts first (they might be needed as tunnels)
146
+ non_proxy = [e for e in entries if not e["proxy_jump"]]
147
+ with_proxy = [e for e in entries if e["proxy_jump"]]
148
+
149
+ # Track connected profiles for proxy jump resolution
150
+ connected_profiles: dict[str, ServerProfile] = dict(existing)
151
+
152
+ for entry in non_proxy + with_proxy:
153
+ alias = entry["alias"]
154
+
155
+ # Skip if already in vault
156
+ if alias in existing:
157
+ console.print(f" [dim]{alias}[/dim] — already in vault, skipping")
158
+ results.append((alias, "existing"))
159
+ continue
160
+
161
+ console.print(f" [cyan]{alias}[/cyan] ({entry['host']}:{entry['port']}) — ", end="")
162
+
163
+ # Resolve proxy jump profile if needed
164
+ jump_profile = None
165
+ if entry["proxy_jump"]:
166
+ if entry["proxy_jump"] in connected_profiles:
167
+ jump_profile = connected_profiles[entry["proxy_jump"]]
168
+ else:
169
+ console.print("[yellow]proxy jump host not available, skipping[/yellow]")
170
+ results.append((alias, "skipped"))
171
+ continue
172
+
173
+ # Try key-based auth (tunnel + probe in single async context)
174
+ success, method = asyncio.run(
175
+ _probe_with_tunnel(entry, jump_profile=jump_profile, timeout=10)
176
+ )
177
+
178
+ if success:
179
+ console.print(f"[green]connected (ssh key)[/green]")
180
+ profile = ServerProfile(
181
+ alias=alias, host=entry["host"], port=entry["port"],
182
+ username=entry["username"], password="",
183
+ auth_method="key", proxy_jump=entry.get("proxy_jump", ""),
184
+ )
185
+ save_profile(pw, profile)
186
+ connected_profiles[alias] = profile
187
+ results.append((alias, "key"))
188
+ else:
189
+ console.print("[yellow]key auth failed[/yellow]")
190
+ try_password = typer.confirm(f" Try password for {alias}?", default=True)
191
+ if not try_password:
192
+ results.append((alias, "skipped"))
193
+ continue
194
+
195
+ password = typer.prompt(f" Password for {entry['username']}@{alias}", hide_input=True)
196
+ pw_success, _ = asyncio.run(
197
+ _probe_with_tunnel(entry, jump_profile=jump_profile, password=password, timeout=10)
198
+ )
199
+
200
+ if pw_success:
201
+ console.print(f" [green]connected (password)[/green]")
202
+ profile = ServerProfile(
203
+ alias=alias, host=entry["host"], port=entry["port"],
204
+ username=entry["username"], password=password,
205
+ auth_method="password", proxy_jump=entry.get("proxy_jump", ""),
206
+ )
207
+ save_profile(pw, profile)
208
+ connected_profiles[alias] = profile
209
+ results.append((alias, "password"))
210
+ else:
211
+ console.print(f" [red]failed[/red]")
212
+ results.append((alias, "failed"))
213
+
214
+ # Step 4: Summary
215
+ console.print("\n[bold]Summary:[/bold]")
216
+ summary_table = Table()
217
+ summary_table.add_column("Server", style="cyan")
218
+ summary_table.add_column("Status")
219
+ for alias, status in results:
220
+ style_map = {
221
+ "key": "[green]saved (ssh key)[/green]",
222
+ "password": "[green]saved (password)[/green]",
223
+ "existing": "[dim]already configured[/dim]",
224
+ "skipped": "[yellow]skipped[/yellow]",
225
+ "failed": "[red]failed[/red]",
226
+ }
227
+ summary_table.add_row(alias, style_map.get(status, status))
228
+ console.print(summary_table)
229
+
230
+ # Step 5: Add more?
231
+ console.print()
232
+ while typer.confirm("Add another server manually?", default=False):
233
+ alias = typer.prompt(" Alias")
234
+ host = typer.prompt(" Host")
235
+ port = typer.prompt(" Port", default=22, type=int)
236
+ username = typer.prompt(" Username", default="root")
237
+ password = typer.prompt(" SSH password (leave empty for key auth)", default="", hide_input=True)
238
+ auth = "password" if password else "key"
239
+ profile = ServerProfile(
240
+ alias=alias, host=host, port=port, username=username,
241
+ password=password, auth_method=auth,
242
+ )
243
+ save_profile(pw, profile)
244
+ console.print(f" [green]Added '{alias}'.[/green]")
245
+
246
+ console.print("\n[green]Setup complete.[/green] Use 'fling list' to see your servers.")
@@ -0,0 +1,249 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+
6
+ import asyncssh
7
+
8
+ from fling.models import ServerProfile
9
+
10
+
11
+ @dataclass
12
+ class CommandResult:
13
+ alias: str
14
+ stdout: str
15
+ stderr: str
16
+ exit_code: int
17
+
18
+
19
+ async def _get_tunnel(proxy_jump: str, profiles: dict[str, ServerProfile] | None = None):
20
+ """Open a tunnel connection through a proxy jump host."""
21
+ if not proxy_jump or not profiles or proxy_jump not in profiles:
22
+ return None
23
+ jump = profiles[proxy_jump]
24
+ connect_kwargs = dict(
25
+ host=jump.host,
26
+ port=jump.port,
27
+ username=jump.username,
28
+ known_hosts=None,
29
+ )
30
+ if jump.auth_method == "password" and jump.password:
31
+ connect_kwargs["password"] = jump.password
32
+ return await asyncssh.connect(**connect_kwargs)
33
+
34
+
35
+ def _connect_kwargs(profile: ServerProfile) -> dict:
36
+ """Build asyncssh.connect kwargs based on auth method."""
37
+ kwargs = dict(
38
+ host=profile.host,
39
+ port=profile.port,
40
+ username=profile.username,
41
+ known_hosts=None,
42
+ )
43
+ if profile.auth_method == "password" and profile.password:
44
+ kwargs["password"] = profile.password
45
+ return kwargs
46
+
47
+
48
+ async def probe_connection(
49
+ host: str,
50
+ port: int,
51
+ username: str,
52
+ password: str | None = None,
53
+ proxy_jump_conn=None,
54
+ timeout: int = 10,
55
+ ) -> tuple[bool, str]:
56
+ """Try to connect and run 'echo ok'. Returns (success, auth_method_used)."""
57
+ # Try key-based auth first
58
+ try:
59
+ connect_kwargs = dict(
60
+ host=host, port=port, username=username, known_hosts=None,
61
+ )
62
+ if proxy_jump_conn:
63
+ connect_kwargs["tunnel"] = proxy_jump_conn
64
+ async with asyncssh.connect(**connect_kwargs) as conn:
65
+ result = await asyncio.wait_for(conn.run("echo ok", check=False), timeout=timeout)
66
+ if result.exit_status == 0:
67
+ return True, "key"
68
+ except Exception:
69
+ pass
70
+
71
+ # Try password auth if provided
72
+ if password:
73
+ try:
74
+ connect_kwargs = dict(
75
+ host=host, port=port, username=username,
76
+ password=password, known_hosts=None,
77
+ )
78
+ if proxy_jump_conn:
79
+ connect_kwargs["tunnel"] = proxy_jump_conn
80
+ async with asyncssh.connect(**connect_kwargs) as conn:
81
+ result = await asyncio.wait_for(conn.run("echo ok", check=False), timeout=timeout)
82
+ if result.exit_status == 0:
83
+ return True, "password"
84
+ except Exception:
85
+ pass
86
+
87
+ return False, ""
88
+
89
+
90
+ async def run_command(
91
+ profile: ServerProfile,
92
+ command: str,
93
+ sudo: bool = False,
94
+ timeout: int = 30,
95
+ all_profiles: dict[str, ServerProfile] | None = None,
96
+ ) -> CommandResult:
97
+ tunnel = None
98
+ try:
99
+ if profile.proxy_jump:
100
+ tunnel = await _get_tunnel(profile.proxy_jump, all_profiles)
101
+
102
+ kwargs = _connect_kwargs(profile)
103
+ if tunnel:
104
+ kwargs["tunnel"] = tunnel
105
+
106
+ async with asyncssh.connect(**kwargs) as conn:
107
+ if sudo:
108
+ command = f"sudo -S {command}"
109
+ sudo_input = (profile.password + "\n") if profile.password else ""
110
+ result = await asyncio.wait_for(
111
+ conn.run(command, input=sudo_input, check=False),
112
+ timeout=timeout,
113
+ )
114
+ else:
115
+ result = await asyncio.wait_for(
116
+ conn.run(command, check=False),
117
+ timeout=timeout,
118
+ )
119
+ return CommandResult(
120
+ alias=profile.alias,
121
+ stdout=result.stdout or "",
122
+ stderr=result.stderr or "",
123
+ exit_code=result.exit_status or 0,
124
+ )
125
+ except asyncio.TimeoutError:
126
+ return CommandResult(
127
+ alias=profile.alias, stdout="",
128
+ stderr=f"Timed out after {timeout}s", exit_code=-1,
129
+ )
130
+ except Exception as e:
131
+ return CommandResult(
132
+ alias=profile.alias, stdout="", stderr=str(e), exit_code=-1,
133
+ )
134
+ finally:
135
+ if tunnel:
136
+ tunnel.close()
137
+
138
+
139
+ async def transfer_files(
140
+ src_profile: ServerProfile,
141
+ src_path: str,
142
+ dst_profile: ServerProfile,
143
+ dst_path: str,
144
+ all_profiles: dict[str, ServerProfile] | None = None,
145
+ ) -> tuple[str, str]:
146
+ """Download files from src to a local temp dir, then upload to dst.
147
+
148
+ Returns (stdout_log, stderr_log).
149
+ """
150
+ import tempfile
151
+ import os
152
+
153
+ log_lines = []
154
+ errors = []
155
+
156
+ with tempfile.TemporaryDirectory(prefix="fling_transfer_") as tmp:
157
+ # --- Download from source ---
158
+ src_tunnel = None
159
+ try:
160
+ if src_profile.proxy_jump:
161
+ src_tunnel = await _get_tunnel(src_profile.proxy_jump, all_profiles)
162
+ kwargs = _connect_kwargs(src_profile)
163
+ if src_tunnel:
164
+ kwargs["tunnel"] = src_tunnel
165
+
166
+ async with asyncssh.connect(**kwargs) as conn:
167
+ # Check if source is a directory
168
+ result = await conn.run(f"test -d {src_path} && echo DIR || echo FILE", check=False)
169
+ is_dir = result.stdout.strip() == "DIR"
170
+
171
+ if is_dir:
172
+ log_lines.append(f"Downloading directory {src_path} from {src_profile.alias}...")
173
+ await asyncssh.scp(
174
+ (conn, src_path), tmp, recurse=True, preserve=True
175
+ )
176
+ else:
177
+ # Could be a glob or single file — let the remote expand it
178
+ result = await conn.run(f"ls -1 {src_path} 2>/dev/null", check=False)
179
+ files = [f for f in result.stdout.strip().split("\n") if f]
180
+ if not files:
181
+ return "", f"No files matched: {src_path}"
182
+ log_lines.append(f"Downloading {len(files)} file(s) from {src_profile.alias}...")
183
+ for f in files:
184
+ await asyncssh.scp(
185
+ (conn, f), tmp, preserve=True
186
+ )
187
+ except Exception as e:
188
+ return "", f"Download failed: {e}"
189
+ finally:
190
+ if src_tunnel:
191
+ src_tunnel.close()
192
+
193
+ # List what we got
194
+ local_files = []
195
+ for root, dirs, filenames in os.walk(tmp):
196
+ for fname in filenames:
197
+ full = os.path.join(root, fname)
198
+ rel = os.path.relpath(full, tmp)
199
+ local_files.append(rel)
200
+ log_lines.append(f"Staged {len(local_files)} file(s) locally")
201
+
202
+ # --- Upload to destination ---
203
+ dst_tunnel = None
204
+ try:
205
+ if dst_profile.proxy_jump:
206
+ dst_tunnel = await _get_tunnel(dst_profile.proxy_jump, all_profiles)
207
+ kwargs = _connect_kwargs(dst_profile)
208
+ if dst_tunnel:
209
+ kwargs["tunnel"] = dst_tunnel
210
+
211
+ async with asyncssh.connect(**kwargs) as conn:
212
+ # Ensure destination directory exists
213
+ await conn.run(f"mkdir -p {dst_path}", check=False)
214
+
215
+ log_lines.append(f"Uploading to {dst_profile.alias}:{dst_path}...")
216
+ # Upload everything from tmp
217
+ entries = os.listdir(tmp)
218
+ for entry in entries:
219
+ src_local = os.path.join(tmp, entry)
220
+ is_local_dir = os.path.isdir(src_local)
221
+ await asyncssh.scp(
222
+ src_local, (conn, dst_path),
223
+ recurse=is_local_dir, preserve=True
224
+ )
225
+ except Exception as e:
226
+ return "\n".join(log_lines), f"Upload failed: {e}"
227
+ finally:
228
+ if dst_tunnel:
229
+ dst_tunnel.close()
230
+
231
+ log_lines.append("Transfer complete")
232
+ for f in local_files:
233
+ log_lines.append(f" {f}")
234
+
235
+ return "\n".join(log_lines), "\n".join(errors) if errors else ""
236
+
237
+
238
+ async def run_on_all(
239
+ profiles: list[ServerProfile],
240
+ command: str,
241
+ sudo: bool = False,
242
+ timeout: int = 30,
243
+ all_profiles: dict[str, ServerProfile] | None = None,
244
+ ) -> list[CommandResult]:
245
+ tasks = [
246
+ run_command(p, command, sudo=sudo, timeout=timeout, all_profiles=all_profiles)
247
+ for p in profiles
248
+ ]
249
+ return await asyncio.gather(*tasks)
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from cryptography.fernet import Fernet, InvalidToken
9
+ from cryptography.hazmat.primitives import hashes
10
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
11
+
12
+ from fling.models import ServerProfile
13
+
14
+ VAULT_DIR = Path.home() / ".config" / "fling"
15
+ VAULT_FILE = VAULT_DIR / "vault.enc"
16
+ SALT_SIZE = 16
17
+ KDF_ITERATIONS = 600_000
18
+
19
+
20
+ def _derive_key(password: str, salt: bytes) -> bytes:
21
+ kdf = PBKDF2HMAC(
22
+ algorithm=hashes.SHA256(),
23
+ length=32,
24
+ salt=salt,
25
+ iterations=KDF_ITERATIONS,
26
+ )
27
+ return base64.urlsafe_b64encode(kdf.derive(password.encode()))
28
+
29
+
30
+ def get_master_password() -> str:
31
+ pw = os.environ.get("FLING_MASTER_KEY")
32
+ if pw:
33
+ return pw
34
+ import getpass
35
+ return getpass.getpass("Master password: ")
36
+
37
+
38
+ def vault_exists() -> bool:
39
+ return VAULT_FILE.exists()
40
+
41
+
42
+ def create_vault(password: str) -> None:
43
+ VAULT_DIR.mkdir(parents=True, exist_ok=True)
44
+ salt = os.urandom(SALT_SIZE)
45
+ key = _derive_key(password, salt)
46
+ f = Fernet(key)
47
+ encrypted = f.encrypt(json.dumps({}).encode())
48
+ VAULT_FILE.write_bytes(salt + encrypted)
49
+ VAULT_FILE.chmod(0o600)
50
+
51
+
52
+ def _load_raw(password: str) -> dict:
53
+ data = VAULT_FILE.read_bytes()
54
+ salt = data[:SALT_SIZE]
55
+ encrypted = data[SALT_SIZE:]
56
+ key = _derive_key(password, salt)
57
+ f = Fernet(key)
58
+ try:
59
+ decrypted = f.decrypt(encrypted)
60
+ except InvalidToken:
61
+ raise ValueError("Invalid master password")
62
+ return json.loads(decrypted), salt
63
+
64
+
65
+ def _save_raw(profiles: dict, password: str, salt: bytes) -> None:
66
+ key = _derive_key(password, salt)
67
+ f = Fernet(key)
68
+ encrypted = f.encrypt(json.dumps(profiles).encode())
69
+ VAULT_FILE.write_bytes(salt + encrypted)
70
+ VAULT_FILE.chmod(0o600)
71
+
72
+
73
+ def load_profiles(password: str) -> dict[str, ServerProfile]:
74
+ raw, _ = _load_raw(password)
75
+ return {alias: ServerProfile.from_dict(data) for alias, data in raw.items()}
76
+
77
+
78
+ def save_profile(password: str, profile: ServerProfile) -> None:
79
+ raw, salt = _load_raw(password)
80
+ raw[profile.alias] = profile.to_dict()
81
+ _save_raw(raw, password, salt)
82
+
83
+
84
+ def remove_profile(password: str, alias: str) -> bool:
85
+ raw, salt = _load_raw(password)
86
+ if alias not in raw:
87
+ return False
88
+ del raw[alias]
89
+ _save_raw(raw, password, salt)
90
+ return True
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: fling-remote
3
+ Version: 0.1.0
4
+ Summary: Lightweight SSH orchestration CLI with encrypted credential vault
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: asyncssh>=2.17
7
+ Requires-Dist: cryptography>=42.0
8
+ Requires-Dist: typer>=0.12
9
+ Requires-Dist: rich>=13.0
10
+ Requires-Dist: python-dotenv>=1.0
@@ -0,0 +1,13 @@
1
+ pyproject.toml
2
+ fling/__init__.py
3
+ fling/cli.py
4
+ fling/models.py
5
+ fling/setup.py
6
+ fling/ssh.py
7
+ fling/vault.py
8
+ fling_remote.egg-info/PKG-INFO
9
+ fling_remote.egg-info/SOURCES.txt
10
+ fling_remote.egg-info/dependency_links.txt
11
+ fling_remote.egg-info/entry_points.txt
12
+ fling_remote.egg-info/requires.txt
13
+ fling_remote.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fling = fling.cli:app
@@ -0,0 +1,5 @@
1
+ asyncssh>=2.17
2
+ cryptography>=42.0
3
+ typer>=0.12
4
+ rich>=13.0
5
+ python-dotenv>=1.0
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "fling-remote"
7
+ version = "0.1.0"
8
+ description = "Lightweight SSH orchestration CLI with encrypted credential vault"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "asyncssh>=2.17",
12
+ "cryptography>=42.0",
13
+ "typer>=0.12",
14
+ "rich>=13.0",
15
+ "python-dotenv>=1.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ fling = "fling.cli:app"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+