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.
- fling_remote-0.1.0/PKG-INFO +10 -0
- fling_remote-0.1.0/fling/__init__.py +0 -0
- fling_remote-0.1.0/fling/cli.py +247 -0
- fling_remote-0.1.0/fling/models.py +24 -0
- fling_remote-0.1.0/fling/setup.py +246 -0
- fling_remote-0.1.0/fling/ssh.py +249 -0
- fling_remote-0.1.0/fling/vault.py +90 -0
- fling_remote-0.1.0/fling_remote.egg-info/PKG-INFO +10 -0
- fling_remote-0.1.0/fling_remote.egg-info/SOURCES.txt +13 -0
- fling_remote-0.1.0/fling_remote.egg-info/dependency_links.txt +1 -0
- fling_remote-0.1.0/fling_remote.egg-info/entry_points.txt +2 -0
- fling_remote-0.1.0/fling_remote.egg-info/requires.txt +5 -0
- fling_remote-0.1.0/fling_remote.egg-info/top_level.txt +1 -0
- fling_remote-0.1.0/pyproject.toml +19 -0
- fling_remote-0.1.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fling
|
|
@@ -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"
|