2022-03-22 19:17:50 +00:00
package cli
import (
2022-06-08 08:45:29 +00:00
"bufio"
"bytes"
"context"
"errors"
2022-03-30 22:59:54 +00:00
"fmt"
2022-06-08 08:45:29 +00:00
"io"
"io/fs"
2023-03-16 18:03:37 +00:00
"net/http"
2022-03-30 22:59:54 +00:00
"os"
"path/filepath"
"runtime"
2022-06-08 08:45:29 +00:00
"sort"
2023-12-08 16:01:13 +00:00
"strconv"
2022-03-30 22:59:54 +00:00
"strings"
"github.com/cli/safeexec"
2022-06-08 08:45:29 +00:00
"github.com/pkg/diff"
"github.com/pkg/diff/write"
2024-02-07 08:21:26 +00:00
"golang.org/x/exp/constraints"
2022-06-08 08:45:29 +00:00
"golang.org/x/exp/slices"
2022-03-30 22:59:54 +00:00
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
2023-08-18 18:55:43 +00:00
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
2024-03-15 16:24:38 +00:00
"github.com/coder/serpent"
2022-03-22 19:17:50 +00:00
)
2022-06-08 08:45:29 +00:00
const (
2022-06-15 14:22:30 +00:00
sshDefaultConfigFileName = "~/.ssh/config"
sshStartToken = "# ------------START-CODER-----------"
sshEndToken = "# ------------END-CODER------------"
sshConfigSectionHeader = "# This section is managed by coder. DO NOT EDIT."
sshConfigDocsHeader = `
2022-03-30 22:59:54 +00:00
#
2022-06-15 14:22:30 +00:00
# You should not hand - edit this section unless you are removing it , all
# changes will be lost when running "coder config-ssh" .
`
sshConfigOptionsHeader = ` #
2022-06-08 08:45:29 +00:00
# Last config - ssh options :
`
)
2022-06-15 14:22:30 +00:00
// sshConfigOptions represents options that can be stored and read
2022-06-08 08:45:29 +00:00
// from the coder config in ~/.ssh/coder.
2022-06-15 14:22:30 +00:00
type sshConfigOptions struct {
2023-12-08 16:01:13 +00:00
waitEnum string
userHostPrefix string
sshOptions [ ] string
disableAutostart bool
2024-02-07 08:21:26 +00:00
header [ ] string
headerCommand string
2022-06-08 08:45:29 +00:00
}
2023-03-16 18:03:37 +00:00
// addOptions expects options in the form of "option=value" or "option value".
// It will override any existing option with the same key to prevent duplicates.
// Invalid options will return an error.
func ( o * sshConfigOptions ) addOptions ( options ... string ) error {
for _ , option := range options {
err := o . addOption ( option )
if err != nil {
return err
}
}
return nil
}
func ( o * sshConfigOptions ) addOption ( option string ) error {
2023-03-28 16:06:42 +00:00
key , value , err := codersdk . ParseSSHConfigOption ( option )
2023-03-16 18:03:37 +00:00
if err != nil {
return err
}
for i , existing := range o . sshOptions {
// Override existing option if they share the same key.
// This is case-insensitive. Parsing each time might be a little slow,
// but it is ok.
existingKey , _ , err := codersdk . ParseSSHConfigOption ( existing )
if err != nil {
// Don't mess with original values if there is an error.
// This could have come from the user's manual edits.
continue
}
if strings . EqualFold ( existingKey , key ) {
2023-03-28 16:06:42 +00:00
if value == "" {
// Delete existing option.
o . sshOptions = append ( o . sshOptions [ : i ] , o . sshOptions [ i + 1 : ] ... )
} else {
// Override existing option.
o . sshOptions [ i ] = option
}
2023-03-16 18:03:37 +00:00
return nil
}
}
2023-03-28 16:06:42 +00:00
// Only append the option if it is not empty.
if value != "" {
o . sshOptions = append ( o . sshOptions , option )
}
2023-03-16 18:03:37 +00:00
return nil
}
2022-06-15 14:22:30 +00:00
func ( o sshConfigOptions ) equal ( other sshConfigOptions ) bool {
2024-02-07 08:21:26 +00:00
if ! slicesSortedEqual ( o . sshOptions , other . sshOptions ) {
2023-06-08 14:06:50 +00:00
return false
}
2024-02-07 08:21:26 +00:00
if ! slicesSortedEqual ( o . header , other . header ) {
return false
}
return o . waitEnum == other . waitEnum && o . userHostPrefix == other . userHostPrefix && o . disableAutostart == other . disableAutostart && o . headerCommand == other . headerCommand
}
// slicesSortedEqual compares two slices without side-effects or regard to order.
func slicesSortedEqual [ S ~ [ ] E , E constraints . Ordered ] ( a , b S ) bool {
if len ( a ) != len ( b ) {
return false
}
a = slices . Clone ( a )
slices . Sort ( a )
b = slices . Clone ( b )
slices . Sort ( b )
return slices . Equal ( a , b )
2022-06-08 08:45:29 +00:00
}
2022-06-15 14:22:30 +00:00
func ( o sshConfigOptions ) asList ( ) ( list [ ] string ) {
2023-06-08 14:06:50 +00:00
if o . waitEnum != "auto" {
list = append ( list , fmt . Sprintf ( "wait: %s" , o . waitEnum ) )
}
if o . userHostPrefix != "" {
list = append ( list , fmt . Sprintf ( "ssh-host-prefix: %s" , o . userHostPrefix ) )
}
2023-12-08 16:01:13 +00:00
if o . disableAutostart {
list = append ( list , fmt . Sprintf ( "disable-autostart: %v" , o . disableAutostart ) )
}
2022-06-08 08:45:29 +00:00
for _ , opt := range o . sshOptions {
list = append ( list , fmt . Sprintf ( "ssh-option: %s" , opt ) )
}
2024-02-07 08:21:26 +00:00
for _ , h := range o . header {
list = append ( list , fmt . Sprintf ( "header: %s" , h ) )
}
if o . headerCommand != "" {
list = append ( list , fmt . Sprintf ( "header-command: %s" , o . headerCommand ) )
}
2022-06-08 08:45:29 +00:00
return list
}
type sshWorkspaceConfig struct {
Name string
Hosts [ ] string
}
func sshFetchWorkspaceConfigs ( ctx context . Context , client * codersdk . Client ) ( [ ] sshWorkspaceConfig , error ) {
2022-11-10 18:25:46 +00:00
res , err := client . Workspaces ( ctx , codersdk . WorkspaceFilter {
2022-06-08 08:45:29 +00:00
Owner : codersdk . Me ,
} )
if err != nil {
return nil , err
}
var errGroup errgroup . Group
2022-11-10 18:25:46 +00:00
workspaceConfigs := make ( [ ] sshWorkspaceConfig , len ( res . Workspaces ) )
for i , workspace := range res . Workspaces {
2022-06-08 08:45:29 +00:00
i := i
workspace := workspace
errGroup . Go ( func ( ) error {
resources , err := client . TemplateVersionResources ( ctx , workspace . LatestBuild . TemplateVersionID )
if err != nil {
return err
}
wc := sshWorkspaceConfig { Name : workspace . Name }
2022-07-21 19:49:32 +00:00
var agents [ ] codersdk . WorkspaceAgent
2022-06-08 08:45:29 +00:00
for _ , resource := range resources {
if resource . Transition != codersdk . WorkspaceTransitionStart {
continue
}
2022-07-21 19:49:32 +00:00
agents = append ( agents , resource . Agents ... )
}
// handle both WORKSPACE and WORKSPACE.AGENT syntax
if len ( agents ) == 1 {
wc . Hosts = append ( wc . Hosts , workspace . Name )
2022-06-08 08:45:29 +00:00
}
2022-07-21 19:49:32 +00:00
for _ , agent := range agents {
hostname := workspace . Name + "." + agent . Name
wc . Hosts = append ( wc . Hosts , hostname )
}
2022-06-08 08:45:29 +00:00
workspaceConfigs [ i ] = wc
return nil
} )
}
err = errGroup . Wait ( )
if err != nil {
return nil , err
}
return workspaceConfigs , nil
}
func sshPrepareWorkspaceConfigs ( ctx context . Context , client * codersdk . Client ) ( receive func ( ) ( [ ] sshWorkspaceConfig , error ) ) {
wcC := make ( chan [ ] sshWorkspaceConfig , 1 )
errC := make ( chan error , 1 )
go func ( ) {
wc , err := sshFetchWorkspaceConfigs ( ctx , client )
wcC <- wc
errC <- err
} ( )
return func ( ) ( [ ] sshWorkspaceConfig , error ) {
return <- wcC , <- errC
}
}
2022-03-22 19:17:50 +00:00
2024-03-17 14:45:26 +00:00
func ( r * RootCmd ) configSSH ( ) * serpent . Command {
2022-03-30 22:59:54 +00:00
var (
2023-06-22 22:08:12 +00:00
sshConfigFile string
sshConfigOpts sshConfigOptions
usePreviousOpts bool
dryRun bool
skipProxyCommand bool
forceUnixSeparators bool
2023-07-13 17:17:39 +00:00
coderCliPath string
2022-03-30 22:59:54 +00:00
)
2023-03-23 22:42:20 +00:00
client := new ( codersdk . Client )
2024-03-17 14:45:26 +00:00
cmd := & serpent . Command {
2022-05-09 22:42:02 +00:00
Annotations : workspaceCommand ,
Use : "config-ssh" ,
2022-09-19 16:36:18 +00:00
Short : "Add an SSH Host entry for your workspaces \"ssh coder.workspace\"" ,
2023-03-23 22:42:20 +00:00
Long : formatExamples (
2022-07-11 16:08:09 +00:00
example {
Description : "You can use -o (or --ssh-option) so set SSH options to be used for all your workspaces" ,
Command : "coder config-ssh -o ForwardAgent=yes" ,
} ,
example {
Description : "You can use --dry-run (or -n) to see the changes that would be made" ,
Command : "coder config-ssh --dry-run" ,
} ,
) ,
2024-03-15 16:24:38 +00:00
Middleware : serpent . Chain (
serpent . RequireNArgs ( 0 ) ,
2023-03-23 22:42:20 +00:00
r . InitClient ( client ) ,
) ,
2024-03-15 16:24:38 +00:00
Handler : func ( inv * serpent . Invocation ) error {
2023-06-08 14:06:50 +00:00
if sshConfigOpts . waitEnum != "auto" && skipProxyCommand {
2023-06-08 14:23:03 +00:00
// The wait option is applied to the ProxyCommand. If the user
// specifies skip-proxy-command, then wait cannot be applied.
2023-06-08 14:06:50 +00:00
return xerrors . Errorf ( "cannot specify both --skip-proxy-command and --wait" )
}
2024-02-07 08:21:26 +00:00
sshConfigOpts . header = r . header
sshConfigOpts . headerCommand = r . headerCommand
2023-06-08 14:06:50 +00:00
2023-03-23 22:42:20 +00:00
recvWorkspaceConfigs := sshPrepareWorkspaceConfigs ( inv . Context ( ) , client )
2022-06-08 08:45:29 +00:00
2023-03-23 22:42:20 +00:00
out := inv . Stdout
2022-06-22 15:33:08 +00:00
if dryRun {
// Print everything except diff to stderr so
// that it's possible to capture the diff.
2023-03-23 22:42:20 +00:00
out = inv . Stderr
2022-06-08 08:45:29 +00:00
}
2023-07-13 17:17:39 +00:00
var err error
coderBinary := coderCliPath
if coderBinary == "" {
coderBinary , err = currentBinPath ( out )
if err != nil {
return err
}
2022-06-08 08:45:29 +00:00
}
2023-07-13 17:17:39 +00:00
2023-06-22 22:08:12 +00:00
escapedCoderBinary , err := sshConfigExecEscape ( coderBinary , forceUnixSeparators )
2022-08-30 18:08:20 +00:00
if err != nil {
return xerrors . Errorf ( "escape coder binary for ssh failed: %w" , err )
}
2023-03-23 22:42:20 +00:00
root := r . createConfig ( )
2023-06-22 22:08:12 +00:00
escapedGlobalConfig , err := sshConfigExecEscape ( string ( root ) , forceUnixSeparators )
2022-08-30 18:08:20 +00:00
if err != nil {
return xerrors . Errorf ( "escape global config for ssh failed: %w" , err )
}
2022-06-08 08:45:29 +00:00
2022-06-15 14:22:30 +00:00
homedir , err := os . UserHomeDir ( )
2022-06-08 08:45:29 +00:00
if err != nil {
return xerrors . Errorf ( "user home dir failed: %w" , err )
}
2022-03-30 22:59:54 +00:00
if strings . HasPrefix ( sshConfigFile , "~/" ) {
2022-06-15 14:22:30 +00:00
sshConfigFile = filepath . Join ( homedir , sshConfigFile [ 2 : ] )
2022-03-30 22:59:54 +00:00
}
2022-06-08 08:45:29 +00:00
// Only allow not-exist errors to avoid trashing
// the users SSH config.
configRaw , err := os . ReadFile ( sshConfigFile )
if err != nil && ! errors . Is ( err , fs . ErrNotExist ) {
return xerrors . Errorf ( "read ssh config failed: %w" , err )
}
2022-06-15 14:22:30 +00:00
// Keep track of changes we are making.
var changes [ ] string
// Parse the previous configuration only if config-ssh
// has been run previously.
var lastConfig * sshConfigOptions
2023-02-03 01:23:42 +00:00
section , ok , err := sshConfigGetCoderSection ( configRaw )
if err != nil {
return err
}
if ok {
2022-06-15 14:22:30 +00:00
c := sshConfigParseLastOptions ( bytes . NewReader ( section ) )
lastConfig = & c
2022-03-30 22:59:54 +00:00
}
2022-04-11 23:54:30 +00:00
2022-06-08 08:45:29 +00:00
// Avoid prompting in diff mode (unexpected behavior)
// or when a previous config does not exist.
2022-06-17 15:03:15 +00:00
if usePreviousOpts && lastConfig != nil {
sshConfigOpts = * lastConfig
2022-06-22 15:33:08 +00:00
} else if lastConfig != nil && ! sshConfigOpts . equal ( * lastConfig ) {
2023-03-16 18:03:37 +00:00
for _ , v := range sshConfigOpts . sshOptions {
// If the user passes an invalid option, we should catch
// this early.
if _ , _ , err := codersdk . ParseSSHConfigOption ( v ) ; err != nil {
return xerrors . Errorf ( "invalid option from flag: %w" , err )
}
}
2022-06-15 14:22:30 +00:00
newOpts := sshConfigOpts . asList ( )
2022-06-08 08:45:29 +00:00
newOptsMsg := "\n\n New options: none"
if len ( newOpts ) > 0 {
newOptsMsg = fmt . Sprintf ( "\n\n New options:\n * %s" , strings . Join ( newOpts , "\n * " ) )
}
2022-06-15 14:22:30 +00:00
oldOpts := lastConfig . asList ( )
2022-06-08 08:45:29 +00:00
oldOptsMsg := "\n\n Previous options: none"
if len ( oldOpts ) > 0 {
oldOptsMsg = fmt . Sprintf ( "\n\n Previous options:\n * %s" , strings . Join ( oldOpts , "\n * " ) )
}
2023-03-23 22:42:20 +00:00
line , err := cliui . Prompt ( inv , cliui . PromptOptions {
2022-06-08 08:45:29 +00:00
Text : fmt . Sprintf ( "New options differ from previous options:%s%s\n\n Use new options?" , newOptsMsg , oldOptsMsg ) ,
IsConfirm : true ,
} )
if err != nil {
if line == "" && xerrors . Is ( err , cliui . Canceled ) {
return nil
}
// Selecting "no" will use the last config.
2022-06-15 14:22:30 +00:00
sshConfigOpts = * lastConfig
2022-08-24 13:58:46 +00:00
} else {
2023-06-08 14:06:50 +00:00
changes = append ( changes , "Use new options" )
2022-06-08 08:45:29 +00:00
}
2022-06-22 15:33:08 +00:00
// Only print when prompts are shown.
2023-03-23 22:42:20 +00:00
if yes , _ := inv . ParsedFlags ( ) . GetBool ( "yes" ) ; ! yes {
2022-06-22 15:33:08 +00:00
_ , _ = fmt . Fprint ( out , "\n" )
}
2022-06-08 08:45:29 +00:00
}
2022-06-15 14:22:30 +00:00
configModified := configRaw
2022-06-08 08:45:29 +00:00
buf := & bytes . Buffer { }
2023-02-03 01:23:42 +00:00
before , _ , after , err := sshConfigSplitOnCoderSection ( configModified )
if err != nil {
return err
}
2022-06-15 14:22:30 +00:00
// Write the first half of the users config file to buf.
_ , _ = buf . Write ( before )
2022-06-08 08:45:29 +00:00
2022-06-15 14:22:30 +00:00
// Write comment and store the provided options as part
2022-06-08 08:45:29 +00:00
// of the config for future (re)use.
2022-06-15 14:22:30 +00:00
newline := len ( before ) > 0
sshConfigWriteSectionHeader ( buf , newline , sshConfigOpts )
2022-06-08 08:45:29 +00:00
workspaceConfigs , err := recvWorkspaceConfigs ( )
if err != nil {
return xerrors . Errorf ( "fetch workspace configs failed: %w" , err )
}
2023-03-16 18:03:37 +00:00
2023-03-23 22:42:20 +00:00
coderdConfig , err := client . SSHConfiguration ( inv . Context ( ) )
2023-03-16 18:03:37 +00:00
if err != nil {
// If the error is 404, this deployment does not support
// this endpoint yet. Do not error, just assume defaults.
// TODO: Remove this in 2 months (May 31, 2023). Just return the error
// and remove this 404 check.
var sdkErr * codersdk . Error
if ! ( xerrors . As ( err , & sdkErr ) && sdkErr . StatusCode ( ) == http . StatusNotFound ) {
return xerrors . Errorf ( "fetch coderd config failed: %w" , err )
}
coderdConfig . HostnamePrefix = "coder."
}
2023-06-08 14:06:50 +00:00
if sshConfigOpts . userHostPrefix != "" {
2023-03-16 18:03:37 +00:00
// Override with user flag.
2023-06-08 14:06:50 +00:00
coderdConfig . HostnamePrefix = sshConfigOpts . userHostPrefix
2023-03-16 18:03:37 +00:00
}
2022-06-08 08:45:29 +00:00
// Ensure stable sorting of output.
2023-08-09 19:50:26 +00:00
slices . SortFunc ( workspaceConfigs , func ( a , b sshWorkspaceConfig ) int {
return slice . Ascending ( a . Name , b . Name )
2022-06-08 08:45:29 +00:00
} )
for _ , wc := range workspaceConfigs {
sort . Strings ( wc . Hosts )
// Write agent configuration.
2023-03-16 18:03:37 +00:00
for _ , workspaceHostname := range wc . Hosts {
sshHostname := fmt . Sprintf ( "%s%s" , coderdConfig . HostnamePrefix , workspaceHostname )
defaultOptions := [ ] string {
"HostName " + sshHostname ,
"ConnectTimeout=0" ,
"StrictHostKeyChecking=no" ,
2022-06-08 08:45:29 +00:00
// Without this, the "REMOTE HOST IDENTITY CHANGED"
// message will appear.
2023-03-16 18:03:37 +00:00
"UserKnownHostsFile=/dev/null" ,
2022-06-08 08:45:29 +00:00
// This disables the "Warning: Permanently added 'hostname' (RSA) to the list of known hosts."
// message from appearing on every SSH. This happens because we ignore the known hosts.
2023-03-16 18:03:37 +00:00
"LogLevel ERROR" ,
}
2022-06-08 08:45:29 +00:00
if ! skipProxyCommand {
2024-02-07 08:21:26 +00:00
rootFlags := fmt . Sprintf ( "--global-config %s" , escapedGlobalConfig )
for _ , h := range sshConfigOpts . header {
rootFlags += fmt . Sprintf ( " --header %q" , h )
}
if sshConfigOpts . headerCommand != "" {
rootFlags += fmt . Sprintf ( " --header-command %q" , sshConfigOpts . headerCommand )
}
2023-06-08 14:06:50 +00:00
flags := ""
if sshConfigOpts . waitEnum != "auto" {
flags += " --wait=" + sshConfigOpts . waitEnum
}
2023-12-08 16:01:13 +00:00
if sshConfigOpts . disableAutostart {
flags += " --disable-autostart=true"
}
2023-03-16 18:03:37 +00:00
defaultOptions = append ( defaultOptions , fmt . Sprintf (
2024-02-07 08:21:26 +00:00
"ProxyCommand %s %s ssh --stdio%s %s" ,
escapedCoderBinary , rootFlags , flags , workspaceHostname ,
2023-03-16 18:03:37 +00:00
) )
}
2023-06-08 14:06:50 +00:00
// Create a copy of the options so we can modify them.
configOptions := sshConfigOpts
configOptions . sshOptions = nil
2023-03-16 18:03:37 +00:00
// Add standard options.
err := configOptions . addOptions ( defaultOptions ... )
if err != nil {
return err
}
// Override with deployment options
for k , v := range coderdConfig . SSHConfigOptions {
opt := fmt . Sprintf ( "%s %s" , k , v )
err := configOptions . addOptions ( opt )
if err != nil {
return xerrors . Errorf ( "add coderd config option %q: %w" , opt , err )
}
}
// Override with flag options
for _ , opt := range sshConfigOpts . sshOptions {
err := configOptions . addOptions ( opt )
if err != nil {
return xerrors . Errorf ( "add flag config option %q: %w" , opt , err )
}
}
hostBlock := [ ] string {
"Host " + sshHostname ,
}
// Prefix with '\t'
for _ , v := range configOptions . sshOptions {
hostBlock = append ( hostBlock , "\t" + v )
2022-06-08 08:45:29 +00:00
}
2023-03-16 18:03:37 +00:00
_ , _ = buf . WriteString ( strings . Join ( hostBlock , "\n" ) )
2022-06-08 08:45:29 +00:00
_ = buf . WriteByte ( '\n' )
}
2022-03-30 22:59:54 +00:00
}
2022-06-08 08:45:29 +00:00
2022-06-15 14:22:30 +00:00
sshConfigWriteSectionEnd ( buf )
// Write the remainder of the users config file to buf.
_ , _ = buf . Write ( after )
if ! bytes . Equal ( configModified , buf . Bytes ( ) ) {
2022-08-24 13:58:46 +00:00
changes = append ( changes , fmt . Sprintf ( "Update the coder section in %s" , sshConfigFile ) )
2022-06-15 14:22:30 +00:00
configModified = buf . Bytes ( )
2022-03-30 22:59:54 +00:00
}
2022-06-08 08:45:29 +00:00
2022-08-24 13:58:46 +00:00
if len ( changes ) == 0 {
_ , _ = fmt . Fprintf ( out , "No changes to make.\n" )
return nil
}
if dryRun {
_ , _ = fmt . Fprintf ( out , "Dry run, the following changes would be made to your SSH configuration:\n\n * %s\n\n" , strings . Join ( changes , "\n * " ) )
2023-03-23 22:42:20 +00:00
color := isTTYOut ( inv )
2022-08-24 13:58:46 +00:00
diff , err := diffBytes ( sshConfigFile , configRaw , configModified , color )
if err != nil {
return xerrors . Errorf ( "diff failed: %w" , err )
}
if len ( diff ) > 0 {
// Write diff to stdout.
2023-03-23 22:42:20 +00:00
_ , _ = fmt . Fprintf ( inv . Stdout , "%s" , diff )
2022-06-22 15:33:08 +00:00
}
2022-08-24 13:58:46 +00:00
return nil
}
if len ( changes ) > 0 {
2023-03-23 22:42:20 +00:00
_ , err = cliui . Prompt ( inv , cliui . PromptOptions {
2022-08-24 13:58:46 +00:00
Text : fmt . Sprintf ( "The following changes will be made to your SSH configuration:\n\n * %s\n\n Continue?" , strings . Join ( changes , "\n * " ) ) ,
2022-06-22 15:33:08 +00:00
IsConfirm : true ,
} )
if err != nil {
return nil
2022-06-08 08:45:29 +00:00
}
2022-06-22 15:33:08 +00:00
// Only print when prompts are shown.
2023-03-23 22:42:20 +00:00
if yes , _ := inv . ParsedFlags ( ) . GetBool ( "yes" ) ; ! yes {
2022-06-22 15:33:08 +00:00
_ , _ = fmt . Fprint ( out , "\n" )
}
}
2022-06-08 08:45:29 +00:00
2022-08-24 13:58:46 +00:00
if ! bytes . Equal ( configRaw , configModified ) {
err = writeWithTempFileAndMove ( sshConfigFile , bytes . NewReader ( configModified ) )
if err != nil {
return xerrors . Errorf ( "write ssh config failed: %w" , err )
2022-06-08 08:45:29 +00:00
}
2023-03-23 22:42:20 +00:00
_ , _ = fmt . Fprintf ( out , "Updated %q\n" , sshConfigFile )
2022-06-08 08:45:29 +00:00
}
if len ( workspaceConfigs ) > 0 {
_ , _ = fmt . Fprintln ( out , "You should now be able to ssh into your workspace." )
2023-03-16 18:03:37 +00:00
_ , _ = fmt . Fprintf ( out , "For example, try running:\n\n\t$ ssh %s%s\n" , coderdConfig . HostnamePrefix , workspaceConfigs [ 0 ] . Name )
2022-06-08 08:45:29 +00:00
} else {
2022-08-24 13:58:46 +00:00
_ , _ = fmt . Fprint ( out , "You don't have any workspaces yet, try creating one with:\n\n\t$ coder create <workspace>\n" )
2022-03-30 22:59:54 +00:00
}
2022-03-22 19:17:50 +00:00
return nil
} ,
}
2023-03-23 22:42:20 +00:00
2024-03-15 16:24:38 +00:00
cmd . Options = serpent . OptionSet {
2023-03-23 22:42:20 +00:00
{
Flag : "ssh-config-file" ,
Env : "CODER_SSH_CONFIG_FILE" ,
Default : sshDefaultConfigFileName ,
Description : "Specifies the path to an SSH config." ,
2024-03-15 16:24:38 +00:00
Value : serpent . StringOf ( & sshConfigFile ) ,
2023-03-23 22:42:20 +00:00
} ,
2023-07-13 17:17:39 +00:00
{
Flag : "coder-binary-path" ,
Env : "CODER_SSH_CONFIG_BINARY_PATH" ,
Default : "" ,
Description : "Optionally specify the absolute path to the coder binary used in ProxyCommand. " +
"By default, the binary invoking this command ('config ssh') is used." ,
2024-03-15 16:24:38 +00:00
Value : serpent . Validate ( serpent . StringOf ( & coderCliPath ) , func ( value * serpent . String ) error {
2023-07-13 17:17:39 +00:00
if runtime . GOOS == goosWindows {
// For some reason filepath.IsAbs() does not work on windows.
return nil
}
absolute := filepath . IsAbs ( value . String ( ) )
if ! absolute {
return xerrors . Errorf ( "coder cli path must be an absolute path" )
}
return nil
} ) ,
} ,
2023-03-23 22:42:20 +00:00
{
Flag : "ssh-option" ,
FlagShorthand : "o" ,
Env : "CODER_SSH_CONFIG_OPTS" ,
Description : "Specifies additional SSH options to embed in each host stanza." ,
2024-03-15 16:24:38 +00:00
Value : serpent . StringArrayOf ( & sshConfigOpts . sshOptions ) ,
2023-03-23 22:42:20 +00:00
} ,
{
Flag : "dry-run" ,
FlagShorthand : "n" ,
Env : "CODER_SSH_DRY_RUN" ,
Description : "Perform a trial run with no changes made, showing a diff at the end." ,
2024-03-15 16:24:38 +00:00
Value : serpent . BoolOf ( & dryRun ) ,
2023-03-23 22:42:20 +00:00
} ,
{
Flag : "skip-proxy-command" ,
Env : "CODER_SSH_SKIP_PROXY_COMMAND" ,
Description : "Specifies whether the ProxyCommand option should be skipped. Useful for testing." ,
2024-03-15 16:24:38 +00:00
Value : serpent . BoolOf ( & skipProxyCommand ) ,
2023-03-23 22:42:20 +00:00
Hidden : true ,
} ,
{
Flag : "use-previous-options" ,
Env : "CODER_SSH_USE_PREVIOUS_OPTIONS" ,
Description : "Specifies whether or not to keep options from previous run of config-ssh." ,
2024-03-15 16:24:38 +00:00
Value : serpent . BoolOf ( & usePreviousOpts ) ,
2023-03-23 22:42:20 +00:00
} ,
{
Flag : "ssh-host-prefix" ,
2023-06-08 14:06:50 +00:00
Env : "CODER_CONFIGSSH_SSH_HOST_PREFIX" ,
2023-03-23 22:42:20 +00:00
Description : "Override the default host prefix." ,
2024-03-15 16:24:38 +00:00
Value : serpent . StringOf ( & sshConfigOpts . userHostPrefix ) ,
2023-06-08 14:06:50 +00:00
} ,
{
Flag : "wait" ,
Env : "CODER_CONFIGSSH_WAIT" , // Not to be mixed with CODER_SSH_WAIT.
Description : "Specifies whether or not to wait for the startup script to finish executing. Auto means that the agent startup script behavior configured in the workspace template is used." ,
Default : "auto" ,
2024-03-15 16:24:38 +00:00
Value : serpent . EnumOf ( & sshConfigOpts . waitEnum , "yes" , "no" , "auto" ) ,
2023-03-23 22:42:20 +00:00
} ,
2023-12-08 16:01:13 +00:00
{
Flag : "disable-autostart" ,
Description : "Disable starting the workspace automatically when connecting via SSH." ,
Env : "CODER_CONFIGSSH_DISABLE_AUTOSTART" ,
2024-03-15 16:24:38 +00:00
Value : serpent . BoolOf ( & sshConfigOpts . disableAutostart ) ,
2023-12-08 16:01:13 +00:00
Default : "false" ,
} ,
2023-06-22 22:08:12 +00:00
{
Flag : "force-unix-filepaths" ,
Env : "CODER_CONFIGSSH_UNIX_FILEPATHS" ,
Description : "By default, 'config-ssh' uses the os path separator when writing the ssh config. " +
"This might be an issue in Windows machine that use a unix-like shell. " +
"This flag forces the use of unix file paths (the forward slash '/')." ,
2024-03-15 16:24:38 +00:00
Value : serpent . BoolOf ( & forceUnixSeparators ) ,
2023-06-22 22:08:12 +00:00
// On non-windows showing this command is useless because it is a noop.
// Hide vs disable it though so if a command is copied from a Windows
// machine to a unix machine it will still work and not throw an
// "unknown flag" error.
Hidden : hideForceUnixSlashes ,
} ,
2023-03-23 22:42:20 +00:00
cliui . SkipPromptOption ( ) ,
}
2022-06-17 15:03:15 +00:00
2022-06-15 14:22:30 +00:00
return cmd
2022-06-08 08:45:29 +00:00
}
2022-06-15 14:22:30 +00:00
//nolint:revive
func sshConfigWriteSectionHeader ( w io . Writer , addNewline bool , o sshConfigOptions ) {
nl := "\n"
if ! addNewline {
nl = ""
2022-06-08 08:45:29 +00:00
}
2022-06-15 14:22:30 +00:00
_ , _ = fmt . Fprint ( w , nl + sshStartToken + "\n" )
_ , _ = fmt . Fprint ( w , sshConfigSectionHeader )
_ , _ = fmt . Fprint ( w , sshConfigDocsHeader )
2023-06-08 14:06:50 +00:00
var ow strings . Builder
if o . waitEnum != "auto" {
_ , _ = fmt . Fprintf ( & ow , "# :%s=%s\n" , "wait" , o . waitEnum )
}
if o . userHostPrefix != "" {
_ , _ = fmt . Fprintf ( & ow , "# :%s=%s\n" , "ssh-host-prefix" , o . userHostPrefix )
}
2023-12-08 16:01:13 +00:00
if o . disableAutostart {
_ , _ = fmt . Fprintf ( & ow , "# :%s=%v\n" , "disable-autostart" , o . disableAutostart )
}
2023-06-08 14:06:50 +00:00
for _ , opt := range o . sshOptions {
_ , _ = fmt . Fprintf ( & ow , "# :%s=%s\n" , "ssh-option" , opt )
}
2024-02-07 08:21:26 +00:00
for _ , h := range o . header {
_ , _ = fmt . Fprintf ( & ow , "# :%s=%s\n" , "header" , h )
}
if o . headerCommand != "" {
_ , _ = fmt . Fprintf ( & ow , "# :%s=%s\n" , "header-command" , o . headerCommand )
}
2023-06-08 14:06:50 +00:00
if ow . Len ( ) > 0 {
2022-06-15 14:22:30 +00:00
_ , _ = fmt . Fprint ( w , sshConfigOptionsHeader )
2023-06-08 14:06:50 +00:00
_ , _ = fmt . Fprint ( w , ow . String ( ) )
2022-06-08 08:45:29 +00:00
}
2023-06-08 14:06:50 +00:00
2022-06-08 08:45:29 +00:00
_ , _ = fmt . Fprint ( w , "#\n" )
}
2022-06-15 14:22:30 +00:00
func sshConfigWriteSectionEnd ( w io . Writer ) {
_ , _ = fmt . Fprint ( w , sshEndToken + "\n" )
}
2022-06-08 08:45:29 +00:00
2022-06-15 14:22:30 +00:00
func sshConfigParseLastOptions ( r io . Reader ) ( o sshConfigOptions ) {
2023-06-08 14:06:50 +00:00
// Default values.
o . waitEnum = "auto"
2022-06-08 08:45:29 +00:00
s := bufio . NewScanner ( r )
for s . Scan ( ) {
line := s . Text ( )
if strings . HasPrefix ( line , "# :" ) {
line = strings . TrimPrefix ( line , "# :" )
parts := strings . SplitN ( line , "=" , 2 )
switch parts [ 0 ] {
2023-06-08 14:06:50 +00:00
case "wait" :
o . waitEnum = parts [ 1 ]
case "ssh-host-prefix" :
o . userHostPrefix = parts [ 1 ]
2022-06-08 08:45:29 +00:00
case "ssh-option" :
o . sshOptions = append ( o . sshOptions , parts [ 1 ] )
2023-12-08 16:01:13 +00:00
case "disable-autostart" :
o . disableAutostart , _ = strconv . ParseBool ( parts [ 1 ] )
2024-02-07 08:21:26 +00:00
case "header" :
o . header = append ( o . header , parts [ 1 ] )
case "header-command" :
o . headerCommand = parts [ 1 ]
2022-06-08 08:45:29 +00:00
default :
// Unknown option, ignore.
}
}
}
if err := s . Err ( ) ; err != nil {
panic ( err )
}
return o
}
2023-02-03 01:23:42 +00:00
// sshConfigGetCoderSection is a helper function that only returns the coder
// section of the SSH config and a boolean if it exists.
func sshConfigGetCoderSection ( data [ ] byte ) ( section [ ] byte , ok bool , err error ) {
_ , section , _ , err = sshConfigSplitOnCoderSection ( data )
if err != nil {
return nil , false , err
2022-06-15 14:22:30 +00:00
}
2023-02-03 01:23:42 +00:00
return section , len ( section ) > 0 , nil
2022-06-15 14:22:30 +00:00
}
2023-02-03 01:23:42 +00:00
// sshConfigSplitOnCoderSection splits the SSH config into 3 sections.
// All lines before sshStartToken, the coder section, and all lines after
// sshEndToken.
func sshConfigSplitOnCoderSection ( data [ ] byte ) ( before , section [ ] byte , after [ ] byte , err error ) {
startCount := bytes . Count ( data , [ ] byte ( sshStartToken ) )
endCount := bytes . Count ( data , [ ] byte ( sshEndToken ) )
if startCount > 1 || endCount > 1 {
return nil , nil , nil , xerrors . New ( "Malformed config: ssh config has multiple coder sections, please remove all but one" )
}
2022-06-15 14:22:30 +00:00
startIndex := bytes . Index ( data , [ ] byte ( sshStartToken ) )
endIndex := bytes . Index ( data , [ ] byte ( sshEndToken ) )
2023-02-03 01:23:42 +00:00
if startIndex == - 1 && endIndex != - 1 {
return nil , nil , nil , xerrors . New ( "Malformed config: ssh config has end header, but missing start header" )
}
if startIndex != - 1 && endIndex == - 1 {
return nil , nil , nil , xerrors . New ( "Malformed config: ssh config has start header, but missing end header" )
}
2022-06-15 14:22:30 +00:00
if startIndex != - 1 && endIndex != - 1 {
2023-02-03 01:23:42 +00:00
if startIndex > endIndex {
return nil , nil , nil , xerrors . New ( "Malformed config: ssh config has coder section, but it is malformed and the END header is before the START header" )
}
2022-06-15 14:22:30 +00:00
// We use -1 and +1 here to also include the preceding
// and trailing newline, where applicable.
start := startIndex
if start > 0 {
start --
}
end := endIndex + len ( sshEndToken )
if end < len ( data ) {
end ++
}
2023-02-03 01:23:42 +00:00
return data [ : start ] , data [ start : end ] , data [ end : ] , nil
2022-06-15 14:22:30 +00:00
}
2023-02-03 01:23:42 +00:00
return data , nil , nil , nil
2022-06-15 14:22:30 +00:00
}
2022-06-08 08:45:29 +00:00
// writeWithTempFileAndMove writes to a temporary file in the same
// directory as path and renames the temp file to the file provided in
// path. This ensure we avoid trashing the file we are writing due to
// unforeseen circumstance like filesystem full, command killed, etc.
func writeWithTempFileAndMove ( path string , r io . Reader ) ( err error ) {
dir := filepath . Dir ( path )
name := filepath . Base ( path )
2022-08-30 18:08:20 +00:00
// Ensure that e.g. the ~/.ssh directory exists.
if err = os . MkdirAll ( dir , 0 o700 ) ; err != nil {
return xerrors . Errorf ( "create directory: %w" , err )
}
2022-06-08 08:45:29 +00:00
// Create a tempfile in the same directory for ensuring write
// operation does not fail.
f , err := os . CreateTemp ( dir , fmt . Sprintf ( ".%s." , name ) )
if err != nil {
return xerrors . Errorf ( "create temp file failed: %w" , err )
}
defer func ( ) {
if err != nil {
_ = os . Remove ( f . Name ( ) ) // Cleanup in case a step failed.
}
} ( )
_ , err = io . Copy ( f , r )
if err != nil {
_ = f . Close ( )
return xerrors . Errorf ( "write temp file failed: %w" , err )
}
err = f . Close ( )
if err != nil {
return xerrors . Errorf ( "close temp file failed: %w" , err )
}
err = os . Rename ( f . Name ( ) , path )
if err != nil {
return xerrors . Errorf ( "rename temp file failed: %w" , err )
}
return nil
}
2022-08-30 18:08:20 +00:00
// sshConfigExecEscape quotes the string if it contains spaces, as per
// `man 5 ssh_config`. However, OpenSSH uses exec in the users shell to
// run the command, and as such the formatting/escape requirements
// cannot simply be covered by `fmt.Sprintf("%q", path)`.
//
// Always escaping the path with `fmt.Sprintf("%q", path)` usually works
// on most platforms, but double quotes sometimes break on Windows 10
// (see #2853). This function takes a best-effort approach to improving
// compatibility and covering edge cases.
//
// Given the following ProxyCommand:
//
// ProxyCommand "/path/with space/coder" ssh --stdio work
//
// This is ~what OpenSSH would execute:
//
// /bin/bash -c '"/path/with space/to/coder" ssh --stdio workspace'
//
// However, since it's actually an arg in C, the contents inside the
// single quotes are interpreted as is, e.g. if there was a '\t', it
// would be the literal string '\t', not a tab.
//
// See:
// - https://github.com/coder/coder/issues/2853
// - https://github.com/openssh/openssh-portable/blob/V_9_0_P1/sshconnect.c#L158-L167
// - https://github.com/PowerShell/openssh-portable/blob/v8.1.0.0/sshconnect.c#L231-L293
// - https://github.com/PowerShell/openssh-portable/blob/v8.1.0.0/contrib/win32/win32compat/w32fd.c#L1075-L1100
2023-06-22 22:08:12 +00:00
//
// Additional Windows-specific notes:
//
// In some situations a Windows user could be using a unix-like shell such as
// git bash. In these situations the coder.exe is using the windows filepath
// separator (\), but the shell wants the unix filepath separator (/).
// Trying to determine if the shell is unix-like is difficult, so this function
// takes the argument 'forceUnixPath' to force the filepath to be unix-like.
//
// On actual unix machines, this is **always** a noop. Even if a windows
// path is provided.
//
// Passing a "false" for forceUnixPath will result in the filepath separator
// untouched from the original input.
// ---
// This is a control flag, and that is ok. It is a control flag
// based on the OS of the user. Making this a different file is excessive.
// nolint:revive
func sshConfigExecEscape ( path string , forceUnixPath bool ) ( string , error ) {
if forceUnixPath {
// This is a workaround for #7639, where the filepath separator is
// incorrectly the Windows separator (\) instead of the unix separator (/).
path = filepath . ToSlash ( path )
}
2022-08-30 18:08:20 +00:00
// This is unlikely to ever happen, but newlines are allowed on
// certain filesystems, but cannot be used inside ssh config.
if strings . ContainsAny ( path , "\n" ) {
return "" , xerrors . Errorf ( "invalid path: %s" , path )
}
// In the unlikely even that a path contains quotes, they must be
// escaped so that they are not interpreted as shell quotes.
if strings . Contains ( path , "\"" ) {
path = strings . ReplaceAll ( path , "\"" , "\\\"" )
}
// A space or a tab requires quoting, but tabs must not be escaped
// (\t) since OpenSSH interprets it as a literal \t, not a tab.
if strings . ContainsAny ( path , " \t" ) {
path = fmt . Sprintf ( "\"%s\"" , path ) //nolint:gocritic // We don't want %q here.
}
return path , nil
}
2022-03-30 22:59:54 +00:00
// currentBinPath returns the path to the coder binary suitable for use in ssh
// ProxyCommand.
2022-06-08 08:45:29 +00:00
func currentBinPath ( w io . Writer ) ( string , error ) {
2022-03-30 22:59:54 +00:00
exePath , err := os . Executable ( )
if err != nil {
return "" , xerrors . Errorf ( "get executable path: %w" , err )
}
binName := filepath . Base ( exePath )
// We use safeexec instead of os/exec because os/exec returns paths in
// the current working directory, which we will run into very often when
// looking for our own path.
pathPath , err := safeexec . LookPath ( binName )
// On Windows, the coder-cli executable must be in $PATH for both Msys2/Git
// Bash and OpenSSH for Windows (used by Powershell and VS Code) to function
// correctly. Check if the current executable is in $PATH, and warn the user
// if it isn't.
if err != nil && runtime . GOOS == "windows" {
2022-06-08 08:45:29 +00:00
cliui . Warn ( w ,
2022-03-30 22:59:54 +00:00
"The current executable is not in $PATH." ,
"This may lead to problems connecting to your workspace via SSH." ,
fmt . Sprintf ( "Please move %q to a location in your $PATH (such as System32) and run `%s config-ssh` again." , binName , binName ) ,
)
2022-06-08 08:45:29 +00:00
_ , _ = fmt . Fprint ( w , "\n" )
2022-03-30 22:59:54 +00:00
// Return the exePath so SSH at least works outside of Msys2.
return exePath , nil
}
// Warn the user if the current executable is not the same as the one in
// $PATH.
if filepath . Clean ( pathPath ) != filepath . Clean ( exePath ) {
2022-06-08 08:45:29 +00:00
cliui . Warn ( w ,
2022-03-30 22:59:54 +00:00
"The current executable path does not match the executable path found in $PATH." ,
"This may cause issues connecting to your workspace via SSH." ,
fmt . Sprintf ( "\tCurrent executable path: %q" , exePath ) ,
fmt . Sprintf ( "\tExecutable path in $PATH: %q" , pathPath ) ,
)
2022-06-08 08:45:29 +00:00
_ , _ = fmt . Fprint ( w , "\n" )
2022-03-30 22:59:54 +00:00
}
2022-06-27 17:15:55 +00:00
return exePath , nil
2022-03-30 22:59:54 +00:00
}
2022-06-08 08:45:29 +00:00
// diffBytes takes two byte slices and diffs them as if they were in a
// file named name.
2022-08-21 22:32:53 +00:00
// nolint: revive // Color is an option, not a control coupling.
2022-06-08 08:45:29 +00:00
func diffBytes ( name string , b1 , b2 [ ] byte , color bool ) ( [ ] byte , error ) {
var buf bytes . Buffer
var opts [ ] write . Option
if color {
opts = append ( opts , write . TerminalColor ( ) )
}
2022-06-22 15:33:08 +00:00
err := diff . Text ( name , name , b1 , b2 , & buf , opts ... )
2022-06-08 08:45:29 +00:00
if err != nil {
return nil , err
}
b := buf . Bytes ( )
// Check if diff only output two lines, if yes, there's no diff.
//
// Example:
// --- /home/user/.ssh/config
2022-06-22 15:33:08 +00:00
// +++ /home/user/.ssh/config
2022-06-08 08:45:29 +00:00
if bytes . Count ( b , [ ] byte { '\n' } ) == 2 {
b = nil
}
return b , nil
}