microsoft/TypeAgent
Publicmirrored fromhttps://github.com/microsoft/TypeAgentAvailable
.devcontainer/scripts/setup-ssh-access.sh
365lines · modecode
| 1 | #!/usr/bin/env bash |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # Licensed under the MIT License. |
| 4 | |
| 5 | set -euo pipefail |
| 6 | |
| 7 | SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) |
| 8 | REPO_ROOT=$(cd -- "$SCRIPT_DIR/../.." && pwd) |
| 9 | WORKSPACE_FOLDER=$(cd -- "$REPO_ROOT" && pwd) |
| 10 | DEFAULT_KEY_NAME="typeagent-devcontainer" |
| 11 | DEFAULT_KEY_PATH="$HOME/.ssh/$DEFAULT_KEY_NAME" |
| 12 | DEFAULT_CONFIG_PATH="$HOME/.ssh/config" |
| 13 | DEFAULT_LOCAL_PORT="2222" |
| 14 | REMOTE_USER="codespace" |
| 15 | |
| 16 | usage() { |
| 17 | cat <<EOF |
| 18 | Usage: $(basename "$0") [options] |
| 19 | |
| 20 | Set up SSH key access to the running TypeAgent devcontainer. |
| 21 | |
| 22 | Options: |
| 23 | --workspace-folder PATH Workspace folder to match (default: repo root) |
| 24 | --config PATH Devcontainer config file to match (optional) |
| 25 | --key-path PATH Private key path to use (default: $DEFAULT_KEY_PATH) |
| 26 | --host-alias NAME SSH host alias to write to ~/.ssh/config (default: typeagent-devcontainer) |
| 27 | --local-port PORT Local SSH port to expose in ssh config (default: $DEFAULT_LOCAL_PORT) |
| 28 | --insecure-local Disable host key verification for local-only workflows |
| 29 | --print-only Do not modify files or container state; print detected values only |
| 30 | -h, --help Show this help text |
| 31 | EOF |
| 32 | } |
| 33 | |
| 34 | log() { |
| 35 | printf '[setup-ssh-access] %s\n' "$*" |
| 36 | } |
| 37 | |
| 38 | fail() { |
| 39 | printf '[setup-ssh-access] Error: %s\n' "$*" >&2 |
| 40 | exit 1 |
| 41 | } |
| 42 | |
| 43 | require_cmd() { |
| 44 | command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1" |
| 45 | } |
| 46 | |
| 47 | is_wsl() { |
| 48 | [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qiE "microsoft|wsl" /proc/version 2>/dev/null |
| 49 | } |
| 50 | |
| 51 | ensure_ssh_config_block() { |
| 52 | local config_path=$1 |
| 53 | local host_alias=$2 |
| 54 | local key_path_for_config=$3 |
| 55 | local strict_host_key_checking=$4 |
| 56 | local user_known_hosts_file=$5 |
| 57 | local global_known_hosts_file=$6 |
| 58 | |
| 59 | local ssh_config_block |
| 60 | ssh_config_block=$(cat <<EOF |
| 61 | Host $host_alias |
| 62 | HostName localhost |
| 63 | Port $LOCAL_PORT |
| 64 | User $REMOTE_USER |
| 65 | IdentityFile $key_path_for_config |
| 66 | IdentitiesOnly yes |
| 67 | PreferredAuthentications publickey |
| 68 | PubkeyAuthentication yes |
| 69 | PasswordAuthentication no |
| 70 | KbdInteractiveAuthentication no |
| 71 | StrictHostKeyChecking $strict_host_key_checking |
| 72 | UserKnownHostsFile $user_known_hosts_file |
| 73 | GlobalKnownHostsFile $global_known_hosts_file |
| 74 | EOF |
| 75 | ) |
| 76 | |
| 77 | local config_begin_marker="# BEGIN typeagent-devcontainer:$host_alias" |
| 78 | local config_end_marker="# END typeagent-devcontainer:$host_alias" |
| 79 | local legacy_marker="# typeagent-devcontainer:$host_alias" |
| 80 | |
| 81 | mkdir -p "$(dirname "$config_path")" |
| 82 | touch "$config_path" |
| 83 | chmod 600 "$config_path" 2>/dev/null || true |
| 84 | |
| 85 | if [[ $PRINT_ONLY -eq 1 ]]; then |
| 86 | log "Would ensure SSH config block in $config_path" |
| 87 | return 0 |
| 88 | fi |
| 89 | |
| 90 | local tmp_file |
| 91 | tmp_file=$(mktemp) |
| 92 | trap 'rm -f "$tmp_file"' RETURN |
| 93 | |
| 94 | awk \ |
| 95 | -v alias="$host_alias" \ |
| 96 | -v begin_marker="$config_begin_marker" \ |
| 97 | -v end_marker="$config_end_marker" \ |
| 98 | -v legacy_marker="$legacy_marker" ' |
| 99 | $0 == begin_marker { in_managed=1; next } |
| 100 | in_managed { |
| 101 | if ($0 == end_marker) { |
| 102 | in_managed=0 |
| 103 | } |
| 104 | next |
| 105 | } |
| 106 | $0 == legacy_marker { next } |
| 107 | $0 ~ ("^Host[[:space:]]+" alias "$") { in_alias_stanza=1; next } |
| 108 | in_alias_stanza { |
| 109 | if ($0 ~ /^Host[[:space:]]+/) { |
| 110 | in_alias_stanza=0 |
| 111 | print |
| 112 | } |
| 113 | next |
| 114 | } |
| 115 | { print } |
| 116 | ' "$config_path" > "$tmp_file" |
| 117 | mv "$tmp_file" "$config_path" |
| 118 | trap - RETURN |
| 119 | |
| 120 | { |
| 121 | if [[ -s "$config_path" ]] && [[ "$(tail -c 1 "$config_path")" != "" ]]; then |
| 122 | printf '\n' |
| 123 | fi |
| 124 | printf '%s\n%s\n%s\n' "$config_begin_marker" "$ssh_config_block" "$config_end_marker" |
| 125 | } >> "$config_path" |
| 126 | } |
| 127 | |
| 128 | WORKSPACE_MATCH="$WORKSPACE_FOLDER" |
| 129 | CONFIG_MATCH="" |
| 130 | KEY_PATH="$DEFAULT_KEY_PATH" |
| 131 | HOST_ALIAS="$DEFAULT_KEY_NAME" |
| 132 | LOCAL_PORT="$DEFAULT_LOCAL_PORT" |
| 133 | PRINT_ONLY=0 |
| 134 | INSECURE_LOCAL=0 |
| 135 | |
| 136 | while [[ $# -gt 0 ]]; do |
| 137 | case "$1" in |
| 138 | --workspace-folder) |
| 139 | [[ $# -ge 2 ]] || fail "Missing value for $1" |
| 140 | WORKSPACE_MATCH=$(cd -- "$2" && pwd) |
| 141 | shift 2 |
| 142 | ;; |
| 143 | --config) |
| 144 | [[ $# -ge 2 ]] || fail "Missing value for $1" |
| 145 | CONFIG_MATCH=$(cd -- "$(dirname -- "$2")" && pwd)/$(basename -- "$2") |
| 146 | shift 2 |
| 147 | ;; |
| 148 | --key-path) |
| 149 | [[ $# -ge 2 ]] || fail "Missing value for $1" |
| 150 | KEY_PATH="$2" |
| 151 | shift 2 |
| 152 | ;; |
| 153 | --host-alias) |
| 154 | [[ $# -ge 2 ]] || fail "Missing value for $1" |
| 155 | HOST_ALIAS="$2" |
| 156 | shift 2 |
| 157 | ;; |
| 158 | --local-port) |
| 159 | [[ $# -ge 2 ]] || fail "Missing value for $1" |
| 160 | LOCAL_PORT="$2" |
| 161 | shift 2 |
| 162 | ;; |
| 163 | --insecure-local) |
| 164 | INSECURE_LOCAL=1 |
| 165 | shift |
| 166 | ;; |
| 167 | --print-only) |
| 168 | PRINT_ONLY=1 |
| 169 | shift |
| 170 | ;; |
| 171 | -h|--help) |
| 172 | usage |
| 173 | exit 0 |
| 174 | ;; |
| 175 | *) |
| 176 | fail "Unknown argument: $1" |
| 177 | ;; |
| 178 | esac |
| 179 | done |
| 180 | |
| 181 | require_cmd docker |
| 182 | require_cmd jq |
| 183 | require_cmd ssh-keygen |
| 184 | require_cmd ssh |
| 185 | |
| 186 | [[ "$HOST_ALIAS" =~ ^[a-zA-Z0-9._-]+$ ]] || fail "Invalid host alias: $HOST_ALIAS" |
| 187 | [[ "$LOCAL_PORT" =~ ^[0-9]+$ ]] || fail "Invalid local port: $LOCAL_PORT" |
| 188 | |
| 189 | mkdir -p "$HOME/.ssh" |
| 190 | chmod 700 "$HOME/.ssh" |
| 191 | |
| 192 | STRICT_HOST_KEY_CHECKING="accept-new" |
| 193 | USER_KNOWN_HOSTS_FILE="$HOME/.ssh/known_hosts" |
| 194 | GLOBAL_KNOWN_HOSTS_FILE="/etc/ssh/ssh_known_hosts" |
| 195 | if [[ $INSECURE_LOCAL -eq 1 ]]; then |
| 196 | STRICT_HOST_KEY_CHECKING="no" |
| 197 | USER_KNOWN_HOSTS_FILE="/dev/null" |
| 198 | GLOBAL_KNOWN_HOSTS_FILE="/dev/null" |
| 199 | fi |
| 200 | |
| 201 | find_container() { |
| 202 | local workspace=$1 |
| 203 | local config=$2 |
| 204 | local container_ids=() |
| 205 | mapfile -t container_ids < <(docker ps -q) |
| 206 | [[ ${#container_ids[@]} -gt 0 ]] || return 0 |
| 207 | |
| 208 | docker inspect "${container_ids[@]}" | jq -r --arg workspace "$workspace" --arg config "$config" ' |
| 209 | .[] |
| 210 | | select(.Config.Labels["devcontainer.local_folder"] == $workspace) |
| 211 | | select(($config == "") or (.Config.Labels["devcontainer.config_file"] == $config)) |
| 212 | | .Name |
| 213 | | ltrimstr("/") |
| 214 | ' | head -n 1 |
| 215 | } |
| 216 | |
| 217 | CONTAINER_NAME=$(find_container "$WORKSPACE_MATCH" "$CONFIG_MATCH") |
| 218 | [[ -n "$CONTAINER_NAME" ]] || fail "No running devcontainer found for $WORKSPACE_MATCH${CONFIG_MATCH:+ using $CONFIG_MATCH}" |
| 219 | |
| 220 | log "Using container: $CONTAINER_NAME" |
| 221 | log "Workspace match: $WORKSPACE_MATCH" |
| 222 | if [[ -n "$CONFIG_MATCH" ]]; then |
| 223 | log "Config match: $CONFIG_MATCH" |
| 224 | fi |
| 225 | |
| 226 | if [[ ! -f "$KEY_PATH" ]]; then |
| 227 | if [[ $PRINT_ONLY -eq 1 ]]; then |
| 228 | log "Would create SSH key: $KEY_PATH" |
| 229 | else |
| 230 | log "Creating SSH key: $KEY_PATH" |
| 231 | ssh-keygen -t ed25519 -f "$KEY_PATH" -N '' -C "$HOST_ALIAS" |
| 232 | fi |
| 233 | else |
| 234 | log "Using existing SSH key: $KEY_PATH" |
| 235 | fi |
| 236 | |
| 237 | PUB_KEY_PATH="$KEY_PATH.pub" |
| 238 | if [[ ! -f "$PUB_KEY_PATH" ]]; then |
| 239 | if [[ $PRINT_ONLY -eq 1 ]]; then |
| 240 | log "Would create public key: $PUB_KEY_PATH" |
| 241 | PUB_KEY="" |
| 242 | else |
| 243 | fail "Public key not found: $PUB_KEY_PATH" |
| 244 | fi |
| 245 | else |
| 246 | PUB_KEY=$(cat "$PUB_KEY_PATH") |
| 247 | fi |
| 248 | |
| 249 | if [[ $PRINT_ONLY -eq 1 ]]; then |
| 250 | log "Would install public key into container user $REMOTE_USER" |
| 251 | else |
| 252 | log "Installing public key into container authorized_keys if needed" |
| 253 | docker exec -u "$REMOTE_USER" "$CONTAINER_NAME" sh -lc ' |
| 254 | set -eu |
| 255 | umask 077 |
| 256 | mkdir -p "$HOME/.ssh" |
| 257 | touch "$HOME/.ssh/authorized_keys" |
| 258 | chmod 700 "$HOME/.ssh" |
| 259 | chmod 600 "$HOME/.ssh/authorized_keys" |
| 260 | ' |
| 261 | |
| 262 | docker exec -i -u "$REMOTE_USER" "$CONTAINER_NAME" sh -lc ' |
| 263 | set -eu |
| 264 | key=$(cat) |
| 265 | auth="$HOME/.ssh/authorized_keys" |
| 266 | if ! grep -Fqx "$key" "$auth"; then |
| 267 | printf "%s\n" "$key" >> "$auth" |
| 268 | echo "added" |
| 269 | else |
| 270 | echo "present" |
| 271 | fi |
| 272 | ' <<< "$PUB_KEY" >/tmp/typeagent-devcontainer-ssh-key-status.$$ || fail "Failed to install public key into container" |
| 273 | |
| 274 | KEY_STATUS=$(cat /tmp/typeagent-devcontainer-ssh-key-status.$$) |
| 275 | rm -f /tmp/typeagent-devcontainer-ssh-key-status.$$ |
| 276 | log "Container key status: $KEY_STATUS" |
| 277 | fi |
| 278 | |
| 279 | if [[ $PRINT_ONLY -eq 1 ]]; then |
| 280 | log "Would harden container sshd to key-only authentication" |
| 281 | else |
| 282 | log "Hardening container sshd configuration to key-only authentication" |
| 283 | docker exec -u root "$CONTAINER_NAME" sh -lc ' |
| 284 | set -eu |
| 285 | install -d -m 755 /etc/ssh/sshd_config.d |
| 286 | cat > /etc/ssh/sshd_config.d/99-typeagent-key-only.conf <<"EOF" |
| 287 | PubkeyAuthentication yes |
| 288 | PasswordAuthentication no |
| 289 | KbdInteractiveAuthentication no |
| 290 | ChallengeResponseAuthentication no |
| 291 | UsePAM yes |
| 292 | EOF |
| 293 | |
| 294 | if pgrep sshd >/dev/null 2>&1; then |
| 295 | pkill -HUP sshd || true |
| 296 | elif command -v service >/dev/null 2>&1; then |
| 297 | service ssh restart || service sshd restart || true |
| 298 | fi |
| 299 | ' || fail "Failed to harden sshd in container" |
| 300 | log "Container sshd hardening applied" |
| 301 | fi |
| 302 | |
| 303 | CONFIG_PATH="$DEFAULT_CONFIG_PATH" |
| 304 | log "Writing SSH config block for $HOST_ALIAS" |
| 305 | ensure_ssh_config_block \ |
| 306 | "$CONFIG_PATH" \ |
| 307 | "$HOST_ALIAS" \ |
| 308 | "$KEY_PATH" \ |
| 309 | "$STRICT_HOST_KEY_CHECKING" \ |
| 310 | "$USER_KNOWN_HOSTS_FILE" \ |
| 311 | "$GLOBAL_KNOWN_HOSTS_FILE" |
| 312 | |
| 313 | if is_wsl; then |
| 314 | if ! command -v cmd.exe >/dev/null 2>&1 || ! command -v wslpath >/dev/null 2>&1; then |
| 315 | log "Warning: WSL detected but cmd.exe / wslpath unavailable; skipping Windows SSH sync" |
| 316 | else |
| 317 | WINDOWS_USERPROFILE_WIN=$(cmd.exe /C "echo %USERPROFILE%" 2>/dev/null | tr -d '\r' || true) |
| 318 | if [[ -z "$WINDOWS_USERPROFILE_WIN" ]] || [[ "$WINDOWS_USERPROFILE_WIN" == "%USERPROFILE%" ]]; then |
| 319 | log "Warning: could not resolve Windows %USERPROFILE%; skipping Windows SSH sync" |
| 320 | else |
| 321 | WINDOWS_USERPROFILE_WSL=$(wslpath -u "$WINDOWS_USERPROFILE_WIN" 2>/dev/null || true) |
| 322 | if [[ -z "$WINDOWS_USERPROFILE_WSL" ]]; then |
| 323 | log "Warning: wslpath could not translate %USERPROFILE% ($WINDOWS_USERPROFILE_WIN); skipping Windows SSH sync" |
| 324 | else |
| 325 | WINDOWS_SSH_DIR_WSL="$WINDOWS_USERPROFILE_WSL/.ssh" |
| 326 | WINDOWS_KEY_BASENAME=$(basename "$KEY_PATH") |
| 327 | WINDOWS_KEY_PATH_WSL="$WINDOWS_SSH_DIR_WSL/$WINDOWS_KEY_BASENAME" |
| 328 | WINDOWS_PUB_KEY_PATH_WSL="$WINDOWS_KEY_PATH_WSL.pub" |
| 329 | WINDOWS_CONFIG_PATH_WSL="$WINDOWS_SSH_DIR_WSL/config" |
| 330 | WINDOWS_KEY_PATH_CONFIG=$(wslpath -m "$WINDOWS_KEY_PATH_WSL" 2>/dev/null || true) |
| 331 | WINDOWS_KNOWN_HOSTS_CONFIG=$(wslpath -m "$WINDOWS_SSH_DIR_WSL/known_hosts" 2>/dev/null || true) |
| 332 | [[ -n "$WINDOWS_KEY_PATH_CONFIG" ]] || fail "Failed to convert WSL key path to Windows path" |
| 333 | [[ -n "$WINDOWS_KNOWN_HOSTS_CONFIG" ]] || fail "Failed to convert WSL known_hosts path to Windows path" |
| 334 | |
| 335 | if [[ $PRINT_ONLY -eq 1 ]]; then |
| 336 | log "Would copy keypair to Windows SSH dir: $WINDOWS_SSH_DIR_WSL" |
| 337 | else |
| 338 | mkdir -p "$WINDOWS_SSH_DIR_WSL" |
| 339 | cp -f "$KEY_PATH" "$WINDOWS_KEY_PATH_WSL" |
| 340 | cp -f "$PUB_KEY_PATH" "$WINDOWS_PUB_KEY_PATH_WSL" |
| 341 | log "Copied keypair to Windows SSH dir: $WINDOWS_SSH_DIR_WSL" |
| 342 | fi |
| 343 | |
| 344 | log "Writing Windows SSH config block for $HOST_ALIAS" |
| 345 | ensure_ssh_config_block \ |
| 346 | "$WINDOWS_CONFIG_PATH_WSL" \ |
| 347 | "$HOST_ALIAS" \ |
| 348 | "$WINDOWS_KEY_PATH_CONFIG" \ |
| 349 | "$STRICT_HOST_KEY_CHECKING" \ |
| 350 | "$WINDOWS_KNOWN_HOSTS_CONFIG" \ |
| 351 | "none" |
| 352 | fi |
| 353 | fi |
| 354 | fi |
| 355 | fi |
| 356 | |
| 357 | printf '\nSSH setup complete.\n\n' |
| 358 | printf 'Container: %s\n' "$CONTAINER_NAME" |
| 359 | printf 'Host alias: %s\n' "$HOST_ALIAS" |
| 360 | printf 'Key: %s\n' "$KEY_PATH" |
| 361 | printf 'Local port: %s\n\n' "$LOCAL_PORT" |
| 362 | printf 'Connect with:\n' |
| 363 | printf ' ssh %s\n\n' "$HOST_ALIAS" |
| 364 | printf 'Or without SSH config:\n' |
| 365 | printf ' ssh -i %s -p %s %s@localhost\n' "$KEY_PATH" "$LOCAL_PORT" "$REMOTE_USER" |
| 366 | |