cloudx-proxy 0.1.1__tar.gz → 0.3.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.
Files changed (24) hide show
  1. cloudx_proxy-0.3.0/CHANGELOG.md +46 -0
  2. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/PKG-INFO +1 -1
  3. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/cloudx_proxy/_version.py +2 -2
  4. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/cloudx_proxy/cli.py +10 -8
  5. cloudx_proxy-0.3.0/cloudx_proxy/setup.py +524 -0
  6. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/cloudx_proxy.egg-info/PKG-INFO +1 -1
  7. cloudx_proxy-0.1.1/CHANGELOG.md +0 -21
  8. cloudx_proxy-0.1.1/cloudx_proxy/setup.py +0 -336
  9. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/.github/workflows/release.yml +0 -0
  10. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/.gitignore +0 -0
  11. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/.releaserc +0 -0
  12. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/CONTRIBUTING.md +0 -0
  13. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/LICENSE +0 -0
  14. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/README.md +0 -0
  15. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/cloudx_proxy/__init__.py +0 -0
  16. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/cloudx_proxy/core.py +0 -0
  17. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/cloudx_proxy.egg-info/SOURCES.txt +0 -0
  18. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/cloudx_proxy.egg-info/dependency_links.txt +0 -0
  19. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/cloudx_proxy.egg-info/entry_points.txt +0 -0
  20. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/cloudx_proxy.egg-info/requires.txt +0 -0
  21. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/cloudx_proxy.egg-info/top_level.txt +0 -0
  22. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/package.json +0 -0
  23. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/pyproject.toml +0 -0
  24. {cloudx_proxy-0.1.1 → cloudx_proxy-0.3.0}/setup.cfg +0 -0
@@ -0,0 +1,46 @@
1
+ # [0.3.0](https://github.com/easytocloud/cloudX-proxy/compare/v0.2.0...v0.3.0) (2025-02-09)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * improve SSH key message and config header ([94dd9b6](https://github.com/easytocloud/cloudX-proxy/commit/94dd9b6bd42b2b23e2e732470adf0096aa98e0fb))
7
+ * improve UI formatting and progress tracking ([570a0de](https://github.com/easytocloud/cloudX-proxy/commit/570a0deab309f42ee8c961062596189f8d5d6a91))
8
+ * only include non-default parameters in ProxyCommand ([e1ecae9](https://github.com/easytocloud/cloudX-proxy/commit/e1ecae9fd91ae1bbed92d60ed384a0e405269a35))
9
+ * simplify setup UI and improve error handling ([613cba3](https://github.com/easytocloud/cloudX-proxy/commit/613cba3596c5631d7125c814f0f829c7171ff529))
10
+ * update branding to cloudx-proxy ([b354d84](https://github.com/easytocloud/cloudX-proxy/commit/b354d84d99005d11f51212ce70d40c0d36ea47dd))
11
+
12
+
13
+ ### Features
14
+
15
+ * add status indicators to instance setup check ([dfb3624](https://github.com/easytocloud/cloudX-proxy/commit/dfb36240583b46a54742306f9eae24e592d65fbe))
16
+ * enhance setup UI with progress bar, colors, and summary ([f72efd1](https://github.com/easytocloud/cloudX-proxy/commit/f72efd175c7805cfd41605f33d5056e714911972))
17
+ * extract default env from IAM user and improve SSH config handling ([25fa9c9](https://github.com/easytocloud/cloudX-proxy/commit/25fa9c976d4ae992e5217680405cd407e613eac3))
18
+
19
+ # [0.2.0](https://github.com/easytocloud/cloudX-proxy/compare/v0.1.1...v0.2.0) (2025-02-09)
20
+
21
+
22
+ ### Features
23
+
24
+ * add setup checklist and make all steps optional ([46016b8](https://github.com/easytocloud/cloudX-proxy/commit/46016b8fd7f1a1ae42fb34a7ff35365279883ab0))
25
+
26
+ ## [0.1.1](https://github.com/easytocloud/cloudX-proxy/compare/v0.1.0...v0.1.1) (2025-02-09)
27
+
28
+ # Changelog
29
+
30
+ All notable changes to this project will be documented in this file.
31
+
32
+ ## [0.1.0](https://github.com/easytocloud/cloudX-proxy/releases/tag/v0.1.0) (2025-02-09)
33
+
34
+ Initial release with core functionality:
35
+
36
+ ### Features
37
+
38
+ * SSH proxy command for connecting VSCode to EC2 instances via SSM
39
+ * AWS profile configuration with cloudX-{env}-{user} format
40
+ * SSH key management with 1Password integration
41
+ * Environment-specific SSH config generation
42
+ * Instance setup status verification
43
+ * Cross-platform support (Windows, macOS, Linux)
44
+ * Automatic instance startup if stopped
45
+ * SSH key distribution via EC2 Instance Connect
46
+ * SSH tunneling through AWS Systems Manager
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: cloudx-proxy
3
- Version: 0.1.1
3
+ Version: 0.3.0
4
4
  Summary: SSH proxy command to connect VSCode with Cloud9/CloudX instance using AWS Systems Manager
5
5
  Author-email: easytocloud <info@easytocloud.com>
6
6
  License: MIT License
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.1.1'
16
- __version_tuple__ = version_tuple = (0, 1, 1)
15
+ __version__ = version = '0.3.0'
16
+ __version_tuple__ = version_tuple = (0, 3, 0)
@@ -16,9 +16,9 @@ def cli():
16
16
  @click.argument('port', type=int, default=22)
17
17
  @click.option('--profile', default='vscode', help='AWS profile to use (default: vscode)')
18
18
  @click.option('--region', help='AWS region (default: from profile, or eu-west-1 if not set)')
19
- @click.option('--key-path', help='Path to SSH public key (default: ~/.ssh/vscode/vscode.pub)')
19
+ @click.option('--ssh-key', default='vscode', help='SSH key name to use (default: vscode)')
20
20
  @click.option('--aws-env', help='AWS environment directory (default: ~/.aws, use name of directory in ~/.aws/aws-envs/)')
21
- def connect(instance_id: str, port: int, profile: str, region: str, key_path: str, aws_env: str):
21
+ def connect(instance_id: str, port: int, profile: str, region: str, ssh_key: str, aws_env: str):
22
22
  """Connect to an EC2 instance via SSM.
23
23
 
24
24
  INSTANCE_ID is the EC2 instance ID to connect to (e.g., i-0123456789abcdef0)
@@ -34,7 +34,7 @@ def connect(instance_id: str, port: int, profile: str, region: str, key_path: st
34
34
  port=port,
35
35
  profile=profile,
36
36
  region=region,
37
- public_key_path=key_path,
37
+ ssh_key=ssh_key,
38
38
  aws_env=aws_env
39
39
  )
40
40
 
@@ -65,6 +65,8 @@ def setup(profile: str, ssh_key: str, aws_env: str):
65
65
  try:
66
66
  setup = CloudXSetup(profile=profile, ssh_key=ssh_key, aws_env=aws_env)
67
67
 
68
+ print("\n\033[1;95m=== cloudx-proxy Setup ===\033[0m\n")
69
+
68
70
  # Set up AWS profile
69
71
  if not setup.setup_aws_profile():
70
72
  sys.exit(1)
@@ -73,10 +75,10 @@ def setup(profile: str, ssh_key: str, aws_env: str):
73
75
  if not setup.setup_ssh_key():
74
76
  sys.exit(1)
75
77
 
76
- # Get CloudX environment and instance details
77
- cloudx_env = click.prompt("Enter CloudX environment (e.g., dev, prod)", type=str)
78
- instance_id = click.prompt("Enter EC2 instance ID (e.g., i-0123456789abcdef0)", type=str)
79
- hostname = click.prompt("Enter hostname for the instance", type=str)
78
+ # Get environment and instance details
79
+ cloudx_env = setup.prompt("Enter environment", getattr(setup, 'default_env', None))
80
+ instance_id = setup.prompt("Enter EC2 instance ID (e.g., i-0123456789abcdef0)")
81
+ hostname = setup.prompt("Enter hostname for the instance")
80
82
 
81
83
  # Set up SSH config
82
84
  if not setup.setup_ssh_config(cloudx_env, instance_id, hostname):
@@ -86,7 +88,7 @@ def setup(profile: str, ssh_key: str, aws_env: str):
86
88
  setup.wait_for_setup_completion(instance_id)
87
89
 
88
90
  except Exception as e:
89
- print(f"Error: {str(e)}", file=sys.stderr)
91
+ print(f"\n\033[91mError: {str(e)}\033[0m", file=sys.stderr)
90
92
  sys.exit(1)
91
93
 
92
94
  if __name__ == '__main__':
@@ -0,0 +1,524 @@
1
+ import os
2
+ import time
3
+ import json
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Optional, Tuple
7
+ import boto3
8
+ from botocore.exceptions import ClientError
9
+
10
+ class CloudXSetup:
11
+ def __init__(self, profile: str = "vscode", ssh_key: str = "vscode", aws_env: str = None):
12
+ """Initialize cloudx-proxy setup.
13
+
14
+ Args:
15
+ profile: AWS profile name (default: "vscode")
16
+ ssh_key: SSH key name (default: "vscode")
17
+ aws_env: AWS environment directory (default: None)
18
+ """
19
+ self.profile = profile
20
+ self.ssh_key = ssh_key
21
+ self.aws_env = aws_env
22
+ self.home_dir = str(Path.home())
23
+ self.ssh_dir = Path(self.home_dir) / ".ssh" / "vscode"
24
+ self.ssh_config_file = self.ssh_dir / "config"
25
+ self.ssh_key_file = self.ssh_dir / f"{ssh_key}"
26
+ self.using_1password = False
27
+ self.default_env = None
28
+
29
+ def print_header(self, text: str) -> None:
30
+ """Print a section header.
31
+
32
+ Args:
33
+ text: The header text
34
+ """
35
+ print(f"\n\n\033[1;94m=== {text} ===\033[0m")
36
+
37
+ def print_status(self, message: str, status: bool = None, indent: int = 0) -> None:
38
+ """Print a status message with optional checkmark/cross.
39
+
40
+ Args:
41
+ message: The message to print
42
+ status: True for success (✓), False for failure (✗), None for no symbol
43
+ indent: Number of spaces to indent
44
+ """
45
+ prefix = " " * indent
46
+ if status is not None:
47
+ symbol = "✓" if status else "✗"
48
+ color = "\033[92m" if status else "\033[91m" # Green for success, red for failure
49
+ reset = "\033[0m"
50
+ print(f"{prefix}{color}{symbol}{reset} {message}")
51
+ else:
52
+ print(f"{prefix}○ {message}")
53
+
54
+ def prompt(self, message: str, default: str = None) -> str:
55
+ """Display a colored prompt for user input.
56
+
57
+ Args:
58
+ message: The prompt message
59
+ default: Default value (shown in brackets)
60
+
61
+ Returns:
62
+ str: User's input or default value
63
+ """
64
+ if default:
65
+ prompt_text = f"\033[93m{message} [{default}]: \033[0m"
66
+ else:
67
+ prompt_text = f"\033[93m{message}: \033[0m"
68
+ response = input(prompt_text)
69
+ return response if response else default
70
+
71
+ def setup_aws_profile(self) -> bool:
72
+ """Set up AWS profile using aws configure command.
73
+
74
+ Returns:
75
+ bool: True if profile was set up successfully or user chose to continue
76
+ """
77
+ self.print_status("Checking AWS profile configuration...")
78
+
79
+ try:
80
+ # Configure AWS environment if specified
81
+ if self.aws_env:
82
+ aws_env_dir = os.path.expanduser(f"~/.aws/aws-envs/{self.aws_env}")
83
+ os.environ["AWS_CONFIG_FILE"] = os.path.join(aws_env_dir, "config")
84
+ os.environ["AWS_SHARED_CREDENTIALS_FILE"] = os.path.join(aws_env_dir, "credentials")
85
+
86
+ # Try to create session with profile
87
+ try:
88
+ session = boto3.Session(profile_name=self.profile)
89
+ except:
90
+ # Profile doesn't exist, create it
91
+ self.print_status(f"AWS profile '{self.profile}' not found", False, 2)
92
+ self.print_status("Setting up AWS profile...", None, 2)
93
+ print("\033[96mPlease enter your AWS credentials:\033[0m")
94
+
95
+ # Use aws configure command
96
+ subprocess.run([
97
+ 'aws', 'configure',
98
+ '--profile', self.profile
99
+ ], check=True)
100
+
101
+ # Create new session with configured profile
102
+ session = boto3.Session(profile_name=self.profile)
103
+
104
+ # Verify the profile works
105
+ try:
106
+ identity = session.client('sts').get_caller_identity()
107
+ user_arn = identity['Arn']
108
+
109
+ # Extract environment from IAM user name
110
+ user_parts = [part for part in user_arn.split('/') if part.startswith('cloudX-')]
111
+ if user_parts:
112
+ self.default_env = user_parts[0].split('-')[1] # Extract env from cloudX-{env}-{user}
113
+ self.print_status(f"AWS profile '{self.profile}' exists and matches cloudX format", True, 2)
114
+ return True
115
+ else:
116
+ self.print_status(f"AWS profile exists but doesn't match cloudX-{{env}}-{{user}} format", False, 2)
117
+ self.print_status("Please ensure your IAM user follows the format: cloudX-{env}-{username}", None, 2)
118
+ return False
119
+ except ClientError:
120
+ self.print_status("Invalid AWS credentials", False, 2)
121
+ return False
122
+
123
+ except Exception as e:
124
+ self.print_status(f"\033[1;91mError:\033[0m {str(e)}", False, 2)
125
+ return False
126
+
127
+ def setup_ssh_key(self) -> bool:
128
+ """Set up SSH key pair.
129
+
130
+ Returns:
131
+ bool: True if key was set up successfully
132
+ """
133
+ self.print_header("SSH Key Configuration")
134
+ self.print_status(f"Checking SSH key '{self.ssh_key}' configuration...")
135
+
136
+ try:
137
+ # Create .ssh/vscode directory if it doesn't exist
138
+ self.ssh_dir.mkdir(parents=True, exist_ok=True)
139
+ self.print_status("SSH directory exists", True, 2)
140
+
141
+ key_exists = self.ssh_key_file.exists() and (self.ssh_key_file.with_suffix('.pub')).exists()
142
+
143
+ if key_exists:
144
+ self.print_status(f"SSH key '{self.ssh_key}' exists", True, 2)
145
+ self.using_1password = self.prompt("Would you like to use 1Password SSH agent?", "N").lower() == 'y'
146
+ if self.using_1password:
147
+ self.print_status("Using 1Password SSH agent", True, 2)
148
+ else:
149
+ store_in_1password = self.prompt("Would you like to store the private key in 1Password?", "N").lower() == 'y'
150
+ if store_in_1password:
151
+ if self._store_key_in_1password():
152
+ self.print_status("Private key stored in 1Password", True, 2)
153
+ else:
154
+ self.print_status("Failed to store private key in 1Password", False, 2)
155
+ else:
156
+ self.print_status(f"Generating new SSH key '{self.ssh_key}'...", None, 2)
157
+ subprocess.run([
158
+ 'ssh-keygen',
159
+ '-t', 'ed25519',
160
+ '-f', str(self.ssh_key_file),
161
+ '-N', '' # Empty passphrase
162
+ ], check=True)
163
+ self.print_status("SSH key generated", True, 2)
164
+
165
+ self.using_1password = self.prompt("Would you like to use 1Password SSH agent?", "N").lower() == 'y'
166
+ if self.using_1password:
167
+ self.print_status("Using 1Password SSH agent", True, 2)
168
+ else:
169
+ store_in_1password = self.prompt("Would you like to store the private key in 1Password?", "N").lower() == 'y'
170
+ if store_in_1password:
171
+ if self._store_key_in_1password():
172
+ self.print_status("Private key stored in 1Password", True, 2)
173
+ else:
174
+ self.print_status("Failed to store private key in 1Password", False, 2)
175
+
176
+ return True
177
+
178
+ except Exception as e:
179
+ self.print_status(f"Error: {str(e)}", False, 2)
180
+ continue_setup = self.prompt("Would you like to continue anyway?", "Y").lower() != 'n'
181
+ if continue_setup:
182
+ self.print_status("Continuing setup despite SSH key issues", None, 2)
183
+ return True
184
+ return False
185
+
186
+ def _store_key_in_1password(self) -> bool:
187
+ """Store SSH private key in 1Password.
188
+
189
+ Returns:
190
+ bool: True if key was stored successfully
191
+ """
192
+ try:
193
+ subprocess.run(['op', '--version'], check=True, capture_output=True)
194
+ print("Storing private key in 1Password...")
195
+ subprocess.run([
196
+ 'op', 'document', 'create',
197
+ str(self.ssh_key_file),
198
+ '--title', f'cloudx-proxy SSH Key - {self.ssh_key}'
199
+ ], check=True)
200
+ return True
201
+ except subprocess.CalledProcessError:
202
+ print("Error: 1Password CLI not installed or not signed in.")
203
+ return False
204
+
205
+ def _add_host_entry(self, cloudx_env: str, instance_id: str, hostname: str, current_config: str) -> bool:
206
+ """Add settings to a specific host entry.
207
+
208
+ Args:
209
+ cloudx_env: CloudX environment
210
+ instance_id: EC2 instance ID
211
+ hostname: Hostname for the instance
212
+ current_config: Current SSH config content
213
+
214
+ Returns:
215
+ bool: True if settings were added successfully
216
+ """
217
+ try:
218
+ # Build host entry with all settings
219
+ proxy_command = "uvx cloudx-proxy connect %h %p"
220
+ if self.profile != "vscode":
221
+ proxy_command += f" --profile {self.profile}"
222
+ if self.aws_env:
223
+ proxy_command += f" --aws-env {self.aws_env}"
224
+ if self.ssh_key != "vscode":
225
+ proxy_command += f" --ssh-key {self.ssh_key}"
226
+
227
+ host_entry = f"""
228
+ Host cloudx-{cloudx_env}-{hostname}
229
+ HostName {instance_id}
230
+ User ec2-user
231
+ """
232
+ if self.using_1password:
233
+ host_entry += f""" IdentityAgent ~/.1password/agent.sock
234
+ IdentityFile {self.ssh_key_file}.pub
235
+ IdentitiesOnly yes
236
+ """
237
+ else:
238
+ host_entry += f""" IdentityFile {self.ssh_key_file}
239
+ """
240
+ host_entry += f""" ProxyCommand {proxy_command}
241
+ """
242
+
243
+ # Append host entry
244
+ with open(self.ssh_config_file, 'a') as f:
245
+ f.write(host_entry)
246
+ self.print_status("Host entry added with settings", True, 2)
247
+ return True
248
+
249
+ except Exception as e:
250
+ self.print_status(f"\033[1;91mError:\033[0m {str(e)}", False, 2)
251
+ continue_setup = self.prompt("Would you like to continue anyway?", "Y").lower() != 'n'
252
+ if continue_setup:
253
+ self.print_status("Continuing setup despite SSH config issues", None, 2)
254
+ return True
255
+ return False
256
+
257
+ def setup_ssh_config(self, cloudx_env: str, instance_id: str, hostname: str) -> bool:
258
+ """Set up SSH config for the instance.
259
+
260
+ This method manages the SSH configuration in ~/.ssh/vscode/config, with the following behavior:
261
+ 1. For a new environment (if cloudx-{env}-* doesn't exist):
262
+ Creates a base config with:
263
+ - User and key configuration
264
+ - 1Password SSH agent integration if selected
265
+ - ProxyCommand using uvx cloudx-proxy with proper parameters
266
+
267
+ 2. For an existing environment:
268
+ - Skips creating duplicate environment config
269
+ - Only adds the new host entry
270
+
271
+ Example config structure:
272
+ ```
273
+ # Base environment config (created only once per environment)
274
+ Host cloudx-{env}-*
275
+ User ec2-user
276
+ IdentityAgent ~/.1password/agent.sock # If using 1Password
277
+ IdentityFile ~/.ssh/vscode/key.pub # .pub for 1Password, no .pub otherwise
278
+ IdentitiesOnly yes # If using 1Password
279
+ ProxyCommand uvx cloudx-proxy connect %h %p --profile profile --aws-env env
280
+
281
+ # Host entries (added for each instance)
282
+ Host cloudx-{env}-hostname
283
+ HostName i-1234567890
284
+ ```
285
+
286
+ Args:
287
+ cloudx_env: CloudX environment (e.g., dev, prod)
288
+ instance_id: EC2 instance ID
289
+ hostname: Hostname for the instance
290
+
291
+ Returns:
292
+ bool: True if config was set up successfully
293
+ """
294
+ self.print_header("SSH Configuration")
295
+ self.print_status("Setting up SSH configuration...")
296
+
297
+ try:
298
+ # Check existing configuration
299
+ if self.ssh_config_file.exists():
300
+ current_config = self.ssh_config_file.read_text()
301
+ # Check if configuration for this environment already exists
302
+ if f"Host cloudx-{cloudx_env}-*" in current_config:
303
+ self.print_status(f"Found existing config for cloudx-{cloudx_env}-*", True, 2)
304
+ choice = self.prompt(
305
+ "Would you like to (1) override the existing config or "
306
+ "(2) add settings to the specific host entry?",
307
+ "1"
308
+ )
309
+ if choice == "2":
310
+ # Add settings to specific host entry
311
+ self.print_status("Adding settings to specific host entry", None, 2)
312
+ return self._add_host_entry(cloudx_env, instance_id, hostname, current_config)
313
+ else:
314
+ # Remove existing config for this environment
315
+ self.print_status("Removing existing configuration", None, 2)
316
+ lines = current_config.splitlines()
317
+ new_lines = []
318
+ skip = False
319
+ for line in lines:
320
+ if line.strip() == f"Host cloudx-{cloudx_env}-*":
321
+ skip = True
322
+ elif skip and line.startswith("Host "):
323
+ skip = False
324
+ if not skip:
325
+ new_lines.append(line)
326
+ current_config = "\n".join(new_lines)
327
+ with open(self.ssh_config_file, 'w') as f:
328
+ f.write(current_config)
329
+
330
+ # Create base config
331
+ self.print_status(f"Creating new config for cloudx-{cloudx_env}-*", None, 2)
332
+ # Build ProxyCommand with only non-default parameters
333
+ proxy_command = "uvx cloudx-proxy connect %h %p"
334
+ if self.profile != "vscode":
335
+ proxy_command += f" --profile {self.profile}"
336
+ if self.aws_env:
337
+ proxy_command += f" --aws-env {self.aws_env}"
338
+ if self.ssh_key != "vscode":
339
+ proxy_command += f" --ssh-key {self.ssh_key}"
340
+
341
+ # Build base configuration
342
+ base_config = f"""# cloudx-proxy SSH Configuration
343
+ Host cloudx-{cloudx_env}-*
344
+ User ec2-user
345
+ """
346
+ # Add 1Password or standard key configuration
347
+ if self.using_1password:
348
+ base_config += f""" IdentityAgent ~/.1password/agent.sock
349
+ IdentityFile {self.ssh_key_file}.pub
350
+ IdentitiesOnly yes
351
+ """
352
+ else:
353
+ base_config += f""" IdentityFile {self.ssh_key_file}
354
+ """
355
+ # Add ProxyCommand
356
+ base_config += f""" ProxyCommand {proxy_command}
357
+ """
358
+
359
+ # If file exists, append the new config, otherwise create it
360
+ if self.ssh_config_file.exists():
361
+ with open(self.ssh_config_file, 'a') as f:
362
+ f.write("\n" + base_config)
363
+ else:
364
+ self.ssh_config_file.write_text(base_config)
365
+ self.print_status("Base configuration created", True, 2)
366
+
367
+ # Add specific host entry
368
+ self.print_status(f"Adding host entry for cloudx-{cloudx_env}-{hostname}", None, 2)
369
+ host_entry = f"""
370
+ Host cloudx-{cloudx_env}-{hostname}
371
+ HostName {instance_id}
372
+ """
373
+ with open(self.ssh_config_file, 'a') as f:
374
+ f.write(host_entry)
375
+ self.print_status("Host entry added", True, 2)
376
+
377
+ # Ensure main SSH config includes our config
378
+ main_config = Path(self.home_dir) / ".ssh" / "config"
379
+ include_line = f"Include {self.ssh_config_file}\n"
380
+
381
+ if main_config.exists():
382
+ content = main_config.read_text()
383
+ if include_line not in content:
384
+ with open(main_config, 'a') as f:
385
+ f.write(f"\n{include_line}")
386
+ self.print_status("Added include line to main SSH config", True, 2)
387
+ else:
388
+ self.print_status("Main SSH config already includes our config", True, 2)
389
+ else:
390
+ main_config.write_text(include_line)
391
+ self.print_status("Created main SSH config with include line", True, 2)
392
+
393
+ self.print_status("\nSSH configuration summary:", None)
394
+ self.print_status(f"Main config: {main_config}", None, 2)
395
+ self.print_status(f"cloudx-proxy config: {self.ssh_config_file}", None, 2)
396
+ self.print_status(f"Connect using: ssh cloudx-{cloudx_env}-{hostname}", None, 2)
397
+
398
+ return True
399
+
400
+ except Exception as e:
401
+ self.print_status(f"\033[1;91mError:\033[0m {str(e)}", False, 2)
402
+ continue_setup = self.prompt("Would you like to continue anyway?", "Y").lower() != 'n'
403
+ if continue_setup:
404
+ self.print_status("Continuing setup despite SSH config issues", None, 2)
405
+ return True
406
+ return False
407
+
408
+ def check_instance_setup(self, instance_id: str) -> Tuple[bool, bool]:
409
+ """Check if instance setup is complete.
410
+
411
+ Args:
412
+ instance_id: EC2 instance ID
413
+
414
+ Returns:
415
+ Tuple[bool, bool]: (is_running, is_setup_complete)
416
+ """
417
+ try:
418
+ session = boto3.Session(profile_name=self.profile)
419
+ ssm = session.client('ssm')
420
+
421
+ # Check if instance is online in SSM
422
+ self.print_status("Checking instance status in SSM...", None, 4)
423
+ response = ssm.describe_instance_information(
424
+ Filters=[{'Key': 'InstanceIds', 'Values': [instance_id]}]
425
+ )
426
+ is_running = bool(response['InstanceInformationList'])
427
+
428
+ if not is_running:
429
+ self.print_status("Instance is not accessible via SSM", False, 4)
430
+ return False, False
431
+
432
+ self.print_status("Instance is accessible via SSM", True, 4)
433
+
434
+ # Check setup status using SSM command
435
+ self.print_status("Checking setup status...", None, 4)
436
+ response = ssm.send_command(
437
+ InstanceIds=[instance_id],
438
+ DocumentName='AWS-RunShellScript',
439
+ Parameters={
440
+ 'commands': [
441
+ 'test -f /home/ec2-user/.install-done && echo "DONE" || '
442
+ 'test -f /home/ec2-user/.install-running && echo "RUNNING" || '
443
+ 'echo "NOT_STARTED"'
444
+ ]
445
+ }
446
+ )
447
+
448
+ command_id = response['Command']['CommandId']
449
+
450
+ # Wait for command completion
451
+ for _ in range(10): # 10 second timeout
452
+ time.sleep(1)
453
+ result = ssm.get_command_invocation(
454
+ CommandId=command_id,
455
+ InstanceId=instance_id
456
+ )
457
+ if result['Status'] in ['Success', 'Failed']:
458
+ break
459
+
460
+ is_setup_complete = result['Status'] == 'Success' and result['StandardOutputContent'].strip() == 'DONE'
461
+
462
+ if is_setup_complete:
463
+ self.print_status("Setup is complete", True, 4)
464
+ else:
465
+ status = result['StandardOutputContent'].strip()
466
+ self.print_status(f"Setup status: {status}", None, 4)
467
+
468
+ return True, is_setup_complete
469
+
470
+ except Exception as e:
471
+ self.print_status(f"\033[1;91mError:\033[0m {str(e)}", False, 4)
472
+ return False, False
473
+
474
+ def wait_for_setup_completion(self, instance_id: str) -> bool:
475
+ """Wait for instance setup to complete.
476
+
477
+ Args:
478
+ instance_id: EC2 instance ID
479
+
480
+ Returns:
481
+ bool: True if setup completed successfully
482
+ """
483
+ self.print_header("Instance Setup Check")
484
+ self.print_status(f"Checking instance {instance_id} setup status...")
485
+
486
+ is_running, is_complete = self.check_instance_setup(instance_id)
487
+
488
+ if not is_running:
489
+ self.print_status("Instance is not running or not accessible via SSM", False, 2)
490
+ continue_setup = self.prompt("Would you like to continue anyway?", "Y").lower() != 'n'
491
+ if continue_setup:
492
+ self.print_status("Continuing setup despite instance access issues", None, 2)
493
+ return True
494
+ return False
495
+
496
+ if is_complete:
497
+ self.print_status("Instance setup is complete", True, 2)
498
+ return True
499
+
500
+ wait = self.prompt("Instance setup is not complete. Would you like to wait?", "Y").lower() != 'n'
501
+ if not wait:
502
+ self.print_status("Skipping instance setup check", None, 2)
503
+ return True
504
+
505
+ self.print_status("Waiting for setup to complete...", None, 2)
506
+ dots = 0
507
+ while True:
508
+ is_running, is_complete = self.check_instance_setup(instance_id)
509
+
510
+ if not is_running:
511
+ self.print_status("Instance is no longer running or accessible", False, 2)
512
+ continue_setup = self.prompt("Would you like to continue anyway?", "Y").lower() != 'n'
513
+ if continue_setup:
514
+ self.print_status("Continuing setup despite instance issues", None, 2)
515
+ return True
516
+ return False
517
+
518
+ if is_complete:
519
+ self.print_status("Instance setup completed successfully", True, 2)
520
+ return True
521
+
522
+ dots = (dots + 1) % 4
523
+ print(f"\r {'.' * dots}{' ' * (3 - dots)}", end='', flush=True)
524
+ time.sleep(10)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: cloudx-proxy
3
- Version: 0.1.1
3
+ Version: 0.3.0
4
4
  Summary: SSH proxy command to connect VSCode with Cloud9/CloudX instance using AWS Systems Manager
5
5
  Author-email: easytocloud <info@easytocloud.com>
6
6
  License: MIT License
@@ -1,21 +0,0 @@
1
- ## [0.1.1](https://github.com/easytocloud/cloudX-proxy/compare/v0.1.0...v0.1.1) (2025-02-09)
2
-
3
- # Changelog
4
-
5
- All notable changes to this project will be documented in this file.
6
-
7
- ## [0.1.0](https://github.com/easytocloud/cloudX-proxy/releases/tag/v0.1.0) (2025-02-09)
8
-
9
- Initial release with core functionality:
10
-
11
- ### Features
12
-
13
- * SSH proxy command for connecting VSCode to EC2 instances via SSM
14
- * AWS profile configuration with cloudX-{env}-{user} format
15
- * SSH key management with 1Password integration
16
- * Environment-specific SSH config generation
17
- * Instance setup status verification
18
- * Cross-platform support (Windows, macOS, Linux)
19
- * Automatic instance startup if stopped
20
- * SSH key distribution via EC2 Instance Connect
21
- * SSH tunneling through AWS Systems Manager
@@ -1,336 +0,0 @@
1
- import os
2
- import time
3
- import json
4
- import subprocess
5
- from pathlib import Path
6
- from typing import Optional, Tuple
7
- import boto3
8
- from botocore.exceptions import ClientError
9
-
10
- class CloudXSetup:
11
- def __init__(self, profile: str = "vscode", ssh_key: str = "vscode", aws_env: str = None):
12
- """Initialize CloudX setup.
13
-
14
- Args:
15
- profile: AWS profile name (default: "vscode")
16
- ssh_key: SSH key name (default: "vscode")
17
- aws_env: AWS environment directory (default: None)
18
- """
19
- self.profile = profile
20
- self.ssh_key = ssh_key
21
- self.aws_env = aws_env
22
- self.home_dir = str(Path.home())
23
- self.ssh_dir = Path(self.home_dir) / ".ssh" / "vscode"
24
- self.ssh_config_file = self.ssh_dir / "config"
25
- self.ssh_key_file = self.ssh_dir / f"{ssh_key}"
26
- self.using_1password = False
27
-
28
- def setup_aws_profile(self) -> bool:
29
- """Set up AWS profile using aws configure command.
30
-
31
- Returns:
32
- bool: True if profile was set up successfully
33
- """
34
- try:
35
- # Configure AWS environment if specified
36
- if self.aws_env:
37
- aws_env_dir = os.path.expanduser(f"~/.aws/aws-envs/{self.aws_env}")
38
- os.environ["AWS_CONFIG_FILE"] = os.path.join(aws_env_dir, "config")
39
- os.environ["AWS_SHARED_CREDENTIALS_FILE"] = os.path.join(aws_env_dir, "credentials")
40
-
41
- # Check if profile exists
42
- session = boto3.Session(profile_name=self.profile)
43
- try:
44
- session.client('sts').get_caller_identity()
45
- print(f"AWS profile '{self.profile}' already exists and is valid.")
46
- return True
47
- except ClientError:
48
- pass
49
-
50
- # Profile doesn't exist or is invalid, set it up
51
- print(f"Setting up AWS profile '{self.profile}'...")
52
- print("Please enter your AWS credentials:")
53
-
54
- # Use aws configure command
55
- subprocess.run([
56
- 'aws', 'configure',
57
- '--profile', self.profile
58
- ], check=True)
59
-
60
- # Verify the profile works
61
- session = boto3.Session(profile_name=self.profile)
62
- identity = session.client('sts').get_caller_identity()
63
- user_arn = identity['Arn']
64
-
65
- if not any(part.startswith('cloudX-') for part in user_arn.split('/')):
66
- print(f"Warning: User ARN '{user_arn}' does not match expected format cloudX-{{env}}-{{user}}")
67
-
68
- return True
69
-
70
- except Exception as e:
71
- print(f"Error setting up AWS profile: {e}")
72
- return False
73
-
74
- def setup_ssh_key(self) -> bool:
75
- """Set up SSH key pair.
76
-
77
- Returns:
78
- bool: True if key was set up successfully
79
- """
80
- try:
81
- # Create .ssh/vscode directory if it doesn't exist
82
- self.ssh_dir.mkdir(parents=True, exist_ok=True)
83
-
84
- key_exists = self.ssh_key_file.exists() and (self.ssh_key_file.with_suffix('.pub')).exists()
85
-
86
- if key_exists:
87
- print(f"SSH key '{self.ssh_key}' already exists.")
88
- self.using_1password = input("Would you like to use 1Password SSH agent? (y/N): ").lower() == 'y'
89
- if not self.using_1password:
90
- store_in_1password = input("Would you like to store the private key in 1Password? (y/N): ").lower() == 'y'
91
- if store_in_1password:
92
- self._store_key_in_1password()
93
- else:
94
- print(f"Generating new SSH key '{self.ssh_key}'...")
95
- subprocess.run([
96
- 'ssh-keygen',
97
- '-t', 'ed25519',
98
- '-f', str(self.ssh_key_file),
99
- '-N', '' # Empty passphrase
100
- ], check=True)
101
-
102
- self.using_1password = input("Would you like to use 1Password SSH agent? (y/N): ").lower() == 'y'
103
- if not self.using_1password:
104
- store_in_1password = input("Would you like to store the private key in 1Password? (y/N): ").lower() == 'y'
105
- if store_in_1password:
106
- self._store_key_in_1password()
107
-
108
- return True
109
-
110
- except Exception as e:
111
- print(f"Error setting up SSH key: {e}")
112
- return False
113
-
114
- def _store_key_in_1password(self) -> bool:
115
- """Store SSH private key in 1Password.
116
-
117
- Returns:
118
- bool: True if key was stored successfully
119
- """
120
- try:
121
- subprocess.run(['op', '--version'], check=True, capture_output=True)
122
- print("Storing private key in 1Password...")
123
- subprocess.run([
124
- 'op', 'document', 'create',
125
- str(self.ssh_key_file),
126
- '--title', f'CloudX SSH Key - {self.ssh_key}'
127
- ], check=True)
128
- return True
129
- except subprocess.CalledProcessError:
130
- print("Error: 1Password CLI not installed or not signed in.")
131
- return False
132
-
133
- def setup_ssh_config(self, cloudx_env: str, instance_id: str, hostname: str) -> bool:
134
- """Set up SSH config for the instance.
135
-
136
- This method manages the SSH configuration in ~/.ssh/vscode/config, with the following behavior:
137
- 1. For a new environment (if cloudx-{env}-* doesn't exist):
138
- Creates a base config with:
139
- - User and key configuration
140
- - 1Password SSH agent integration if selected
141
- - ProxyCommand using uvx cloudx-proxy with proper parameters
142
-
143
- 2. For an existing environment:
144
- - Skips creating duplicate environment config
145
- - Only adds the new host entry
146
-
147
- Example config structure:
148
- ```
149
- # Base environment config (created only once per environment)
150
- Host cloudx-{env}-*
151
- User ec2-user
152
- IdentityAgent ~/.1password/agent.sock # If using 1Password
153
- IdentityFile ~/.ssh/vscode/key.pub # .pub for 1Password, no .pub otherwise
154
- IdentitiesOnly yes # If using 1Password
155
- ProxyCommand uvx cloudx-proxy connect %h %p --profile profile --aws-env env
156
-
157
- # Host entries (added for each instance)
158
- Host cloudx-{env}-hostname
159
- HostName i-1234567890
160
- ```
161
-
162
- Args:
163
- cloudx_env: CloudX environment (e.g., dev, prod)
164
- instance_id: EC2 instance ID
165
- hostname: Hostname for the instance
166
-
167
- Returns:
168
- bool: True if config was set up successfully
169
- """
170
- try:
171
- # Check if we need to create base config
172
- need_base_config = True
173
- if self.ssh_config_file.exists():
174
- current_config = self.ssh_config_file.read_text()
175
- # Check if configuration for this environment already exists
176
- if f"Host cloudx-{cloudx_env}-*" in current_config:
177
- need_base_config = False
178
-
179
- if need_base_config:
180
- # Build ProxyCommand with all necessary parameters
181
- proxy_command = f"uvx cloudx-proxy connect %h %p --profile {self.profile}"
182
- if self.aws_env:
183
- proxy_command += f" --aws-env {self.aws_env}"
184
- if self.ssh_key != "vscode":
185
- proxy_command += f" --key-path {self.ssh_key_file}.pub"
186
-
187
- # Build base configuration
188
- base_config = f"""# CloudX SSH Configuration
189
- Host cloudx-{cloudx_env}-*
190
- User ec2-user
191
- """
192
- # Add 1Password or standard key configuration
193
- if self.using_1password:
194
- base_config += f""" IdentityAgent ~/.1password/agent.sock
195
- IdentityFile {self.ssh_key_file}.pub
196
- IdentitiesOnly yes
197
- """
198
- else:
199
- base_config += f""" IdentityFile {self.ssh_key_file}
200
- """
201
- # Add ProxyCommand
202
- base_config += f""" ProxyCommand {proxy_command}
203
- """
204
-
205
- # If file exists, append the new config, otherwise create it
206
- if self.ssh_config_file.exists():
207
- with open(self.ssh_config_file, 'a') as f:
208
- f.write("\n" + base_config)
209
- else:
210
- self.ssh_config_file.write_text(base_config)
211
-
212
- # Add specific host entry
213
- host_entry = f"""
214
- Host cloudx-{cloudx_env}-{hostname}
215
- HostName {instance_id}
216
- """
217
- with open(self.ssh_config_file, 'a') as f:
218
- f.write(host_entry)
219
-
220
- # Ensure main SSH config includes our config
221
- main_config = Path(self.home_dir) / ".ssh" / "config"
222
- include_line = f"Include {self.ssh_config_file}\n"
223
-
224
- if main_config.exists():
225
- content = main_config.read_text()
226
- if include_line not in content:
227
- with open(main_config, 'a') as f:
228
- f.write(f"\n{include_line}")
229
- else:
230
- main_config.write_text(include_line)
231
-
232
- print(f"\nSSH configuration has been set up:")
233
- print(f"- Main config file: {main_config}")
234
- print(f"- CloudX config file: {self.ssh_config_file}")
235
- print(f"\nYou can now connect using: ssh cloudx-{cloudx_env}-{hostname}")
236
-
237
- return True
238
-
239
- except Exception as e:
240
- print(f"Error setting up SSH config: {e}")
241
- return False
242
-
243
- def check_instance_setup(self, instance_id: str) -> Tuple[bool, bool]:
244
- """Check if instance setup is complete.
245
-
246
- Args:
247
- instance_id: EC2 instance ID
248
-
249
- Returns:
250
- Tuple[bool, bool]: (is_running, is_setup_complete)
251
- """
252
- try:
253
- session = boto3.Session(profile_name=self.profile)
254
- ssm = session.client('ssm')
255
-
256
- # Check if instance is online in SSM
257
- response = ssm.describe_instance_information(
258
- Filters=[{'Key': 'InstanceIds', 'Values': [instance_id]}]
259
- )
260
- is_running = bool(response['InstanceInformationList'])
261
-
262
- if not is_running:
263
- return False, False
264
-
265
- # Check setup status using SSM command
266
- response = ssm.send_command(
267
- InstanceIds=[instance_id],
268
- DocumentName='AWS-RunShellScript',
269
- Parameters={
270
- 'commands': [
271
- 'test -f /home/ec2-user/.install-done && echo "DONE" || '
272
- 'test -f /home/ec2-user/.install-running && echo "RUNNING" || '
273
- 'echo "NOT_STARTED"'
274
- ]
275
- }
276
- )
277
-
278
- command_id = response['Command']['CommandId']
279
-
280
- # Wait for command completion
281
- for _ in range(10): # 10 second timeout
282
- time.sleep(1)
283
- result = ssm.get_command_invocation(
284
- CommandId=command_id,
285
- InstanceId=instance_id
286
- )
287
- if result['Status'] in ['Success', 'Failed']:
288
- break
289
-
290
- is_setup_complete = result['Status'] == 'Success' and result['StandardOutputContent'].strip() == 'DONE'
291
-
292
- return True, is_setup_complete
293
-
294
- except Exception as e:
295
- print(f"Error checking instance setup: {e}")
296
- return False, False
297
-
298
- def wait_for_setup_completion(self, instance_id: str) -> bool:
299
- """Wait for instance setup to complete.
300
-
301
- Args:
302
- instance_id: EC2 instance ID
303
-
304
- Returns:
305
- bool: True if setup completed successfully
306
- """
307
- print(f"Checking setup status for instance {instance_id}...")
308
-
309
- is_running, is_complete = self.check_instance_setup(instance_id)
310
-
311
- if not is_running:
312
- print("Error: Instance is not running or not accessible via SSM")
313
- return False
314
-
315
- if is_complete:
316
- print("Instance setup is already complete")
317
- return True
318
-
319
- wait = input("Instance setup is not complete. Would you like to wait? (Y/n): ").lower() != 'n'
320
- if not wait:
321
- return False
322
-
323
- print("Waiting for setup to complete", end='', flush=True)
324
- while True:
325
- is_running, is_complete = self.check_instance_setup(instance_id)
326
-
327
- if not is_running:
328
- print("\nError: Instance is no longer running or accessible")
329
- return False
330
-
331
- if is_complete:
332
- print("\nInstance setup completed successfully")
333
- return True
334
-
335
- print(".", end='', flush=True)
336
- time.sleep(10)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes