Skip to content

Remote Dev Plugin Part 3 - Enable remote ssh connection#2547

Open
sfc-gh-zzhu wants to merge 1 commit into08-16-add_basic_remote_commandsfrom
08-17-enable_remote_ssh_connection
Open

Remote Dev Plugin Part 3 - Enable remote ssh connection#2547
sfc-gh-zzhu wants to merge 1 commit into08-16-add_basic_remote_commandsfrom
08-17-enable_remote_ssh_connection

Conversation

@sfc-gh-zzhu
Copy link

@sfc-gh-zzhu sfc-gh-zzhu commented Aug 17, 2025

Pre-review checklist

  • I've confirmed that instructions included in README.md are still correct after my changes in the codebase.
  • I've added or updated automated unit tests to verify correctness of my new code.
  • I've added or updated integration tests to verify correctness of my new code.
  • I've confirmed that my changes are working by executing CLI's commands manually on MacOS.
  • I've confirmed that my changes are working by executing CLI's commands manually on Windows.
  • I've confirmed that my changes are up-to-date with the target branch.
  • I've described my changes in the release notes.
  • I've described my changes in the section below.

Changes description

This PR adds SSH support to the remote development environment feature, allowing users to securely connect to their remote environments via SSH. New command options to snow remote start includes:

  • --ssh flag to set up SSH configuration for connecting to remote environments
  • --no-ssh-key flag to use token-only authentication when SSH key generation is not desired

Users can now easily connect to their remote environments using standard SSH clients with the command: snow remote start myproject --ssh

Copy link
Author

sfc-gh-zzhu commented Aug 17, 2025

@sfc-gh-zzhu sfc-gh-zzhu changed the title enable remote ssh connection Remote Dev Plugin Part 3 - Enable remote ssh connection Aug 17, 2025
@sfc-gh-zzhu sfc-gh-zzhu marked this pull request as ready for review August 17, 2025 22:07
@sfc-gh-zzhu sfc-gh-zzhu requested review from a team as code owners August 17, 2025 22:07
@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-17-enable_remote_ssh_connection branch from 64cfea0 to 674c01d Compare August 17, 2025 22:54
@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-17-enable_remote_ssh_connection branch from 674c01d to 0bea118 Compare August 19, 2025 17:03
@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-17-enable_remote_ssh_connection branch from 0bea118 to 2cd640f Compare August 19, 2025 18:34
@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-17-enable_remote_ssh_connection branch from 2cd640f to deb2519 Compare August 19, 2025 20:21
Comment on lines +176 to +258
if platform.system() == "Windows":
# On Windows, ssh-keygen already sets appropriate permissions by default.
# We only need to ensure the file isn't world-writable, which is rarely an issue.
# Windows file permissions work differently than Unix, and OpenSSH on Windows
# handles this correctly without additional intervention.
try:
# Just ensure the file isn't read-only if it's a private key we might need to delete later
current_mode = file_path.stat().st_mode
if current_mode & stat.S_IWRITE == 0: # If read-only
file_path.chmod(current_mode | stat.S_IWRITE) # Make writable
except (OSError, AttributeError):
# If this fails, it's not critical - log debug message only
log.debug(
"Could not adjust permissions on %s (this is usually fine on Windows)",
file_path,
)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a way of testing this?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently no - I plan to explore e2e test later which may help the testing.

Comment on lines 326 to 396
# Expected format: wss://hostname.domain
match = re.match(r"wss://([^/]+)", ssh_endpoint_url)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible the endpoint uses a different protocol?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In our implementation it will always be wss

if not websocat_path:
cc.step("Please install websocat manually and run this command again.")
cc.step(install_websocat_instructions())
return

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a fatal error, i.e. should we throw here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change to use warning instead of normal cc.step.

@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-16-add_basic_remote_commands branch from d8a093f to 78b3491 Compare August 22, 2025 23:02
@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-17-enable_remote_ssh_connection branch from deb2519 to 5003b91 Compare August 22, 2025 23:02
@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-17-enable_remote_ssh_connection branch from 5003b91 to 2723e4c Compare August 25, 2025 18:16
@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-16-add_basic_remote_commands branch 2 times, most recently from 1bba2fd to e1581b0 Compare August 25, 2025 22:06
@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-17-enable_remote_ssh_connection branch 3 times, most recently from 2b86917 to 2610027 Compare August 28, 2025 22:32
@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-16-add_basic_remote_commands branch from e1581b0 to 9f4cced Compare September 5, 2025 00:47
@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-17-enable_remote_ssh_connection branch from 2610027 to 6d2eb90 Compare September 5, 2025 00:47
Comment on lines +674 to +672
while time.time() < end_time:
remaining = int(end_time - time.time())
if remaining > 0 and remaining % SSH_COUNTDOWN_INTERVAL == 0:
log.debug(
"⏳ Next refresh in %d seconds... (Press Ctrl+C to stop)", remaining
)
time.sleep(1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KeyboardInterrupt handling bug: The time.sleep(1) call in _wait_with_countdown() will not immediately respond to KeyboardInterrupt. Python's time.sleep() can delay interrupt handling, making the SSH session management unresponsive to Ctrl+C. This should use signal handling or shorter sleep intervals with interrupt checks.

Suggested change
while time.time() < end_time:
remaining = int(end_time - time.time())
if remaining > 0 and remaining % SSH_COUNTDOWN_INTERVAL == 0:
log.debug(
"⏳ Next refresh in %d seconds... (Press Ctrl+C to stop)", remaining
)
time.sleep(1)
while time.time() < end_time:
remaining = int(end_time - time.time())
if remaining > 0 and remaining % SSH_COUNTDOWN_INTERVAL == 0:
log.debug(
"⏳ Next refresh in %d seconds... (Press Ctrl+C to stop)", remaining
)
# Use shorter sleep intervals to be more responsive to keyboard interrupts
try:
time.sleep(0.1)
except KeyboardInterrupt:
raise

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Comment on lines +628 to +659
fresh_connection = None
try:
log.debug("Creating fresh connection for SSH token...")

current_context = get_cli_context().connection_context

# Create connection with natural session expiration for SSH token refresh
fresh_connection = connect_to_snowflake(
connection_name=current_context.connection_name,
temporary_connection=current_context.temporary_connection,
# Allow session to expire naturally - don't keep it alive artificially
using_session_keep_alive=False,
)

fresh_connection.cursor().execute(
"ALTER SESSION SET python_connector_query_result_format = 'JSON'"
)

token = fresh_connection.rest.token
if token:
log.debug("Fresh token obtained successfully")
return token
else:
log.debug("No token available from fresh connection")
return None

except Exception as e:
log.debug("Failed to create fresh connection: %s", str(e))
return None

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The connection created in _get_fresh_token() is never explicitly closed, which could lead to connection pool exhaustion during long-running SSH sessions. While the comment mentions allowing "session to expire naturally," the connection object itself should still be properly closed in a finally block to ensure resource cleanup even when exceptions occur. Consider adding:

try:
    # existing code...
except Exception as e:
    # existing code...
finally:
    if fresh_connection and not fresh_connection.is_closed():
        fresh_connection.close()

This ensures proper connection cleanup regardless of execution path.

Suggested change
fresh_connection = None
try:
log.debug("Creating fresh connection for SSH token...")
current_context = get_cli_context().connection_context
# Create connection with natural session expiration for SSH token refresh
fresh_connection = connect_to_snowflake(
connection_name=current_context.connection_name,
temporary_connection=current_context.temporary_connection,
# Allow session to expire naturally - don't keep it alive artificially
using_session_keep_alive=False,
)
fresh_connection.cursor().execute(
"ALTER SESSION SET python_connector_query_result_format = 'JSON'"
)
token = fresh_connection.rest.token
if token:
log.debug("Fresh token obtained successfully")
return token
else:
log.debug("No token available from fresh connection")
return None
except Exception as e:
log.debug("Failed to create fresh connection: %s", str(e))
return None
fresh_connection = None
try:
log.debug("Creating fresh connection for SSH token...")
current_context = get_cli_context().connection_context
# Create connection with natural session expiration for SSH token refresh
fresh_connection = connect_to_snowflake(
connection_name=current_context.connection_name,
temporary_connection=current_context.temporary_connection,
# Allow session to expire naturally - don't keep it alive artificially
using_session_keep_alive=False,
)
fresh_connection.cursor().execute(
"ALTER SESSION SET python_connector_query_result_format = 'JSON'"
)
token = fresh_connection.rest.token
if token:
log.debug("Fresh token obtained successfully")
return token
else:
log.debug("No token available from fresh connection")
return None
except Exception as e:
log.debug("Failed to create fresh connection: %s", str(e))
return None
finally:
if fresh_connection and not fresh_connection.is_closed():
fresh_connection.close()

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Comment on lines +396 to +408
# Properly escape values to prevent command injection
escaped_websocat_path = shlex.quote(websocat_path)

# For the token, we need to escape it for shell safety but also ensure it's properly quoted
# within the Authorization header. We escape the token and then add quotes around it.
escaped_token = shlex.quote(token)

config_lines = [
f"Host {service_name}",
f" HostName {hostname}",
f" Port {SSH_DEFAULT_PORT}",
f" User {SSH_DEFAULT_USER}",
f' ProxyCommand {escaped_websocat_path} --binary wss://{hostname}/ -H "Authorization: Snowflake Token=\\"{escaped_token}\\""',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Concern: Potential Command Injection Risk

The current token escaping approach creates a potential security vulnerability. While shlex.quote() is used to escape the token, it's then embedded in a complex string with additional manual escaping (\\") which could compromise the escaping logic.

Consider refactoring this to use a more robust approach:

# Instead of:
f'ProxyCommand {escaped_websocat_path} --binary wss://{hostname}/ -H "Authorization: Snowflake Token=\\"{escaped_token}\\""'

# Consider a cleaner approach that doesn't mix escaping methods:
f'ProxyCommand {escaped_websocat_path} --binary wss://{hostname}/ -H "Authorization: Snowflake Token={escaped_token}"'

This would ensure the token remains properly contained within the shell command structure without risking escape sequence conflicts.

Suggested change
# Properly escape values to prevent command injection
escaped_websocat_path = shlex.quote(websocat_path)
# For the token, we need to escape it for shell safety but also ensure it's properly quoted
# within the Authorization header. We escape the token and then add quotes around it.
escaped_token = shlex.quote(token)
config_lines = [
f"Host {service_name}",
f" HostName {hostname}",
f" Port {SSH_DEFAULT_PORT}",
f" User {SSH_DEFAULT_USER}",
f' ProxyCommand {escaped_websocat_path} --binary wss://{hostname}/ -H "Authorization: Snowflake Token=\\"{escaped_token}\\""',
# Properly escape values to prevent command injection
escaped_websocat_path = shlex.quote(websocat_path)
# For the token, we need to escape it for shell safety
escaped_token = shlex.quote(token)
config_lines = [
f"Host {service_name}",
f" HostName {hostname}",
f" Port {SSH_DEFAULT_PORT}",
f" User {SSH_DEFAULT_USER}",
f' ProxyCommand {escaped_websocat_path} --binary wss://{hostname}/ -H "Authorization: Snowflake Token={escaped_token}"',

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Comment on lines +594 to +614
while True:
log.debug("Token refresh cycle #%d", token_refresh_count + 1)

token = self._get_fresh_token()
if not token:
cc.step("❌ Unable to get token. Retrying in 30 seconds...")
self._wait_with_shutdown_check(SSH_RETRY_INTERVAL)
continue

# Update SSH configuration
auth_method = "SSH key" if private_key_path else "token-only"
log.debug("Updating SSH config with %s authentication", auth_method)

setup_ssh_config_with_token(
service_name, ssh_hostname, token, private_key_path
)

token_refresh_count += 1
cc.step(f"SSH configuration updated (refresh #{token_refresh_count})")

# Wait for next refresh with countdown
self._wait_with_countdown(refresh_interval)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a maximum retry limit or exponential backoff to the while True loop. Currently, if token retrieval consistently fails, the loop will continue indefinitely with fixed 30-second intervals, potentially consuming resources unnecessarily. A retry counter or increasing backoff periods would gracefully handle persistent authentication failures.

Suggested change
while True:
log.debug("Token refresh cycle #%d", token_refresh_count + 1)
token = self._get_fresh_token()
if not token:
cc.step("❌ Unable to get token. Retrying in 30 seconds...")
self._wait_with_shutdown_check(SSH_RETRY_INTERVAL)
continue
# Update SSH configuration
auth_method = "SSH key" if private_key_path else "token-only"
log.debug("Updating SSH config with %s authentication", auth_method)
setup_ssh_config_with_token(
service_name, ssh_hostname, token, private_key_path
)
token_refresh_count += 1
cc.step(f"SSH configuration updated (refresh #{token_refresh_count})")
# Wait for next refresh with countdown
self._wait_with_countdown(refresh_interval)
max_consecutive_failures = 5
consecutive_failures = 0
base_retry_interval = SSH_RETRY_INTERVAL # 30 seconds
while True:
log.debug("Token refresh cycle #%d", token_refresh_count + 1)
token = self._get_fresh_token()
if not token:
consecutive_failures += 1
if consecutive_failures > max_consecutive_failures:
cc.step(f"❌ Failed to get token after {max_consecutive_failures} consecutive attempts. Exiting...")
break
retry_interval = base_retry_interval * (2 ** (consecutive_failures - 1)) # Exponential backoff
cc.step(f"❌ Unable to get token. Retrying in {retry_interval} seconds... (attempt {consecutive_failures}/{max_consecutive_failures})")
self._wait_with_shutdown_check(retry_interval)
continue
# Reset failure counter on success
consecutive_failures = 0
# Update SSH configuration
auth_method = "SSH key" if private_key_path else "token-only"
log.debug("Updating SSH config with %s authentication", auth_method)
setup_ssh_config_with_token(
service_name, ssh_hostname, token, private_key_path
)
token_refresh_count += 1
cc.step(f"SSH configuration updated (refresh #{token_refresh_count})")
# Wait for next refresh with countdown
self._wait_with_countdown(refresh_interval)

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-17-enable_remote_ssh_connection branch from 6d2eb90 to 77accb6 Compare September 15, 2025 05:30
@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-16-add_basic_remote_commands branch from 9f4cced to 777824b Compare September 15, 2025 05:30
Comment on lines +67 to +91
def _setup_ssh_key(self, service_name: str, generate_key: bool) -> Optional[str]:
"""Set up SSH key for a service and return the public key.

Args:
service_name: Name of the service
generate_key: Whether to generate or use SSH keys for the service.
If False, no SSH key operations are performed.

Returns:
SSH public key content if generate_key is True and either:
- An existing SSH key pair is found for the service, or
- A new SSH key pair is successfully generated
None if generate_key is False (no SSH key operations performed)
"""
if not generate_key:
return None

ssh_key_result = get_existing_ssh_key(service_name)
if ssh_key_result:
_, ssh_public_key = ssh_key_result
log.debug("Using existing SSH key pair for service %s", service_name)
return ssh_public_key
else:
log.debug("Generating SSH key pair for service %s", service_name)
_, ssh_public_key = generate_ssh_key_pair(service_name)
return ssh_public_key
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _setup_ssh_key method should handle potential failures from generate_ssh_key_pair(). Currently, if key generation fails, the method will propagate None to service creation which could cause unexpected behavior. Consider adding error handling to either:

  1. Catch exceptions from generate_ssh_key_pair() and log appropriate errors
  2. Verify ssh_public_key is not None after generation
  3. Raise a specific exception with a clear message if key generation fails

This would prevent silent failures and provide better diagnostics when SSH setup doesn't work as expected.

Suggested change
def _setup_ssh_key(self, service_name: str, generate_key: bool) -> Optional[str]:
"""Set up SSH key for a service and return the public key.
Args:
service_name: Name of the service
generate_key: Whether to generate or use SSH keys for the service.
If False, no SSH key operations are performed.
Returns:
SSH public key content if generate_key is True and either:
- An existing SSH key pair is found for the service, or
- A new SSH key pair is successfully generated
None if generate_key is False (no SSH key operations performed)
"""
if not generate_key:
return None
ssh_key_result = get_existing_ssh_key(service_name)
if ssh_key_result:
_, ssh_public_key = ssh_key_result
log.debug("Using existing SSH key pair for service %s", service_name)
return ssh_public_key
else:
log.debug("Generating SSH key pair for service %s", service_name)
_, ssh_public_key = generate_ssh_key_pair(service_name)
return ssh_public_key
def _setup_ssh_key(self, service_name: str, generate_key: bool) -> Optional[str]:
"""Set up SSH key for a service and return the public key.
Args:
service_name: Name of the service
generate_key: Whether to generate or use SSH keys for the service.
If False, no SSH key operations are performed.
Returns:
SSH public key content if generate_key is True and either:
- An existing SSH key pair is found for the service, or
- A new SSH key pair is successfully generated
None if generate_key is False (no SSH key operations performed)
Raises:
SnowflakeSSHKeyError: If SSH key generation fails
"""
if not generate_key:
return None
ssh_key_result = get_existing_ssh_key(service_name)
if ssh_key_result:
_, ssh_public_key = ssh_key_result
log.debug("Using existing SSH key pair for service %s", service_name)
return ssh_public_key
else:
log.debug("Generating SSH key pair for service %s", service_name)
try:
_, ssh_public_key = generate_ssh_key_pair(service_name)
if not ssh_public_key:
raise SnowflakeSSHKeyError(f"Failed to generate SSH public key for service {service_name}")
return ssh_public_key
except Exception as e:
log.error("SSH key generation failed for service %s: %s", service_name, str(e))
raise SnowflakeSSHKeyError(
f"Failed to generate SSH key pair for service {service_name}: {str(e)}"
) from e

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-17-enable_remote_ssh_connection branch from 77accb6 to 85af513 Compare September 19, 2025 19:18
@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-16-add_basic_remote_commands branch from 777824b to 564142b Compare September 19, 2025 19:18
@sfc-gh-zzhu sfc-gh-zzhu mentioned this pull request Sep 29, 2025
9 tasks
@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-16-add_basic_remote_commands branch from 564142b to a794fed Compare October 17, 2025 18:42
@sfc-gh-zzhu sfc-gh-zzhu force-pushed the 08-17-enable_remote_ssh_connection branch from 85af513 to 583ba5c Compare October 17, 2025 18:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants