├── VERSION ├── config_test ├── test_fixtures │ ├── valid │ │ ├── basic_with_variables │ │ │ ├── empty.hcl │ │ │ ├── noise.txt │ │ │ ├── variables.hcl │ │ │ └── example.hcl │ │ ├── no_config │ │ │ └── example.hcl │ │ ├── no_hostname │ │ │ └── wildcard.hcl │ │ └── importing_configs │ │ │ └── example.hcl │ └── invalid │ │ ├── alias_and_hostname_not_specified │ │ └── example.hcl │ │ ├── no_hostname_nor_config │ │ └── example.hcl │ │ ├── variable_redeclaration │ │ └── example.hcl │ │ ├── circular_imports │ │ └── example.hcl │ │ ├── config_not_found │ │ └── host_only.hcl │ │ ├── invalid_hcl │ │ └── invalid.hcl │ │ ├── duplicate_alias │ │ ├── second_alias.hcl │ │ └── first_alias.hcl │ │ ├── non_existing_variable │ │ ├── in_alias │ │ │ └── example.hcl │ │ ├── in_hostname │ │ │ └── example.hcl │ │ ├── in_config │ │ │ └── example.hcl │ │ └── in_external_config │ │ │ └── example.hcl │ │ └── invalid_import_value │ │ └── example.hcl ├── invalid_config_test.go └── valid_config_test.go ├── integration_test ├── hostname_only │ ├── compile_result │ ├── list_result │ └── example.hcl ├── readme │ ├── variables.hcl │ ├── example_service_2.hcl │ ├── example_service_1.hcl │ ├── list_result │ └── compile_result ├── readme_regexp │ ├── config.hcl │ ├── hosts.txt │ ├── list_result │ └── compile_result ├── regexp_hosts │ ├── config.hcl │ ├── hosts.txt │ ├── list_result │ └── compile_result └── generic_test.go ├── command ├── test-fixtures │ ├── config_only.hcl │ ├── my_service.hcl │ ├── consul_and_frontend.hcl │ ├── list_result │ └── compile_result ├── compile_test.go ├── list_test.go ├── hosts.go ├── confirm.go ├── list.go ├── confirm_test.go ├── compile.go └── cli.go ├── config ├── keyword_sanitizer.go ├── hcl.go ├── decoder.go ├── keyword_sanitizer_test.go ├── scanner_test.go ├── scanner.go ├── variables.go ├── reader.go ├── config_props.go └── compiler_input.go ├── .gitignore ├── .golangci.yml ├── compiler ├── validator_test.go ├── validator.go ├── structs.go ├── expander.go ├── compiler.go ├── compiler_test.go └── expander_test.go ├── go.mod ├── main.go ├── .github └── workflows │ ├── release.yml │ ├── README.md │ └── ci.yml ├── LICENSE ├── package.sh ├── Makefile ├── go.sum └── README.md /VERSION: -------------------------------------------------------------------------------- 1 | v0.4.0 2 | -------------------------------------------------------------------------------- /config_test/test_fixtures/valid/basic_with_variables/empty.hcl: -------------------------------------------------------------------------------- 1 | # this file is empty -------------------------------------------------------------------------------- /config_test/test_fixtures/invalid/alias_and_hostname_not_specified/example.hcl: -------------------------------------------------------------------------------- 1 | host "wat" {} -------------------------------------------------------------------------------- /config_test/test_fixtures/valid/basic_with_variables/noise.txt: -------------------------------------------------------------------------------- 1 | this file makes intended noise -------------------------------------------------------------------------------- /integration_test/hostname_only/compile_result: -------------------------------------------------------------------------------- 1 | Host * 2 | A 1 3 | 4 | Host prod* 5 | A 2 6 | 7 | -------------------------------------------------------------------------------- /config_test/test_fixtures/invalid/no_hostname_nor_config/example.hcl: -------------------------------------------------------------------------------- 1 | host "wat" { 2 | alias = "alias" 3 | } -------------------------------------------------------------------------------- /command/test-fixtures/config_only.hcl: -------------------------------------------------------------------------------- 1 | config "global" { 2 | identity_file = "id_rsa.pem" 3 | port = 22 4 | } 5 | -------------------------------------------------------------------------------- /config_test/test_fixtures/valid/no_config/example.hcl: -------------------------------------------------------------------------------- 1 | host "example" { 2 | alias = "short" 3 | hostname = "long" 4 | } -------------------------------------------------------------------------------- /integration_test/hostname_only/list_result: -------------------------------------------------------------------------------- 1 | hostname_only/example.hcl (2): 2 | 3 | all (1): 4 | * 5 | 6 | prod (1): 7 | prod* 8 | -------------------------------------------------------------------------------- /config_test/test_fixtures/invalid/variable_redeclaration/example.hcl: -------------------------------------------------------------------------------- 1 | var { 2 | abc { 3 | def = 1 4 | } 5 | abc.def = 2 6 | } -------------------------------------------------------------------------------- /integration_test/readme/variables.hcl: -------------------------------------------------------------------------------- 1 | var { 2 | keys { 3 | abc = "~/.ssh/abc.pem" 4 | other = "~/.ssh/other.pem" 5 | } 6 | } -------------------------------------------------------------------------------- /config_test/test_fixtures/invalid/circular_imports/example.hcl: -------------------------------------------------------------------------------- 1 | config "Alice" { 2 | _extend = "Bob" 3 | } 4 | 5 | config "Bob" { 6 | _extend = "Alice" 7 | } -------------------------------------------------------------------------------- /config_test/test_fixtures/invalid/config_not_found/host_only.hcl: -------------------------------------------------------------------------------- 1 | host "wally-host" { 2 | hostname = "wally.example.com" 3 | alias = "w" 4 | config = "wally" 5 | } 6 | -------------------------------------------------------------------------------- /config_test/test_fixtures/invalid/invalid_hcl/invalid.hcl: -------------------------------------------------------------------------------- 1 | host "service-a" { 2 | hostname = "service-a[1..5].example.com" 3 | alias = "a{#1}" 4 | config = { 5 | user = "joe" 6 | } 7 | -------------------------------------------------------------------------------- /config_test/test_fixtures/invalid/duplicate_alias/second_alias.hcl: -------------------------------------------------------------------------------- 1 | host "service-a" { 2 | hostname = "service-a[1..5].example.com" 3 | alias = "a{#1}" 4 | config = { 5 | user = "james" 6 | } 7 | } -------------------------------------------------------------------------------- /config_test/test_fixtures/invalid/non_existing_variable/in_alias/example.hcl: -------------------------------------------------------------------------------- 1 | host "service-a" { 2 | hostname = "service-a[1..5]" 3 | alias = "a${a}.${b.c3.d4}" 4 | } 5 | 6 | var { 7 | a = "123" 8 | } -------------------------------------------------------------------------------- /config_test/test_fixtures/invalid/duplicate_alias/first_alias.hcl: -------------------------------------------------------------------------------- 1 | host "service-a" { 2 | hostname = "service-a[1..5].example.com" 3 | alias = "a{#1}" 4 | config = { 5 | user = "joe" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /command/test-fixtures/my_service.hcl: -------------------------------------------------------------------------------- 1 | host "my-service" { 2 | hostname = "instance[1..2].my-service.example.com" 3 | alias = "myservice{#1}" 4 | config = { 5 | identity_file = "my_service.pem" 6 | } 7 | } -------------------------------------------------------------------------------- /config_test/test_fixtures/invalid/non_existing_variable/in_hostname/example.hcl: -------------------------------------------------------------------------------- 1 | host "service-a" { 2 | hostname = "service-${a}[1..5].${b.c3ę.d4.E_F-d}.${a}" 3 | alias = "a${a}" 4 | } 5 | 6 | var { 7 | a = "123" 8 | } -------------------------------------------------------------------------------- /config_test/test_fixtures/invalid/invalid_import_value/example.hcl: -------------------------------------------------------------------------------- 1 | host "def" { 2 | hostname = "servcice-def.example.com" 3 | alias = "def" 4 | config = "def_conf" 5 | } 6 | 7 | config "def_conf" { 8 | _extend = 1 9 | } 10 | -------------------------------------------------------------------------------- /integration_test/hostname_only/example.hcl: -------------------------------------------------------------------------------- 1 | host "all" { 2 | alias = "*" 3 | config { 4 | a = 1 5 | } 6 | } 7 | 8 | host "prod" { 9 | hostname = "prod*" 10 | config { 11 | a = 2 12 | } 13 | } -------------------------------------------------------------------------------- /integration_test/readme/example_service_2.hcl: -------------------------------------------------------------------------------- 1 | host "other" { 2 | hostname = "other[1..2].example.com" 3 | alias = "other{#1}" 4 | config { 5 | user = "lurker" 6 | identity_file = "${keys.other}" 7 | port = 22 8 | } 9 | } -------------------------------------------------------------------------------- /config_test/test_fixtures/valid/no_hostname/wildcard.hcl: -------------------------------------------------------------------------------- 1 | host "all" { 2 | alias = "*" 3 | config { 4 | a = 1 5 | } 6 | } 7 | 8 | host "prod" { 9 | hostname = "prod*" 10 | config { 11 | a = 2 12 | } 13 | } -------------------------------------------------------------------------------- /config_test/test_fixtures/invalid/non_existing_variable/in_config/example.hcl: -------------------------------------------------------------------------------- 1 | host "service-a" { 2 | hostname = "service-a[1..5]" 3 | alias = "a${a}" 4 | config { 5 | user = "${b.c3.d4}" 6 | } 7 | } 8 | 9 | var { 10 | a = "123" 11 | } -------------------------------------------------------------------------------- /integration_test/readme_regexp/config.hcl: -------------------------------------------------------------------------------- 1 | host "dc1-services" { 2 | hostname = "instance(\\d+)\\.my\\-service\\-(dev|prod|test)\\..+" 3 | alias = "host{#1}.{#2}" 4 | config { 5 | user = "abc" 6 | identity_file = "~/.ssh/key.pem" 7 | } 8 | } -------------------------------------------------------------------------------- /config_test/test_fixtures/invalid/non_existing_variable/in_external_config/example.hcl: -------------------------------------------------------------------------------- 1 | host "service-a" { 2 | hostname = "service-a[1..5]" 3 | alias = "a${a}" 4 | config = "ext" 5 | } 6 | 7 | config "ext" { 8 | user = "${b.c3.d4}" 9 | } 10 | 11 | var { 12 | a = "123" 13 | } -------------------------------------------------------------------------------- /integration_test/readme/example_service_1.hcl: -------------------------------------------------------------------------------- 1 | host "abc" { 2 | hostname = "node[1..2].abc.[dev|test].example.com" 3 | alias = "{#2}.abc{#1}" 4 | config = "abc-config" 5 | } 6 | 7 | config "abc-config" { 8 | user = "ubuntu" 9 | identity_file = "${keys.abc}" 10 | port = 22 11 | } -------------------------------------------------------------------------------- /integration_test/readme_regexp/hosts.txt: -------------------------------------------------------------------------------- 1 | instance1.my-service-evo.example.com 2 | instance1.my-service-dev.example.com 3 | instance1.my-service-test.example.com 4 | instance2.my-service-test.example.com 5 | instance1.my-service-prod.example.com 6 | instance2.my-service-prod.example.com 7 | instance3.my-service-prod.example.com -------------------------------------------------------------------------------- /integration_test/regexp_hosts/config.hcl: -------------------------------------------------------------------------------- 1 | host "dc1-services" { 2 | hostname = "ab-([a-z]+\\d+)\\.([a-z-]+)\\-(prod|test).my.dc1.com" 3 | alias = "{#3}.{#2}-{#1}.dc1" 4 | } 5 | 6 | host "dc2-services" { 7 | hostname = "ab-([a-z]+\\d+)\\.([a-z-]+)\\-(prod|test).my.dc2.net" 8 | alias = "{#3}.{#2}-{#1}.dc2" 9 | } -------------------------------------------------------------------------------- /command/test-fixtures/consul_and_frontend.hcl: -------------------------------------------------------------------------------- 1 | host "consul" { 2 | hostname = "consul[1..3].[dc1|dc2].example.com" 3 | alias = "consul{#1}-{#2}" 4 | config = { 5 | identity_file = "some_file.pem" 6 | user = "ubuntu" 7 | } 8 | } 9 | 10 | host "frontend" { 11 | hostname = "frontend[1..2].example.com" 12 | alias = "front{#1}" 13 | config = "global" 14 | } 15 | -------------------------------------------------------------------------------- /config/keyword_sanitizer.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "strings" 5 | 6 | "golang.org/x/text/cases" 7 | "golang.org/x/text/language" 8 | ) 9 | 10 | func sanitize(keyword string) string { 11 | withSpaces := strings.ReplaceAll(keyword, "_", " ") 12 | titled := cases.Title(language.English, cases.NoLower).String(withSpaces) 13 | return strings.ReplaceAll(titled, " ", "") 14 | } 15 | -------------------------------------------------------------------------------- /integration_test/readme/list_result: -------------------------------------------------------------------------------- 1 | readme/example_service_1.hcl (1): 2 | 3 | abc (4): 4 | dev.abc1: node1.abc.dev.example.com 5 | dev.abc2: node2.abc.dev.example.com 6 | test.abc1: node1.abc.test.example.com 7 | test.abc2: node2.abc.test.example.com 8 | 9 | readme/example_service_2.hcl (1): 10 | 11 | other (2): 12 | other1: other1.example.com 13 | other2: other2.example.com 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 14 | .glide/ 15 | 16 | .idea/ 17 | 18 | dist/ 19 | target/ 20 | 21 | .vscode/ 22 | -------------------------------------------------------------------------------- /integration_test/readme_regexp/list_result: -------------------------------------------------------------------------------- 1 | readme_regexp/config.hcl (1): 2 | 3 | dc1-services (6): 4 | host1.dev: instance1.my-service-dev.example.com 5 | host1.test: instance1.my-service-test.example.com 6 | host2.test: instance2.my-service-test.example.com 7 | host1.prod: instance1.my-service-prod.example.com 8 | host2.prod: instance2.my-service-prod.example.com 9 | host3.prod: instance3.my-service-prod.example.com 10 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | run: 4 | timeout: 10m 5 | go: "1.25" 6 | 7 | linters: 8 | enable: 9 | - govet 10 | - errcheck 11 | - staticcheck 12 | - ineffassign 13 | - unused 14 | - misspell 15 | - dupl 16 | - gocritic 17 | - revive 18 | - unconvert 19 | - goconst 20 | - prealloc 21 | - nakedret 22 | - noctx 23 | - paralleltest 24 | - tagliatelle 25 | - tparallel 26 | - wastedassign 27 | -------------------------------------------------------------------------------- /config_test/test_fixtures/valid/basic_with_variables/variables.hcl: -------------------------------------------------------------------------------- 1 | var { 2 | domain1 = "my.domain1.example.com" 3 | domain2 = "my.domain2.example.com" 4 | env { 5 | dev = "development" 6 | prod = "production" 7 | user = "deployment" 8 | more { 9 | test = "testing" 10 | key_name = "secret" 11 | number = 1001 12 | } 13 | } 14 | threshold = 123 15 | b_count = 2 16 | b_alias = "b{#1}" 17 | } 18 | -------------------------------------------------------------------------------- /config/hcl.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type rawFileContext struct { 4 | Hosts []host `hcl:"host"` 5 | RawConfigs map[string]rawConfig `hcl:"config"` 6 | Variables map[string]interface{} `hcl:"var"` 7 | } 8 | 9 | type rawConfig []map[string]interface{} 10 | 11 | type host struct { 12 | Name string `hcl:",key"` 13 | Hostname string `hcl:"hostname"` 14 | Alias string `hcl:"alias"` 15 | RawConfigOrRef interface{} `hcl:"config"` 16 | } 17 | -------------------------------------------------------------------------------- /compiler/validator_test.go: -------------------------------------------------------------------------------- 1 | package compiler 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestValidate(t *testing.T) { 10 | t.Parallel() 11 | 12 | // given 13 | results := []HostEntity{{ 14 | Host: "is_unique", 15 | }, { 16 | Host: "is_unique", 17 | }} 18 | 19 | // when 20 | err := NewValidator().ValidateResults(results) 21 | 22 | // then 23 | assert.Error(t, err) 24 | assert.Equal(t, "generated results contain duplicate alias: `is_unique`", err.Error()) 25 | } 26 | -------------------------------------------------------------------------------- /config/decoder.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/hashicorp/hcl" 5 | ) 6 | 7 | type decoder struct{} 8 | 9 | func newDecoder() *decoder { 10 | return &decoder{} 11 | } 12 | 13 | func (d *decoder) decode(input []byte) (rawFileContext, error) { 14 | config := rawFileContext{} 15 | file, err := hcl.ParseBytes(input) 16 | if err != nil { 17 | return rawFileContext{}, err 18 | } 19 | err = hcl.DecodeObject(&config, file) 20 | if err != nil { 21 | return rawFileContext{}, err 22 | } 23 | return config, nil 24 | } 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dankraw/ssh-aliases 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/hashicorp/hcl v1.0.0 7 | github.com/pkg/errors v0.9.1 8 | github.com/stretchr/testify v1.10.0 9 | github.com/urfave/cli v1.22.17 10 | golang.org/x/text v0.28.0 11 | ) 12 | 13 | require ( 14 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/pmezard/go-difflib v1.0.0 // indirect 17 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 18 | gopkg.in/yaml.v3 v3.0.1 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /config_test/test_fixtures/valid/basic_with_variables/example.hcl: -------------------------------------------------------------------------------- 1 | host "service-a" { 2 | hostname = "service-a[1..5].${domain1}" 3 | alias = "a{#1}" 4 | config = "service-a" 5 | } 6 | 7 | host "service-b" { 8 | hostname = "service-b[1..${b_count}].example.com" 9 | alias = "${b_alias}" 10 | config { 11 | identity_file = "b_id_${env.more.number}_rsa.pem" 12 | port = 22 13 | } 14 | } 15 | 16 | config "service-a" { 17 | identity_file = "a_${env.more.number}_id_${env.more.key_name}_rsa.pem" 18 | port = 22 19 | user = "${env.user}" 20 | } 21 | -------------------------------------------------------------------------------- /integration_test/regexp_hosts/hosts.txt: -------------------------------------------------------------------------------- 1 | github.com 2 | localhost 3 | ab-frontend1.aaa-prod.my.dc1.com 4 | ab-frontend1.bbb-prod.my.dc1.com 5 | ab-frontend2.aaa-prod.my.dc1.com 6 | ab-frontend2.bbb-prod.my.dc1.com 7 | ab-frontend3.aaa-prod.my.dc1.com 8 | ab-frontend5.bbb-prod.my.dc2.net 9 | ab-frontend6.bbb-prod.my.dc2.net 10 | cc-host1.ccc-prod.my.dc2.net 11 | 12 | ab-frontend1.aaa-test.my.dc1.com 13 | ab-frontend2.aaa-test.my.dc1.com 14 | ab-backend100.aaa-prod.my.dc1.com 15 | ab-backend102.aaa-prod.my.dc1.com 16 | ab-backend11.aaa.dev.my.dc1.com 17 | # some comment 18 | -------------------------------------------------------------------------------- /command/compile_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestCompileCommandExecute(t *testing.T) { 13 | t.Parallel() 14 | 15 | // given 16 | buffer := new(bytes.Buffer) 17 | hosts := []string{} 18 | 19 | // when 20 | err := newCompileCommand(buffer).execute(fixtureDir, hosts) 21 | 22 | // then 23 | assert.NoError(t, err) 24 | output, _ := os.ReadFile(filepath.Join(fixtureDir, "compile_result")) 25 | assert.Equal(t, string(output), buffer.String()) 26 | } 27 | -------------------------------------------------------------------------------- /command/test-fixtures/list_result: -------------------------------------------------------------------------------- 1 | test-fixtures/consul_and_frontend.hcl (2): 2 | 3 | consul (6): 4 | consul1-dc1: consul1.dc1.example.com 5 | consul2-dc1: consul2.dc1.example.com 6 | consul3-dc1: consul3.dc1.example.com 7 | consul1-dc2: consul1.dc2.example.com 8 | consul2-dc2: consul2.dc2.example.com 9 | consul3-dc2: consul3.dc2.example.com 10 | 11 | frontend (2): 12 | front1: frontend1.example.com 13 | front2: frontend2.example.com 14 | 15 | test-fixtures/my_service.hcl (1): 16 | 17 | my-service (2): 18 | myservice1: instance1.my-service.example.com 19 | myservice2: instance2.my-service.example.com 20 | -------------------------------------------------------------------------------- /command/list_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | const fixtureDir = "test-fixtures" 13 | 14 | func TestListCommandExecute(t *testing.T) { 15 | t.Parallel() 16 | 17 | // given 18 | buffer := new(bytes.Buffer) 19 | hosts := []string{} 20 | 21 | // when 22 | err := newListCommand(buffer).execute(fixtureDir, hosts) 23 | 24 | // then 25 | assert.NoError(t, err) 26 | output, _ := os.ReadFile(filepath.Join(fixtureDir, "list_result")) 27 | assert.Equal(t, string(output), buffer.String()) 28 | } 29 | -------------------------------------------------------------------------------- /config/keyword_sanitizer_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestShouldSanitizeKeywords(t *testing.T) { 10 | t.Parallel() 11 | 12 | // given 13 | entries := []struct { 14 | input string 15 | expected string 16 | }{ 17 | {"identity_file", "IdentityFile"}, 18 | {"port", "Port"}, 19 | {"hash_known_hosts", "HashKnownHosts"}, 20 | {"MACs", "MACs"}, 21 | {"RhostsRSAAuthentication", "RhostsRSAAuthentication"}, 22 | } 23 | 24 | for _, e := range entries { 25 | // when 26 | actual := sanitize(e.input) 27 | 28 | // then 29 | assert.Equal(t, actual, e.expected) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /config/scanner_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestShouldScanDir(t *testing.T) { 10 | t.Parallel() 11 | 12 | // given 13 | scanner := NewScanner() 14 | 15 | // when 16 | files, err := scanner.ScanDirectory("../config_test/test_fixtures/valid/basic_with_variables") 17 | 18 | // then 19 | assert.NoError(t, err) 20 | assert.Equal(t, []string{ 21 | "../config_test/test_fixtures/valid/basic_with_variables/empty.hcl", 22 | "../config_test/test_fixtures/valid/basic_with_variables/example.hcl", 23 | "../config_test/test_fixtures/valid/basic_with_variables/variables.hcl", 24 | }, files) 25 | } 26 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package main provides the ssh-aliases command-line interface 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | 8 | "github.com/dankraw/ssh-aliases/command" 9 | ) 10 | 11 | // Version contains the binary version when built with -ldflags "-X main.Version=" 12 | var Version string 13 | 14 | func main() { 15 | cli, err := command.NewCLI(Version, os.Stdout) 16 | if err != nil { 17 | fmt.Fprintf(os.Stderr, "An error occurred while configuring CLI:\n%v\n", err.Error()) 18 | os.Exit(1) 19 | } 20 | err = cli.ApplyArgs(os.Args) 21 | if err != nil { 22 | fmt.Fprintf(os.Stderr, "An error occurred during command execution:\n%v\n", err.Error()) 23 | os.Exit(1) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /command/hosts.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | func readHostsFile(hostsFile string) ([]string, error) { 10 | if hostsFile == "" { 11 | return []string{}, nil 12 | } 13 | 14 | // Basic path validation 15 | if strings.Contains(hostsFile, "..") || strings.HasPrefix(hostsFile, "/") { 16 | return nil, fmt.Errorf("invalid hosts file path: %s", hostsFile) 17 | } 18 | 19 | // #nosec G304: path is validated above (no absolute paths, no path traversal) 20 | bytes, err := os.ReadFile(hostsFile) 21 | if err != nil { 22 | return nil, fmt.Errorf("could not read input hosts file: %s: %s", hostsFile, err.Error()) 23 | } 24 | return strings.Split(string(bytes), "\n"), nil 25 | } 26 | -------------------------------------------------------------------------------- /compiler/validator.go: -------------------------------------------------------------------------------- 1 | package compiler 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Validator can be used to check the consistency of generated HostEntities 8 | type Validator struct{} 9 | 10 | // NewValidator creates an instance of Validator 11 | func NewValidator() *Validator { 12 | return &Validator{} 13 | } 14 | 15 | // ValidateResults checks if generated HostEntities have unique alias names 16 | func (v *Validator) ValidateResults(results []HostEntity) error { 17 | aliases := make(map[string]struct{}) 18 | var exists struct{} 19 | for _, r := range results { 20 | if _, contains := aliases[r.Host]; contains { 21 | return fmt.Errorf("generated results contain duplicate alias: `%v`", r.Host) 22 | } 23 | aliases[r.Host] = exists 24 | } 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /config_test/test_fixtures/valid/importing_configs/example.hcl: -------------------------------------------------------------------------------- 1 | host "abc" { 2 | hostname = "servcice-abc.example.com" 3 | alias = "abc" 4 | config { 5 | _extend = "root" 6 | x = "y" 7 | } 8 | } 9 | 10 | host "def" { 11 | hostname = "servcice-def.example.com" 12 | alias = "def" 13 | config = "def_conf" 14 | } 15 | 16 | config "def_conf" { 17 | some_prop = 123 18 | this = "never happens" 19 | _extend = "intermediate" 20 | } 21 | 22 | config "intermediate" { 23 | _extend = ["root", "root_2"] 24 | this = "happens" 25 | } 26 | 27 | config "root" { 28 | additional = "extension" 29 | another = "one" 30 | } 31 | 32 | config "root_2" { 33 | additional = "extension 2" 34 | another = "two" 35 | _extend = "root" # not a circular dependency in this case 36 | } -------------------------------------------------------------------------------- /integration_test/regexp_hosts/list_result: -------------------------------------------------------------------------------- 1 | regexp_hosts/config.hcl (2): 2 | 3 | dc1-services (9): 4 | prod.aaa-frontend1.dc1: ab-frontend1.aaa-prod.my.dc1.com 5 | prod.bbb-frontend1.dc1: ab-frontend1.bbb-prod.my.dc1.com 6 | prod.aaa-frontend2.dc1: ab-frontend2.aaa-prod.my.dc1.com 7 | prod.bbb-frontend2.dc1: ab-frontend2.bbb-prod.my.dc1.com 8 | prod.aaa-frontend3.dc1: ab-frontend3.aaa-prod.my.dc1.com 9 | test.aaa-frontend1.dc1: ab-frontend1.aaa-test.my.dc1.com 10 | test.aaa-frontend2.dc1: ab-frontend2.aaa-test.my.dc1.com 11 | prod.aaa-backend100.dc1: ab-backend100.aaa-prod.my.dc1.com 12 | prod.aaa-backend102.dc1: ab-backend102.aaa-prod.my.dc1.com 13 | 14 | dc2-services (2): 15 | prod.bbb-frontend5.dc2: ab-frontend5.bbb-prod.my.dc2.net 16 | prod.bbb-frontend6.dc2: ab-frontend6.bbb-prod.my.dc2.net 17 | -------------------------------------------------------------------------------- /integration_test/readme_regexp/compile_result: -------------------------------------------------------------------------------- 1 | Host host1.dev 2 | HostName instance1.my-service-dev.example.com 3 | IdentityFile ~/.ssh/key.pem 4 | User abc 5 | 6 | Host host1.test 7 | HostName instance1.my-service-test.example.com 8 | IdentityFile ~/.ssh/key.pem 9 | User abc 10 | 11 | Host host2.test 12 | HostName instance2.my-service-test.example.com 13 | IdentityFile ~/.ssh/key.pem 14 | User abc 15 | 16 | Host host1.prod 17 | HostName instance1.my-service-prod.example.com 18 | IdentityFile ~/.ssh/key.pem 19 | User abc 20 | 21 | Host host2.prod 22 | HostName instance2.my-service-prod.example.com 23 | IdentityFile ~/.ssh/key.pem 24 | User abc 25 | 26 | Host host3.prod 27 | HostName instance3.my-service-prod.example.com 28 | IdentityFile ~/.ssh/key.pem 29 | User abc 30 | 31 | -------------------------------------------------------------------------------- /integration_test/readme/compile_result: -------------------------------------------------------------------------------- 1 | Host dev.abc1 2 | HostName node1.abc.dev.example.com 3 | IdentityFile ~/.ssh/abc.pem 4 | Port 22 5 | User ubuntu 6 | 7 | Host dev.abc2 8 | HostName node2.abc.dev.example.com 9 | IdentityFile ~/.ssh/abc.pem 10 | Port 22 11 | User ubuntu 12 | 13 | Host test.abc1 14 | HostName node1.abc.test.example.com 15 | IdentityFile ~/.ssh/abc.pem 16 | Port 22 17 | User ubuntu 18 | 19 | Host test.abc2 20 | HostName node2.abc.test.example.com 21 | IdentityFile ~/.ssh/abc.pem 22 | Port 22 23 | User ubuntu 24 | 25 | Host other1 26 | HostName other1.example.com 27 | IdentityFile ~/.ssh/other.pem 28 | Port 22 29 | User lurker 30 | 31 | Host other2 32 | HostName other2.example.com 33 | IdentityFile ~/.ssh/other.pem 34 | Port 22 35 | User lurker 36 | 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | name: Package and Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: '1.25' 20 | 21 | - name: Install dependencies 22 | run: go mod download 23 | 24 | - name: Run package.sh 25 | run: | 26 | chmod +x ./package.sh 27 | ./package.sh ${{ github.ref_name }} 28 | 29 | - name: Create GitHub Release 30 | uses: softprops/action-gh-release@v1 31 | with: 32 | files: dist/* 33 | draft: false 34 | prerelease: false 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /config/scanner.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | // Scanner is used to select files that contain ssh-aliases configs 11 | type Scanner struct{} 12 | 13 | // NewScanner creates new instance of Scanner 14 | func NewScanner() *Scanner { 15 | return &Scanner{} 16 | } 17 | 18 | const hclExtension = ".hcl" 19 | 20 | // ScanDirectory returns an array of file names that contain ssh-aliases configs 21 | func (s *Scanner) ScanDirectory(path string) ([]string, error) { 22 | files, err := os.ReadDir(path) 23 | if err != nil { 24 | return nil, fmt.Errorf("error while scanning `%s`: %s", path, err.Error()) 25 | } 26 | var hcls []string 27 | for _, file := range files { 28 | if !file.IsDir() && strings.HasSuffix(file.Name(), hclExtension) { 29 | hcls = append(hcls, filepath.Join(path, file.Name())) 30 | } 31 | } 32 | return hcls, nil 33 | } 34 | -------------------------------------------------------------------------------- /integration_test/regexp_hosts/compile_result: -------------------------------------------------------------------------------- 1 | Host prod.aaa-frontend1.dc1 2 | HostName ab-frontend1.aaa-prod.my.dc1.com 3 | 4 | Host prod.bbb-frontend1.dc1 5 | HostName ab-frontend1.bbb-prod.my.dc1.com 6 | 7 | Host prod.aaa-frontend2.dc1 8 | HostName ab-frontend2.aaa-prod.my.dc1.com 9 | 10 | Host prod.bbb-frontend2.dc1 11 | HostName ab-frontend2.bbb-prod.my.dc1.com 12 | 13 | Host prod.aaa-frontend3.dc1 14 | HostName ab-frontend3.aaa-prod.my.dc1.com 15 | 16 | Host test.aaa-frontend1.dc1 17 | HostName ab-frontend1.aaa-test.my.dc1.com 18 | 19 | Host test.aaa-frontend2.dc1 20 | HostName ab-frontend2.aaa-test.my.dc1.com 21 | 22 | Host prod.aaa-backend100.dc1 23 | HostName ab-backend100.aaa-prod.my.dc1.com 24 | 25 | Host prod.aaa-backend102.dc1 26 | HostName ab-backend102.aaa-prod.my.dc1.com 27 | 28 | Host prod.bbb-frontend5.dc2 29 | HostName ab-frontend5.bbb-prod.my.dc2.net 30 | 31 | Host prod.bbb-frontend6.dc2 32 | HostName ab-frontend6.bbb-prod.my.dc2.net 33 | 34 | -------------------------------------------------------------------------------- /config/variables.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "fmt" 4 | 5 | func normalizedVariables(sources []rawContextSource) (variablesMap, error) { 6 | variables := variablesMap{} 7 | for _, s := range sources { 8 | for k, v := range s.RawContext.Variables { 9 | for key, variable := range expandVariable(k, v) { 10 | if _, contains := variables[key]; contains { 11 | return nil, fmt.Errorf("error in `%s`: variable redeclaration: `%v`", s.SourceName, key) 12 | } 13 | variables[key] = variable 14 | } 15 | } 16 | } 17 | return variables, nil 18 | } 19 | 20 | func expandVariable(key string, variable interface{}) map[string]string { 21 | expanded := map[string]string{} 22 | if arr, ok := variable.([]map[string]interface{}); ok { 23 | for _, m := range arr { 24 | for k, v := range m { 25 | for ek, ev := range expandVariable(k, v) { 26 | expanded[fmt.Sprintf("%s.%s", key, ek)] = ev 27 | } 28 | } 29 | } 30 | } else { 31 | expanded[key] = fmt.Sprintf("%v", variable) 32 | } 33 | return expanded 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Daniel Krawczyk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ $# -eq 0 ]; then 4 | echo "No release version specified." 5 | exit 1 6 | fi 7 | 8 | VERSION=$1 9 | echo "Packaging ssh-aliases ${VERSION}" 10 | 11 | DIST="dist" 12 | OUT_DIR="${DIST}/out" 13 | OUT_BINARY="${OUT_DIR}/ssh-aliases" 14 | 15 | OS_LIST="darwin linux" 16 | ARCH_LIST="amd64 arm64" 17 | ADD_FILES="LICENSE README.md" 18 | 19 | mkdir -p ${OUT_DIR} 20 | 21 | for FILE in ${ADD_FILES}; do 22 | cp ${FILE} ${OUT_DIR} 23 | done 24 | 25 | for OS in ${OS_LIST}; do 26 | for ARCH in ${ARCH_LIST}; do 27 | echo "Making binary for ${OS}/${ARCH}" 28 | env CGO_ENABLED=0 GOOS=${OS} GOARCH=${ARCH} go build -a -o ${OUT_BINARY} -ldflags "-s -w -X main.Version=${VERSION}" 29 | 30 | if [ ${OS} == "darwin" ]; then 31 | zip -rj "${DIST}/ssh-aliases_${VERSION}_${OS}_${ARCH}.zip" ${OUT_DIR} 32 | else 33 | tar -C ${OUT_DIR} -czf "${DIST}/ssh-aliases_${VERSION}_${OS}_${ARCH}.tar.gz" . 34 | fi 35 | 36 | rm ${OUT_BINARY} 37 | done 38 | done 39 | 40 | for FILE in ${ADD_FILES}; do 41 | rm "${OUT_DIR}/${FILE}" 42 | done 43 | rmdir ${OUT_DIR} 44 | 45 | ls -lh ${DIST} 46 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | APP_NAME := ssh-aliases 2 | APP_VERSION := $(shell cat VERSION) 3 | 4 | PACKAGES := $(shell go list ./...) 5 | BUILD_FOLDER := target 6 | DIST_FOLDER := dist 7 | 8 | GIT_DESC := $(shell git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") 9 | GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD) 10 | 11 | SRC := $(shell find . -type f -name '*.go') 12 | 13 | .PHONY: all version fmt clean test build release lint lint-deps 14 | 15 | # Only use git describe if VERSION file doesn't exist or is empty 16 | ifneq "$(APP_VERSION)" "" 17 | # VERSION file exists and has content, use it 18 | else 19 | APP_VERSION := $(GIT_DESC)-$(GIT_BRANCH) 20 | endif 21 | 22 | all: clean build 23 | 24 | version: 25 | @echo $(APP_VERSION) 26 | 27 | clean: 28 | @go clean -v . 29 | @rm -rf $(BUILD_FOLDER) 30 | @rm -rf $(DIST_FOLDER) 31 | 32 | test: 33 | @go test -cover ./... 34 | 35 | build: 36 | @go build -o $(BUILD_FOLDER)/$(APP_NAME) \ 37 | -ldflags '-s -w -X main.Version=$(APP_VERSION)' 38 | 39 | release: clean lint build 40 | @bash ./package.sh $(APP_VERSION) 41 | 42 | fmt: 43 | @goimports -w $(SRC) 44 | @gofmt -l -s -w $(SRC) 45 | 46 | lint: 47 | @golangci-lint run 48 | 49 | -------------------------------------------------------------------------------- /command/test-fixtures/compile_result: -------------------------------------------------------------------------------- 1 | Host consul1-dc1 2 | HostName consul1.dc1.example.com 3 | IdentityFile some_file.pem 4 | User ubuntu 5 | 6 | Host consul2-dc1 7 | HostName consul2.dc1.example.com 8 | IdentityFile some_file.pem 9 | User ubuntu 10 | 11 | Host consul3-dc1 12 | HostName consul3.dc1.example.com 13 | IdentityFile some_file.pem 14 | User ubuntu 15 | 16 | Host consul1-dc2 17 | HostName consul1.dc2.example.com 18 | IdentityFile some_file.pem 19 | User ubuntu 20 | 21 | Host consul2-dc2 22 | HostName consul2.dc2.example.com 23 | IdentityFile some_file.pem 24 | User ubuntu 25 | 26 | Host consul3-dc2 27 | HostName consul3.dc2.example.com 28 | IdentityFile some_file.pem 29 | User ubuntu 30 | 31 | Host front1 32 | HostName frontend1.example.com 33 | IdentityFile id_rsa.pem 34 | Port 22 35 | 36 | Host front2 37 | HostName frontend2.example.com 38 | IdentityFile id_rsa.pem 39 | Port 22 40 | 41 | Host myservice1 42 | HostName instance1.my-service.example.com 43 | IdentityFile my_service.pem 44 | 45 | Host myservice2 46 | HostName instance2.my-service.example.com 47 | IdentityFile my_service.pem 48 | 49 | -------------------------------------------------------------------------------- /command/confirm.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | ) 11 | 12 | type confirm struct { 13 | reader io.Reader 14 | } 15 | 16 | func newConfirm(reader io.Reader) *confirm { 17 | return &confirm{ 18 | reader: reader, 19 | } 20 | } 21 | 22 | func (c *confirm) requireConfirmationIfFileExists(path string) (bool, error) { 23 | exists, err := c.fileExists(path) 24 | if err != nil { 25 | return false, err 26 | } 27 | if !exists { 28 | return true, nil 29 | } 30 | return c.confirmation(path) 31 | } 32 | func (c *confirm) confirmation(path string) (bool, error) { 33 | r := bufio.NewReader(c.reader) 34 | fmt.Printf("File `%s` already exists. Overwrite? (Y/n)\n", path) 35 | response, err := r.ReadString('\n') 36 | if err != nil { 37 | return false, err 38 | } 39 | return strings.TrimSpace(response) == "Y", nil 40 | } 41 | 42 | func (c *confirm) fileExists(path string) (bool, error) { 43 | if strings.TrimSpace(path) == "" { 44 | return false, errors.New("provided path is empty") 45 | } 46 | info, err := os.Stat(path) 47 | if err != nil { 48 | if os.IsNotExist(err) { 49 | return false, nil 50 | } 51 | return false, err 52 | } 53 | if info.IsDir() { 54 | return true, fmt.Errorf("path `%s` is a directory", path) 55 | } 56 | return true, nil 57 | } 58 | -------------------------------------------------------------------------------- /integration_test/generic_test.go: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/dankraw/ssh-aliases/command" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | var tests = []struct { 14 | dir string 15 | additionalArgs []string 16 | }{ 17 | {"hostname_only", []string{}}, 18 | {"readme", []string{}}, 19 | {"readme_regexp", []string{ 20 | "--hosts-file", filepath.Join("readme_regexp", "hosts.txt"), 21 | }}, 22 | {"regexp_hosts", []string{ 23 | "--hosts-file", filepath.Join("regexp_hosts", "hosts.txt"), 24 | }}, 25 | } 26 | 27 | func runCommandTest(t *testing.T, cmd, expectedResultFile string) { 28 | for _, test := range tests { 29 | t.Run(test.dir, func(t *testing.T) { 30 | t.Parallel() 31 | // given 32 | buffer := new(bytes.Buffer) 33 | 34 | // when 35 | cli, err := command.NewCLI("test-version", buffer) 36 | 37 | // then 38 | assert.NoError(t, err) 39 | 40 | // and 41 | args := append([]string{"ssh-aliases", "--scan", test.dir, cmd}, test.additionalArgs...) 42 | err = cli.ApplyArgs(args) 43 | 44 | // then 45 | assert.NoError(t, err) 46 | output, _ := os.ReadFile(filepath.Join(test.dir, expectedResultFile)) 47 | assert.Equal(t, string(output), buffer.String()) 48 | }) 49 | } 50 | } 51 | 52 | func TestCompileCommandExecute(t *testing.T) { 53 | t.Parallel() 54 | runCommandTest(t, "compile", "compile_result") 55 | } 56 | 57 | func TestListCommandExecute(t *testing.T) { 58 | t.Parallel() 59 | runCommandTest(t, "list", "list_result") 60 | } 61 | -------------------------------------------------------------------------------- /compiler/structs.go: -------------------------------------------------------------------------------- 1 | package compiler 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // ExpandingHostConfig is the input for the ssh-aliases compiler 8 | type ExpandingHostConfig struct { 9 | AliasName string 10 | HostnamePattern string 11 | AliasTemplate string 12 | Config ConfigProperties 13 | } 14 | 15 | // IsRegexpHostDefinition checks if the specified host definition should be compiled 16 | // as a regexp against provided hosts input file 17 | func (e *ExpandingHostConfig) IsRegexpHostDefinition() bool { 18 | return strings.Contains(e.HostnamePattern, "(") 19 | } 20 | 21 | // InputHosts is a list of hosts that can be used by the compiler to process RegexpHostConfig 22 | type InputHosts []string 23 | 24 | // InputContext is the container for all host and configs 25 | type InputContext struct { 26 | Sources []ContextSource 27 | } 28 | 29 | // ContextSource represents a single piece of source that provides host and configs definitions 30 | type ContextSource struct { 31 | SourceName string 32 | Hosts []ExpandingHostConfig 33 | } 34 | 35 | // HostEntity is the outcome of ssh-alises compiler 36 | type HostEntity struct { 37 | Host string 38 | HostName string 39 | Config ConfigProperties 40 | } 41 | 42 | // ConfigProperties is a list of ssh config properties 43 | type ConfigProperties []ConfigProperty 44 | 45 | // ConfigProperty is a key-value container 46 | type ConfigProperty struct { 47 | Key string 48 | Value interface{} 49 | } 50 | 51 | // ByConfigPropertyKey can be used to sort an array of ConfigProperties by their keys 52 | type ByConfigPropertyKey []ConfigProperty 53 | 54 | func (s ByConfigPropertyKey) Len() int { 55 | return len(s) 56 | } 57 | 58 | func (s ByConfigPropertyKey) Less(i, j int) bool { 59 | return s[i].Key < s[j].Key 60 | } 61 | 62 | func (s ByConfigPropertyKey) Swap(i, j int) { 63 | s[i], s[j] = s[j], s[i] 64 | } 65 | -------------------------------------------------------------------------------- /config/reader.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/pkg/errors" 9 | 10 | "github.com/dankraw/ssh-aliases/compiler" 11 | ) 12 | 13 | // Reader is able to read directories and files and return inputs for ssh-aliases compiler 14 | type Reader struct { 15 | decoder *decoder 16 | scanner *Scanner 17 | } 18 | 19 | // NewReader returns new instance of Reader 20 | func NewReader() *Reader { 21 | return &Reader{ 22 | decoder: newDecoder(), 23 | scanner: NewScanner(), 24 | } 25 | } 26 | 27 | // ReadConfigs processes the input directory and returns inputs for ssh-aliases compiler 28 | func (e *Reader) ReadConfigs(dir string) (compiler.InputContext, error) { 29 | files, err := e.scanner.ScanDirectory(dir) 30 | if err != nil { 31 | return compiler.InputContext{}, err 32 | } 33 | var sources = make([]rawContextSource, 0, len(files)) 34 | for _, f := range files { 35 | c, err := e.decodeFile(f) 36 | if err != nil { 37 | return compiler.InputContext{}, errors.Wrap(err, fmt.Sprintf("failed parsing `%s`", f)) 38 | } 39 | if len(c.Hosts) < 1 && len(c.RawConfigs) < 1 && len(c.Variables) < 1 { 40 | continue 41 | } 42 | rawSource := rawContextSource{ 43 | SourceName: f, 44 | RawContext: c, 45 | } 46 | sources = append(sources, rawSource) 47 | } 48 | return compilerInputContext(sources) 49 | } 50 | 51 | func (e *Reader) decodeFile(file string) (rawFileContext, error) { 52 | // Basic path validation 53 | if strings.Contains(file, "..") || strings.HasPrefix(file, "/") { 54 | return rawFileContext{}, fmt.Errorf("invalid file path: %s", file) 55 | } 56 | 57 | // #nosec G304: path is validated above (no absolute paths, no path traversal) 58 | data, err := os.ReadFile(file) 59 | if err != nil { 60 | return rawFileContext{}, err 61 | } 62 | c, err := e.decoder.decode(data) 63 | if err != nil { 64 | return rawFileContext{}, err 65 | } 66 | return c, nil 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/README.md: -------------------------------------------------------------------------------- 1 | # CI/CD Workflows 2 | 3 | This directory contains GitHub Actions workflows for continuous integration and deployment. 4 | 5 | ## Workflows 6 | 7 | ### CI (`ci.yml`) 8 | Runs on every push and pull request to master/main branch. 9 | 10 | **Jobs:** 11 | - **Lint**: Code formatting, imports, and linting checks 12 | - **Test**: Unit tests across multiple Go versions and OS platforms 13 | - **Security**: Security scanning with gosec and govulncheck 14 | - **Build**: Multi-platform builds (Linux, macOS, Windows) 15 | - **Integration**: Integration test suite 16 | - **Dependency Review**: Security review of dependencies (PRs only) 17 | 18 | ### Release (`release.yml`) 19 | Runs when a version tag is pushed (e.g., `v1.0.0`). 20 | 21 | **Features:** 22 | - Automated packaging with `package.sh` 23 | - GitHub release creation 24 | - Binary artifact uploads 25 | 26 | ## Local Development 27 | 28 | ### Prerequisites 29 | ```bash 30 | # Install golangci-lint 31 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 32 | 33 | # Install goimports 34 | go install golang.org/x/tools/cmd/goimports@latest 35 | ``` 36 | 37 | ### Commands 38 | ```bash 39 | # Run all checks locally 40 | make lint 41 | 42 | # Format code 43 | make fmt 44 | 45 | # Run tests 46 | make test 47 | 48 | # Build 49 | make build 50 | ``` 51 | 52 | ## Configuration 53 | 54 | ### golangci-lint (`.golangci.yml`) 55 | Comprehensive linting configuration with: 56 | - Code quality checks 57 | - Security scanning 58 | - Performance analysis 59 | - Style enforcement 60 | 61 | ### Dependabot (`.github/dependabot.yml`) 62 | Automated dependency updates: 63 | - Go modules: Weekly updates 64 | - GitHub Actions: Weekly updates 65 | - Automatic PR creation with reviews 66 | 67 | ## Best Practices 68 | 69 | 1. **Always run `make lint` before committing** 70 | 2. **Ensure tests pass locally before pushing** 71 | 3. **Use conventional commit messages** 72 | 4. **Review Dependabot PRs promptly** 73 | 5. **Tag releases with semantic versioning (v1.0.0)** 74 | -------------------------------------------------------------------------------- /command/list.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "io" 7 | 8 | "github.com/dankraw/ssh-aliases/compiler" 9 | "github.com/dankraw/ssh-aliases/config" 10 | ) 11 | 12 | type listCommand struct { 13 | writer io.Writer 14 | configReader *config.Reader 15 | configScanner *config.Scanner 16 | compiler *compiler.Compiler 17 | } 18 | 19 | func newListCommand(writer io.Writer) *listCommand { 20 | return &listCommand{ 21 | writer: writer, 22 | configReader: config.NewReader(), 23 | configScanner: config.NewScanner(), 24 | compiler: compiler.NewCompiler(), 25 | } 26 | } 27 | 28 | func (e *listCommand) execute(dir string, hosts []string) error { 29 | ctx, err := e.configReader.ReadConfigs(dir) 30 | if err != nil { 31 | return err 32 | } 33 | j := 0 34 | for _, s := range ctx.Sources { 35 | if len(s.Hosts) < 1 { 36 | continue 37 | } 38 | fileDelimiter := "" 39 | if j > 0 { 40 | fileDelimiter = "\n" 41 | } 42 | j++ 43 | _, err = fmt.Fprint(e.writer, fileDelimiter+s.SourceName) 44 | if err != nil { 45 | return err 46 | } 47 | _, err = fmt.Fprintf(e.writer, " (%d):\n", len(s.Hosts)) 48 | if err != nil { 49 | return err 50 | } 51 | for _, h := range s.Hosts { 52 | results, err := e.compileHost(h, hosts) 53 | if err != nil { 54 | return err 55 | } 56 | _, err = fmt.Fprint(e.writer, "\n "+h.AliasName) 57 | if err != nil { 58 | return err 59 | } 60 | _, err = fmt.Fprintf(e.writer, " (%d):\n", len(results)) 61 | if err != nil { 62 | return err 63 | } 64 | for _, r := range results { 65 | if r.HostName != "" { 66 | _, err = fmt.Fprintf(e.writer, " %v: %v\n", r.Host, r.HostName) 67 | if err != nil { 68 | return err 69 | } 70 | } else { 71 | _, err = fmt.Fprintf(e.writer, " %v\n", r.Host) 72 | if err != nil { 73 | return err 74 | } 75 | } 76 | } 77 | } 78 | } 79 | return nil 80 | } 81 | 82 | func (e *listCommand) compileHost(host compiler.ExpandingHostConfig, hosts []string) ([]compiler.HostEntity, error) { 83 | if host.IsRegexpHostDefinition() { 84 | return e.compiler.CompileRegexp(host, hosts) 85 | } 86 | return e.compiler.Compile(host) 87 | } 88 | -------------------------------------------------------------------------------- /command/confirm_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestFileExists(t *testing.T) { 13 | t.Parallel() 14 | 15 | // when 16 | exists, err := newConfirm(os.Stdin).fileExists(filepath.Join(fixtureDir, "list_result")) 17 | 18 | // then 19 | assert.True(t, exists) 20 | assert.NoError(t, err) 21 | } 22 | 23 | func TestFileNotExists(t *testing.T) { 24 | t.Parallel() 25 | 26 | // when 27 | exists, err := newConfirm(os.Stdin).fileExists(filepath.Join(fixtureDir, "not_exists")) 28 | 29 | // then 30 | assert.False(t, exists) 31 | assert.NoError(t, err) 32 | } 33 | 34 | func TestDirExists(t *testing.T) { 35 | t.Parallel() 36 | 37 | // when 38 | exists, err := newConfirm(os.Stdin).fileExists(fixtureDir) 39 | 40 | // then 41 | assert.True(t, exists) 42 | assert.Error(t, err) 43 | assert.Equal(t, fmt.Sprintf("path `%s` is a directory", fixtureDir), err.Error()) 44 | } 45 | 46 | func TestInvalidPath(t *testing.T) { 47 | t.Parallel() 48 | 49 | // when 50 | exists, err := newConfirm(os.Stdin).fileExists("") 51 | 52 | // then 53 | assert.False(t, exists) 54 | assert.Error(t, err) 55 | assert.Equal(t, "provided path is empty", err.Error()) 56 | } 57 | 58 | type TestReader struct { 59 | response string 60 | } 61 | 62 | func NewTestReader(response string) *TestReader { 63 | return &TestReader{ 64 | response: response, 65 | } 66 | } 67 | 68 | func (r *TestReader) Read(p []byte) (n int, err error) { 69 | copy(p, r.response) 70 | return len(r.response), nil 71 | } 72 | 73 | func TestConfirm(t *testing.T) { 74 | t.Parallel() 75 | 76 | // when 77 | reader := NewTestReader("Y\n") 78 | confirmed, err := newConfirm(reader).requireConfirmationIfFileExists(filepath.Join(fixtureDir, "list_result")) 79 | 80 | // then 81 | assert.True(t, confirmed) 82 | assert.NoError(t, err) 83 | } 84 | 85 | func TestGiveUp(t *testing.T) { 86 | t.Parallel() 87 | 88 | // when 89 | reader := NewTestReader("nope\n") 90 | confirmed, err := newConfirm(reader).requireConfirmationIfFileExists(filepath.Join(fixtureDir, "list_result")) 91 | 92 | // then 93 | assert.False(t, confirmed) 94 | assert.NoError(t, err) 95 | } 96 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 2 | github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 8 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 9 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 10 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 14 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 15 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 16 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 17 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 18 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 19 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 20 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 21 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 22 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 23 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 24 | github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= 25 | github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= 26 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 27 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 28 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 29 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 30 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 31 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 32 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 33 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 34 | -------------------------------------------------------------------------------- /config_test/invalid_config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/dankraw/ssh-aliases/config" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | var reader = config.NewReader() 12 | 13 | var testsParentDir = filepath.Join("test_fixtures", "invalid") 14 | 15 | var tests = []struct { 16 | dir string 17 | expectedErrorMsg string 18 | }{ 19 | {"duplicate_alias", "duplicate host `service-a`"}, 20 | {"config_not_found", "error in `test_fixtures/invalid/config_not_found/host_only.hcl`: " + 21 | "error in `wally-host` host definition: no config `wally` found"}, 22 | {"invalid_hcl", "failed parsing `test_fixtures/invalid/invalid_hcl/invalid.hcl`: " + 23 | "At 7:2: object expected closing RBRACE got: EOF"}, 24 | {"variable_redeclaration", "error in `test_fixtures/invalid/variable_redeclaration/example.hcl`: " + 25 | "variable redeclaration: `abc.def`"}, 26 | {"invalid_import_value", "error in `test_fixtures/invalid/invalid_import_value/example.hcl`: " + 27 | "invalid `def_conf` config definition: config import statement has invalid value: `1`"}, 28 | {"alias_and_hostname_not_specified", "error in `test_fixtures/invalid/alias_and_hostname_not_specified/example.hcl`: " + 29 | "invalid `wat` host definition: alias and hostname are both empty or undefined"}, 30 | {"no_hostname_nor_config", "error in `test_fixtures/invalid/no_hostname_nor_config/example.hcl`: " + 31 | "no config nor hostname specified for host `wat`"}, 32 | {"non_existing_variable/in_alias", "error in `test_fixtures/invalid/non_existing_variable/in_alias/example.hcl`: " + 33 | "error in alias of `service-a` host definition: variable `b.c3.d4` not defined"}, 34 | {"non_existing_variable/in_hostname", "error in `test_fixtures/invalid/non_existing_variable/in_hostname/example.hcl`: " + 35 | "error in hostname of `service-a` host definition: variable `b.c3ę.d4.E_F-d` not defined"}, 36 | {"non_existing_variable/in_config", "error in `test_fixtures/invalid/non_existing_variable/in_config/example.hcl`: " + 37 | "error in `service-a` host definition: could not compile config property `user`: variable `b.c3.d4` not defined"}, 38 | {"non_existing_variable/in_external_config", "error in `test_fixtures/invalid/non_existing_variable/in_external_config/example.hcl`: " + 39 | "invalid `ext` config definition: could not compile config property `user`: variable `b.c3.d4` not defined"}, 40 | } 41 | 42 | func TestShouldThrowErrorOnDuplicateAlias(t *testing.T) { 43 | t.Parallel() 44 | 45 | for _, test := range tests { 46 | t.Run(test.dir, func(t *testing.T) { 47 | t.Parallel() 48 | // when 49 | _, err := reader.ReadConfigs(filepath.Join(testsParentDir, test.dir)) 50 | 51 | // then 52 | assert.Error(t, err) 53 | assert.Equal(t, test.expectedErrorMsg, err.Error()) 54 | }) 55 | } 56 | } 57 | 58 | func TestShouldThrowErrorOnCircularImports(t *testing.T) { 59 | t.Parallel() 60 | 61 | // when 62 | _, err := reader.ReadConfigs("./test_fixtures/invalid/circular_imports") 63 | 64 | // then 65 | assert.Error(t, err) 66 | assert.Contains(t, err.Error(), "circular import in configs") 67 | } 68 | -------------------------------------------------------------------------------- /command/compile.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "github.com/dankraw/ssh-aliases/compiler" 10 | "github.com/dankraw/ssh-aliases/config" 11 | ) 12 | 13 | type compileSaveCommand struct { 14 | file string 15 | confirm *confirm 16 | } 17 | 18 | func newCompileSaveCommand(file string) *compileSaveCommand { 19 | return &compileSaveCommand{ 20 | file: file, 21 | confirm: newConfirm(os.Stdin), 22 | } 23 | } 24 | 25 | func (c *compileSaveCommand) execute(dir string, force bool, hosts []string) error { 26 | if !force { 27 | confirmed, err := c.confirm.requireConfirmationIfFileExists(c.file) 28 | if err != nil { 29 | return err 30 | } 31 | if confirmed { 32 | fmt.Printf("Writing changes to %s", c.file) 33 | } else { 34 | fmt.Printf("Exiting without writing changes to %s", c.file) 35 | return nil 36 | } 37 | } 38 | buffer := new(bytes.Buffer) 39 | err := newCompileCommand(buffer).execute(dir, hosts) 40 | if err != nil { 41 | return err 42 | } 43 | return os.WriteFile(c.file, buffer.Bytes(), 0o600) 44 | } 45 | 46 | type compileCommand struct { 47 | indentation int 48 | writer io.Writer 49 | configReader *config.Reader 50 | compiler *compiler.Compiler 51 | validator *compiler.Validator 52 | } 53 | 54 | func newCompileCommand(writer io.Writer) *compileCommand { 55 | return &compileCommand{ 56 | indentation: 4, 57 | writer: writer, 58 | configReader: config.NewReader(), 59 | compiler: compiler.NewCompiler(), 60 | validator: compiler.NewValidator(), 61 | } 62 | } 63 | 64 | func (c *compileCommand) execute(dir string, hosts []string) error { 65 | ctx, err := c.configReader.ReadConfigs(dir) 66 | if err != nil { 67 | return err 68 | } 69 | var allResults []compiler.HostEntity 70 | for _, s := range ctx.Sources { 71 | for _, h := range s.Hosts { 72 | results, err := c.compileHost(h, hosts) 73 | if err != nil { 74 | return err 75 | } 76 | allResults = append(allResults, results...) 77 | } 78 | } 79 | err = c.validator.ValidateResults(allResults) 80 | if err != nil { 81 | return err 82 | } 83 | for _, result := range allResults { 84 | err = c.printHostConfig(result) 85 | if err != nil { 86 | return err 87 | } 88 | } 89 | return nil 90 | } 91 | 92 | func (c *compileCommand) compileHost(host compiler.ExpandingHostConfig, hosts []string) ([]compiler.HostEntity, error) { 93 | if host.IsRegexpHostDefinition() { 94 | result, err := c.compiler.CompileRegexp(host, hosts) 95 | return result, err 96 | } 97 | return c.compiler.Compile(host) 98 | } 99 | 100 | func (c *compileCommand) printHostConfig(cfg compiler.HostEntity) error { 101 | _, err := fmt.Fprintf(c.writer, "Host %v\n", cfg.Host) 102 | if err != nil { 103 | return err 104 | } 105 | if cfg.HostName != "" { 106 | err = c.printHostConfigProperty("HostName", cfg.HostName) 107 | if err != nil { 108 | return err 109 | } 110 | } 111 | for _, e := range cfg.Config { 112 | err = c.printHostConfigProperty(e.Key, e.Value) 113 | if err != nil { 114 | return err 115 | } 116 | } 117 | _, err = fmt.Fprintln(c.writer) 118 | return err 119 | } 120 | 121 | func (c *compileCommand) printHostConfigProperty(keyword string, value interface{}) error { 122 | _, err := fmt.Fprintf(c.writer, " %s %v\n", keyword, value) 123 | return err 124 | } 125 | -------------------------------------------------------------------------------- /config/config_props.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | type configProps map[string]interface{} 10 | 11 | type variablesMap map[string]string 12 | 13 | func interpolatedConfigProps(variables variablesMap, rawConfig []map[string]interface{}) (configProps, error) { 14 | h := configProps{} 15 | for _, x := range rawConfig { 16 | for k, v := range x { 17 | if vStr, ok := v.(string); ok { 18 | interpolated, err := applyVariablesToString(vStr, variables) 19 | if err != nil { 20 | return nil, fmt.Errorf("could not compile config property `%s`: %s", k, err.Error()) 21 | } 22 | h[k] = interpolated 23 | } else { 24 | h[k] = v 25 | } 26 | } 27 | } 28 | return h, nil 29 | } 30 | 31 | var variableRegexp = regexp.MustCompile(`\${([^}]+)}`) 32 | 33 | func applyVariablesToString(str string, vals variablesMap) (string, error) { 34 | match := variableRegexp.FindStringSubmatchIndex(str) 35 | for match != nil { 36 | beginMatch := match[0] 37 | endMatch := match[1] 38 | beginIdx := match[2] 39 | endIdx := match[3] 40 | varName := str[beginIdx:endIdx] 41 | if value, ok := vals[varName]; ok { 42 | str = str[0:beginMatch] + value + str[endMatch:] 43 | match = variableRegexp.FindStringSubmatchIndex(str) 44 | } else { 45 | return "", fmt.Errorf("variable `%s` not defined", varName) 46 | } 47 | } 48 | return str, nil 49 | } 50 | 51 | const extendConfigKey = "_extend" 52 | 53 | func (c configProps) evaluateConfigImports(propsMap map[string]configProps, evaluatedImports *[]string) (configProps, error) { 54 | if value, ok := c[extendConfigKey]; ok { 55 | evaluated := configProps{} 56 | switch importedValue := value.(type) { 57 | case string: 58 | imported, err := importProps(importedValue, propsMap, evaluatedImports) 59 | if err != nil { 60 | return nil, err 61 | } 62 | for k, v := range imported { 63 | evaluated[k] = v 64 | } 65 | case []interface{}: 66 | for _, importedInterface := range importedValue { 67 | if importedStr, ok := importedInterface.(string); ok { 68 | 69 | // each import branch needs a copy of evaluated imports list 70 | evaluatedImportsBranch := make([]string, len(*evaluatedImports)) 71 | copy(evaluatedImportsBranch, *evaluatedImports) 72 | 73 | imported, err := importProps(importedStr, propsMap, &evaluatedImportsBranch) 74 | if err != nil { 75 | return nil, err 76 | } 77 | for k, v := range imported { 78 | evaluated[k] = v 79 | } 80 | } else { 81 | return nil, fmt.Errorf("config import statement has invalid value: `%v`", importedInterface) 82 | } 83 | } 84 | default: 85 | return nil, fmt.Errorf("config import statement has invalid value: `%v`", value) 86 | } 87 | for key, value := range c { 88 | if key != extendConfigKey { 89 | evaluated[key] = value 90 | } 91 | } 92 | return evaluated, nil 93 | } 94 | return c, nil 95 | } 96 | 97 | func importProps(importedStr string, propsMap map[string]configProps, evaluatedImports *[]string) (configProps, error) { 98 | if contains(*evaluatedImports, importedStr) { 99 | return nil, fmt.Errorf("circular import in configs (config imports chain: `%s` -> `%s`)", 100 | strings.Join(*evaluatedImports, " -> "), importedStr) 101 | } 102 | *evaluatedImports = append(*evaluatedImports, importedStr) 103 | if imported, ok := propsMap[importedStr]; ok { 104 | return imported.evaluateConfigImports(propsMap, evaluatedImports) 105 | } 106 | return nil, fmt.Errorf("trying to import `%s`, but such config does not exist", importedStr) 107 | } 108 | 109 | func contains(slice []string, element string) bool { 110 | for _, e := range slice { 111 | if element == e { 112 | return true 113 | } 114 | } 115 | return false 116 | } 117 | -------------------------------------------------------------------------------- /command/cli.go: -------------------------------------------------------------------------------- 1 | // Package command provides CLI command implementations for ssh-aliases 2 | package command 3 | 4 | import ( 5 | "os/user" 6 | 7 | "path/filepath" 8 | 9 | "io" 10 | 11 | "github.com/urfave/cli" 12 | ) 13 | 14 | const sshAliasesDir = ".ssh_aliases" 15 | 16 | // CLI stands for Command Line Interface 17 | // CLI interprets user input and executes commands 18 | type CLI struct { 19 | app *cli.App 20 | } 21 | 22 | // NewCLI creates new CLI instance 23 | // provided version will be printed with --version 24 | // CLI will write output to provided writer 25 | func NewCLI(version string, writer io.Writer) (*CLI, error) { 26 | app, err := configureCLI(version, writer) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return &CLI{ 31 | app: app, 32 | }, nil 33 | } 34 | 35 | func configureCLI(version string, writer io.Writer) (*cli.App, error) { 36 | homeDir, err := homeDir() 37 | if err != nil { 38 | return nil, err 39 | } 40 | var scanDir string 41 | var save bool 42 | var force bool 43 | var file string 44 | var hostsFile string 45 | 46 | app := cli.NewApp() 47 | app.Version = version 48 | app.Name = "ssh-aliases" 49 | app.Usage = "template driven ssh config generation" 50 | app.Flags = []cli.Flag{ 51 | cli.StringFlag{ 52 | Name: "scan, s", 53 | Usage: "input files dir", 54 | Value: filepath.Join(homeDir, sshAliasesDir), 55 | Destination: &scanDir, 56 | }, 57 | } 58 | app.Commands = []cli.Command{{ 59 | Name: "list", 60 | Aliases: []string{"l"}, 61 | Usage: "Prints the list of host definitions", 62 | Flags: []cli.Flag{ 63 | cli.StringFlag{ 64 | Name: "hosts-file", 65 | Usage: "input hosts file for regexp compilation", 66 | Destination: &hostsFile, 67 | }, 68 | }, 69 | Action: func(_ *cli.Context) error { 70 | hosts, err := readHostsFile(hostsFile) 71 | if err != nil { 72 | return cli.NewExitError(err.Error(), 1) 73 | } 74 | err = newListCommand(writer).execute(scanDir, hosts) 75 | if err != nil { 76 | return cli.NewExitError(err.Error(), 1) 77 | } 78 | return nil 79 | }, 80 | }, { 81 | Name: "compile", 82 | Aliases: []string{"c"}, 83 | Usage: "Prints compiled ssh config file or writes it to a file", 84 | Flags: []cli.Flag{ 85 | cli.BoolFlag{ 86 | Name: "save", 87 | Usage: "write compilation output to file instead of printing to stdout", 88 | Destination: &save, 89 | }, 90 | cli.StringFlag{ 91 | Name: "file", 92 | Usage: "destination file path", 93 | Destination: &file, 94 | Value: filepath.Join(homeDir, ".ssh", "config"), 95 | }, 96 | cli.BoolFlag{ 97 | Name: "force", 98 | Usage: "overwrite existing file without confirmation", 99 | Destination: &force, 100 | }, 101 | cli.StringFlag{ 102 | Name: "hosts-file", 103 | Usage: "input hosts file for regexp compilation", 104 | Destination: &hostsFile, 105 | }, 106 | }, 107 | Action: func(_ *cli.Context) error { 108 | var err error 109 | hosts, err := readHostsFile(hostsFile) 110 | if err != nil { 111 | return cli.NewExitError(err.Error(), 1) 112 | } 113 | if save { 114 | err = newCompileSaveCommand(file).execute(scanDir, force, hosts) 115 | } else { 116 | err = newCompileCommand(writer).execute(scanDir, hosts) 117 | } 118 | if err != nil { 119 | return cli.NewExitError(err.Error(), 1) 120 | } 121 | return nil 122 | }, 123 | }} 124 | return app, nil 125 | } 126 | 127 | func homeDir() (string, error) { 128 | usr, err := user.Current() 129 | if err != nil { 130 | return "", err 131 | } 132 | return usr.HomeDir, nil 133 | } 134 | 135 | // ApplyArgs runs the CLI against provided args 136 | func (c *CLI) ApplyArgs(args []string) error { 137 | return c.app.Run(args) 138 | } 139 | -------------------------------------------------------------------------------- /compiler/expander.go: -------------------------------------------------------------------------------- 1 | package compiler 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "sort" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | type expander struct { 12 | rangeRegexp *regexp.Regexp 13 | variationRegexp *regexp.Regexp 14 | hostnameRegexp *regexp.Regexp 15 | } 16 | 17 | func newExpander() *expander { 18 | return &expander{ 19 | rangeRegexp: regexp.MustCompile(`\[(\d+)\.\.(\d+)\]`), 20 | variationRegexp: regexp.MustCompile(`\[([a-zA-Z0-9-|]+(?:\.[a-zA-Z0-9-|]+)*)+\]`), 21 | hostnameRegexp: regexp.MustCompile(`^([a-zA-Z0-9_]|[a-zA-Z0-9_][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])` + 22 | `(\.([a-zA-Z0-9_]|[a-zA-Z0-9_][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_]))*$`), 23 | } 24 | } 25 | 26 | type expandingRange struct { 27 | beginIdx int 28 | endIdx int 29 | values []string 30 | } 31 | 32 | type expandedHostname struct { 33 | Hostname string 34 | Replacements []string 35 | } 36 | 37 | type byIndex []expandingRange 38 | 39 | func (s byIndex) Len() int { 40 | return len(s) 41 | } 42 | 43 | func (s byIndex) Less(i, j int) bool { 44 | return s[i].beginIdx < s[j].beginIdx 45 | } 46 | 47 | func (s byIndex) Swap(i, j int) { 48 | s[i], s[j] = s[j], s[i] 49 | } 50 | 51 | func (e *expander) expand(host string) ([]expandedHostname, error) { 52 | var ranges = make([]expandingRange, 0, len(e.rangeRegexp.FindAllStringSubmatchIndex(host, -1))) 53 | n := 1 54 | for _, r := range e.rangeRegexp.FindAllStringSubmatchIndex(host, -1) { 55 | expRange, err := e.expandingRange(host, r) 56 | if err != nil { 57 | return nil, err 58 | } 59 | ranges = append(ranges, expRange) 60 | n *= len(expRange.values) 61 | } 62 | for _, v := range e.variationRegexp.FindAllStringSubmatchIndex(host, -1) { 63 | split := strings.Split(host[v[2]:v[3]], "|") 64 | ranges = append(ranges, expandingRange{ 65 | beginIdx: v[0], 66 | endIdx: v[1], 67 | values: split, 68 | }) 69 | n *= len(split) 70 | } 71 | if len(ranges) == 0 { 72 | if !e.hostnameRegexp.MatchString(host) { 73 | return nil, fmt.Errorf("produced string `%v` is not a valid Hostname", host) 74 | } 75 | return []expandedHostname{{Hostname: host}}, nil 76 | } 77 | hostnames, err := e.expandedHostnames(n, host, ranges) 78 | if err != nil { 79 | return nil, err 80 | } 81 | return hostnames, nil 82 | } 83 | 84 | func (e *expander) expandingRange(host string, rangeGroup []int) (expandingRange, error) { 85 | begin, err := strconv.Atoi(host[rangeGroup[2]:rangeGroup[3]]) 86 | if err != nil { 87 | return expandingRange{}, err 88 | } 89 | end, err := strconv.Atoi(host[rangeGroup[4]:rangeGroup[5]]) 90 | if err != nil { 91 | return expandingRange{}, err 92 | } 93 | if begin >= end { 94 | return expandingRange{}, fmt.Errorf("invalid range: %v is not smaller than %v", begin, end) 95 | } 96 | values := []string{} 97 | for i := begin; i <= end; i++ { 98 | values = append(values, strconv.Itoa(i)) 99 | } 100 | return expandingRange{ 101 | beginIdx: rangeGroup[0], 102 | endIdx: rangeGroup[1], 103 | values: values, 104 | }, nil 105 | } 106 | 107 | func (e *expander) expandedHostnames(size int, host string, ranges []expandingRange) ([]expandedHostname, error) { 108 | var hostnames []expandedHostname 109 | sort.Sort(byIndex(ranges)) 110 | for i := 0; i < size; i++ { 111 | j := 1 112 | var hostnameReplacements []string 113 | produced := host[0:ranges[0].beginIdx] 114 | for p, r := range ranges { 115 | idx := (i / j) % len(r.values) 116 | value := r.values[idx] 117 | produced += value 118 | j *= len(r.values) 119 | nextIdx := p + 1 120 | if nextIdx < len(ranges) { 121 | produced += host[r.endIdx:ranges[nextIdx].beginIdx] 122 | } else { 123 | produced += host[r.endIdx:] 124 | } 125 | hostnameReplacements = append(hostnameReplacements, value) 126 | } 127 | if !e.hostnameRegexp.MatchString(produced) { 128 | return nil, fmt.Errorf("produced string `%v` is not a valid Hostname", produced) 129 | } 130 | hostnames = append(hostnames, expandedHostname{ 131 | Hostname: produced, 132 | Replacements: hostnameReplacements, 133 | }) 134 | } 135 | return hostnames, nil 136 | } 137 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | lint: 11 | name: Lint and Format Check 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: '1.25' 21 | 22 | - name: Install golangci-lint 23 | uses: golangci/golangci-lint-action@v8 24 | with: 25 | version: v2.4.0 26 | args: --timeout=10m 27 | skip-cache: true 28 | 29 | - name: Install goimports 30 | run: go install golang.org/x/tools/cmd/goimports@latest 31 | 32 | - name: Check formatting 33 | run: | 34 | if [ "$(gofmt -l . | wc -l)" -gt 0 ]; then 35 | echo "Code is not formatted. Please run 'make fmt' and commit changes." 36 | gofmt -l . 37 | exit 1 38 | fi 39 | 40 | - name: Check imports 41 | run: | 42 | if [ "$(goimports -l . | wc -l)" -gt 0 ]; then 43 | echo "Imports are not properly formatted. Please run 'make fmt' and commit changes." 44 | goimports -l . 45 | exit 1 46 | fi 47 | 48 | test: 49 | name: Test 50 | runs-on: ubuntu-latest 51 | strategy: 52 | matrix: 53 | go-version: [ '1.25' ] 54 | os: [ ubuntu-latest, macos-latest, windows-latest ] 55 | steps: 56 | - name: Checkout code 57 | uses: actions/checkout@v4 58 | 59 | - name: Set up Go ${{ matrix.go-version }} 60 | uses: actions/setup-go@v4 61 | with: 62 | go-version: ${{ matrix.go-version }} 63 | 64 | - name: Install dependencies 65 | run: go mod download 66 | 67 | - name: Verify dependencies 68 | run: go mod verify 69 | 70 | - name: Run tests 71 | run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... 72 | 73 | - name: Upload coverage to Codecov 74 | uses: codecov/codecov-action@v4 75 | with: 76 | file: ./coverage.out 77 | flags: unittests 78 | name: codecov-umbrella 79 | fail_ci_if_error: false 80 | 81 | security: 82 | name: Security Scan 83 | runs-on: ubuntu-latest 84 | needs: [lint, test] 85 | steps: 86 | - uses: actions/checkout@v4 87 | 88 | - name: Set up Go 89 | uses: actions/setup-go@v4 90 | with: 91 | go-version: '1.25' 92 | 93 | - name: Install GoSec 94 | run: | 95 | go install github.com/securego/gosec/v2/cmd/gosec@latest 96 | 97 | - name: Run GoSec 98 | run: | 99 | gosec -fmt sarif -out results.sarif ./... 100 | 101 | - name: Upload GoSec SARIF 102 | uses: github/codeql-action/upload-sarif@v3 103 | if: always() 104 | with: 105 | sarif_file: results.sarif 106 | 107 | - name: Run govulncheck 108 | run: | 109 | go install golang.org/x/vuln/cmd/govulncheck@latest 110 | govulncheck ./... 111 | 112 | build: 113 | name: Build 114 | runs-on: ubuntu-latest 115 | needs: [lint, test] 116 | strategy: 117 | matrix: 118 | goos: [linux, darwin, windows] 119 | goarch: [amd64, arm64] 120 | exclude: 121 | - goos: windows 122 | goarch: arm64 123 | steps: 124 | - name: Checkout code 125 | uses: actions/checkout@v4 126 | 127 | - name: Set up Go 128 | uses: actions/setup-go@v4 129 | with: 130 | go-version: '1.25' 131 | 132 | - name: Build for ${{ matrix.goos }}/${{ matrix.goarch }} 133 | env: 134 | GOOS: ${{ matrix.goos }} 135 | GOARCH: ${{ matrix.goarch }} 136 | run: | 137 | go build -v -ldflags="-s -w -X main.Version=$(cat VERSION)" -o "ssh-aliases-${{ matrix.goos }}-${{ matrix.goarch }}" 138 | 139 | - name: Upload build artifacts 140 | uses: actions/upload-artifact@v4 141 | with: 142 | name: ssh-aliases-${{ matrix.goos }}-${{ matrix.goarch }} 143 | path: ssh-aliases-${{ matrix.goos }}-${{ matrix.goarch }} 144 | 145 | dependency-review: 146 | name: Dependency Review 147 | runs-on: ubuntu-latest 148 | if: github.event_name == 'pull_request' 149 | steps: 150 | - name: Checkout code 151 | uses: actions/checkout@v4 152 | 153 | - name: Dependency Review 154 | uses: actions/dependency-review-action@v4 155 | with: 156 | fail-on-severity: moderate 157 | -------------------------------------------------------------------------------- /compiler/compiler.go: -------------------------------------------------------------------------------- 1 | // Package compiler provides the core compilation logic for ssh-aliases 2 | package compiler 3 | 4 | import ( 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | ) 9 | 10 | // Compiler is responsible for transforming ExpandingHostConfigs into an array of HostEntities. 11 | type Compiler struct { 12 | expander *expander 13 | groupsRegexp *regexp.Regexp 14 | } 15 | 16 | // NewCompiler creates an instance of Compiler 17 | func NewCompiler() *Compiler { 18 | return &Compiler{ 19 | expander: newExpander(), 20 | groupsRegexp: regexp.MustCompile(`{#(\d+)}`), 21 | } 22 | } 23 | 24 | type templateReplacement struct { 25 | beginIdx int 26 | endIdx int 27 | replacementIdx int 28 | } 29 | 30 | // Compile converts a single ExpandingHostConfig into list of HostEntities 31 | func (c *Compiler) Compile(input ExpandingHostConfig) ([]HostEntity, error) { 32 | if input.HostnamePattern == "" { 33 | return []HostEntity{{ 34 | Host: input.AliasTemplate, 35 | Config: input.Config, 36 | }}, nil 37 | } 38 | if input.AliasTemplate == "" { 39 | return []HostEntity{{ 40 | Host: input.HostnamePattern, 41 | Config: input.Config, 42 | }}, nil 43 | } 44 | expanded, err := c.expander.expand(input.HostnamePattern) 45 | if err != nil { 46 | return nil, err 47 | } 48 | replacements := c.aliasReplacementGroups(input.AliasTemplate) 49 | var results = make([]HostEntity, 0, len(expanded)) 50 | for _, h := range expanded { 51 | alias, err := c.compileToTargetHost(input.AliasTemplate, replacements, h, input.HostnamePattern) 52 | if err != nil { 53 | return nil, fmt.Errorf("error compiling host `%s`: %s", input.AliasName, err.Error()) 54 | } 55 | results = append(results, HostEntity{ 56 | Host: alias, 57 | HostName: h.Hostname, 58 | Config: input.Config, 59 | }) 60 | } 61 | return results, nil 62 | } 63 | 64 | func (c *Compiler) compileToTargetHost(aliasTemplate string, replacements []templateReplacement, 65 | host expandedHostname, hostnamePattern string) (string, error) { 66 | if len(replacements) == 0 { 67 | return aliasTemplate, nil 68 | } 69 | err := c.validateReplacements(aliasTemplate, hostnamePattern, replacements, host.Replacements) 70 | if err != nil { 71 | return "", err 72 | } 73 | alias := "" 74 | for i, s := range replacements { 75 | if i == 0 { 76 | alias += aliasTemplate[0:s.beginIdx] 77 | } 78 | 79 | alias += host.Replacements[s.replacementIdx] 80 | nextIdx := i + 1 81 | if nextIdx < len(replacements) { 82 | nextSelector := replacements[nextIdx] 83 | alias += aliasTemplate[s.endIdx:nextSelector.beginIdx] 84 | } else { 85 | alias += aliasTemplate[s.endIdx:] 86 | } 87 | } 88 | return alias, nil 89 | } 90 | 91 | func (c *Compiler) validateReplacements(aliasTemplate string, hostnamePattern string, 92 | aliasReplacements []templateReplacement, patternReplacements []string) error { 93 | maxIdxAllowed := len(patternReplacements) 94 | for _, replacement := range aliasReplacements { 95 | replacementIdx := replacement.replacementIdx + 1 96 | if replacementIdx > maxIdxAllowed { 97 | return fmt.Errorf("alias `%s` contains placeholder with index `#%d` being out of bounds, "+ 98 | "`%s` allows `#%d` as the maximum index", 99 | aliasTemplate, replacementIdx, hostnamePattern, maxIdxAllowed) 100 | } 101 | } 102 | return nil 103 | } 104 | 105 | // CompileRegexp compiles regexp ExpandingHostConfig against provided InputHosts 106 | func (c *Compiler) CompileRegexp(input ExpandingHostConfig, hosts InputHosts) ([]HostEntity, error) { 107 | re, err := regexp.Compile(input.HostnamePattern) 108 | if err != nil { 109 | return nil, fmt.Errorf("error compiling hostname pattern of %s: %s", input.AliasName, err.Error()) 110 | } 111 | replacements := c.aliasReplacementGroups(input.AliasTemplate) 112 | var results []HostEntity 113 | for _, host := range hosts { 114 | match := re.FindAllStringSubmatch(host, -1) 115 | for _, matchedHost := range match { 116 | h := expandedHostname{ 117 | Hostname: matchedHost[0], 118 | Replacements: matchedHost[1:], 119 | } 120 | alias, err := c.compileToTargetHost(input.AliasTemplate, replacements, h, input.HostnamePattern) 121 | if err != nil { 122 | return nil, fmt.Errorf("error compiling regexp host `%s`: %s", input.AliasName, err.Error()) 123 | } 124 | results = append(results, HostEntity{ 125 | Host: alias, 126 | HostName: h.Hostname, 127 | Config: input.Config, 128 | }) 129 | } 130 | } 131 | return results, nil 132 | } 133 | 134 | func (c *Compiler) aliasReplacementGroups(aliasTemplate string) []templateReplacement { 135 | templateGroups := c.groupsRegexp.FindAllStringSubmatchIndex(aliasTemplate, -1) 136 | var replacements = make([]templateReplacement, 0, len(templateGroups)) 137 | for _, group := range templateGroups { 138 | hostnameGroupSelect, _ := strconv.Atoi(aliasTemplate[group[2]:group[3]]) 139 | replacements = append(replacements, templateReplacement{group[0], group[1], hostnameGroupSelect - 1}) 140 | } 141 | return replacements 142 | } 143 | -------------------------------------------------------------------------------- /config_test/valid_config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/dankraw/ssh-aliases/compiler" 7 | "github.com/dankraw/ssh-aliases/config" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestShouldReadCompleteConfigFromDir(t *testing.T) { 12 | t.Parallel() 13 | 14 | // given 15 | reader := config.NewReader() 16 | 17 | // when 18 | ctx, err := reader.ReadConfigs("./test_fixtures/valid/basic_with_variables") 19 | 20 | // then 21 | assert.NoError(t, err) 22 | assert.Equal(t, compiler.InputContext{ 23 | Sources: []compiler.ContextSource{ 24 | { 25 | SourceName: "test_fixtures/valid/basic_with_variables/example.hcl", 26 | Hosts: []compiler.ExpandingHostConfig{{ 27 | AliasName: "service-a", 28 | HostnamePattern: "service-a[1..5].my.domain1.example.com", 29 | AliasTemplate: "a{#1}", 30 | Config: compiler.ConfigProperties{ 31 | compiler.ConfigProperty{ 32 | Key: "IdentityFile", 33 | Value: "a_1001_id_secret_rsa.pem", 34 | }, 35 | compiler.ConfigProperty{ 36 | Key: "Port", 37 | Value: 22, 38 | }, 39 | compiler.ConfigProperty{ 40 | Key: "User", 41 | Value: "deployment", 42 | }, 43 | }, 44 | }, { 45 | AliasName: "service-b", 46 | HostnamePattern: "service-b[1..2].example.com", 47 | AliasTemplate: "b{#1}", 48 | Config: compiler.ConfigProperties{ 49 | compiler.ConfigProperty{ 50 | Key: "IdentityFile", 51 | Value: "b_id_1001_rsa.pem", 52 | }, 53 | compiler.ConfigProperty{ 54 | Key: "Port", 55 | Value: 22, 56 | }, 57 | }, 58 | }}, 59 | }, { 60 | SourceName: "test_fixtures/valid/basic_with_variables/variables.hcl", 61 | Hosts: []compiler.ExpandingHostConfig{}, 62 | }, 63 | }, 64 | }, ctx) 65 | } 66 | 67 | func TestShouldReadFilesWithImportedConfigs(t *testing.T) { 68 | t.Parallel() 69 | 70 | // given 71 | reader := config.NewReader() 72 | 73 | // when 74 | ctx, err := reader.ReadConfigs("./test_fixtures/valid/importing_configs") 75 | 76 | // then 77 | assert.NoError(t, err) 78 | assert.Equal(t, compiler.InputContext{ 79 | Sources: []compiler.ContextSource{ 80 | { 81 | SourceName: "test_fixtures/valid/importing_configs/example.hcl", 82 | Hosts: []compiler.ExpandingHostConfig{{ 83 | AliasName: "abc", 84 | HostnamePattern: "servcice-abc.example.com", 85 | AliasTemplate: "abc", 86 | Config: compiler.ConfigProperties{ 87 | compiler.ConfigProperty{ 88 | Key: "Additional", 89 | Value: "extension", 90 | }, 91 | compiler.ConfigProperty{ 92 | Key: "Another", 93 | Value: "one", 94 | }, 95 | compiler.ConfigProperty{ 96 | Key: "X", 97 | Value: "y", 98 | }, 99 | }, 100 | }, { 101 | AliasName: "def", 102 | HostnamePattern: "servcice-def.example.com", 103 | AliasTemplate: "def", 104 | Config: compiler.ConfigProperties{ 105 | compiler.ConfigProperty{ 106 | Key: "Additional", 107 | Value: "extension 2", 108 | }, 109 | compiler.ConfigProperty{ 110 | Key: "Another", 111 | Value: "two", 112 | }, 113 | compiler.ConfigProperty{ 114 | Key: "SomeProp", 115 | Value: 123, 116 | }, 117 | compiler.ConfigProperty{ 118 | Key: "This", 119 | Value: "never happens", 120 | }, 121 | }, 122 | }}, 123 | }, 124 | }, 125 | }, ctx) 126 | } 127 | 128 | func TestShouldReadHostDefinitionsWithoutHostnames(t *testing.T) { 129 | t.Parallel() 130 | 131 | // given 132 | reader := config.NewReader() 133 | 134 | // when 135 | ctx, err := reader.ReadConfigs("./test_fixtures/valid/no_hostname") 136 | 137 | // then 138 | assert.NoError(t, err) 139 | assert.Equal(t, compiler.InputContext{ 140 | Sources: []compiler.ContextSource{ 141 | { 142 | SourceName: "test_fixtures/valid/no_hostname/wildcard.hcl", 143 | Hosts: []compiler.ExpandingHostConfig{{ 144 | AliasName: "all", 145 | AliasTemplate: "*", 146 | Config: compiler.ConfigProperties{ 147 | compiler.ConfigProperty{ 148 | Key: "A", 149 | Value: 1, 150 | }, 151 | }, 152 | }, { 153 | AliasName: "prod", 154 | HostnamePattern: "prod*", 155 | Config: compiler.ConfigProperties{ 156 | compiler.ConfigProperty{ 157 | Key: "A", 158 | Value: 2, 159 | }, 160 | }, 161 | }}, 162 | }, 163 | }, 164 | }, ctx) 165 | } 166 | 167 | func TestShouldReadHostDefinitionsWithoutConfig(t *testing.T) { 168 | t.Parallel() 169 | 170 | // given 171 | reader := config.NewReader() 172 | 173 | // when 174 | ctx, err := reader.ReadConfigs("./test_fixtures/valid/no_config") 175 | 176 | // then 177 | assert.NoError(t, err) 178 | assert.Equal(t, compiler.InputContext{ 179 | Sources: []compiler.ContextSource{ 180 | { 181 | SourceName: "test_fixtures/valid/no_config/example.hcl", 182 | Hosts: []compiler.ExpandingHostConfig{{ 183 | AliasName: "example", 184 | AliasTemplate: "short", 185 | HostnamePattern: "long", 186 | Config: compiler.ConfigProperties{}, 187 | }}, 188 | }, 189 | }, 190 | }, ctx) 191 | } 192 | -------------------------------------------------------------------------------- /config/compiler_input.go: -------------------------------------------------------------------------------- 1 | // Package config provides configuration parsing and compilation input processing 2 | package config 3 | 4 | import ( 5 | "fmt" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/dankraw/ssh-aliases/compiler" 10 | ) 11 | 12 | type rawContextSource struct { 13 | SourceName string 14 | RawContext rawFileContext 15 | } 16 | 17 | func compilerInputContext(sources []rawContextSource) (compiler.InputContext, error) { 18 | err := validateHosts(sources) 19 | if err != nil { 20 | return compiler.InputContext{}, err 21 | } 22 | variables, err := normalizedVariables(sources) 23 | if err != nil { 24 | return compiler.InputContext{}, err 25 | } 26 | namedProps, err := getNamedConfigProps(sources, variables) 27 | if err != nil { 28 | return compiler.InputContext{}, err 29 | } 30 | var ctxSources = make([]compiler.ContextSource, 0, len(sources)) 31 | for _, s := range sources { 32 | expandingHostConfigs, err := expandingHostConfigs(s.RawContext, variables, namedProps) 33 | if err != nil { 34 | return compiler.InputContext{}, fmt.Errorf("error in `%s`: %s", s.SourceName, err.Error()) 35 | } 36 | ctxSources = append(ctxSources, compiler.ContextSource{ 37 | SourceName: s.SourceName, 38 | Hosts: expandingHostConfigs, 39 | }) 40 | } 41 | return compiler.InputContext{ 42 | Sources: ctxSources, 43 | }, nil 44 | } 45 | 46 | func validateHosts(sources []rawContextSource) error { 47 | hosts := make(map[string]struct{}) 48 | var exists struct{} 49 | for _, s := range sources { 50 | for _, h := range s.RawContext.Hosts { 51 | if strings.TrimSpace(h.Alias) == "" && strings.TrimSpace(h.Hostname) == "" { 52 | return fmt.Errorf("error in `%s`: invalid `%s` host definition: alias and hostname are both empty or undefined", 53 | s.SourceName, h.Name) 54 | } 55 | if _, contains := hosts[h.Name]; contains { 56 | return fmt.Errorf("duplicate host `%v`", h.Name) 57 | } 58 | hosts[h.Name] = exists 59 | } 60 | } 61 | return nil 62 | } 63 | 64 | func getNamedConfigProps(sources []rawContextSource, variables variablesMap) (map[string]configProps, error) { 65 | configToSourceMap := map[string]string{} 66 | propsMap := map[string]configProps{} 67 | for _, s := range sources { 68 | for name, r := range s.RawContext.RawConfigs { 69 | configToSourceMap[name] = s.SourceName 70 | interpolated, err := interpolatedConfigProps(variables, r) 71 | if err != nil { 72 | return nil, fmt.Errorf("error in `%s`: invalid `%s` config definition: %s", 73 | configToSourceMap[name], name, err.Error()) 74 | } 75 | propsMap[name] = interpolated 76 | } 77 | } 78 | evaluated := map[string]configProps{} 79 | for name, props := range propsMap { 80 | evaluatedImports := make([]string, 0) 81 | evaluatedConfig, err := props.evaluateConfigImports(propsMap, &evaluatedImports) 82 | if err != nil { 83 | return nil, fmt.Errorf("error in `%s`: invalid `%s` config definition: %s", configToSourceMap[name], name, err.Error()) 84 | } 85 | evaluated[name] = evaluatedConfig 86 | } 87 | return evaluated, nil 88 | } 89 | 90 | func expandingHostConfigs(fileCtx rawFileContext, variables variablesMap, propsMap map[string]configProps) ([]compiler.ExpandingHostConfig, error) { 91 | configsMap := propsMap 92 | inputs := []compiler.ExpandingHostConfig{} 93 | 94 | for _, a := range fileCtx.Hosts { 95 | config := compiler.ConfigProperties{} 96 | 97 | switch v := a.RawConfigOrRef.(type) { 98 | case string: 99 | if named, ok := configsMap[v]; ok { 100 | config = sortedCompilerProperties(named) 101 | } else { 102 | return nil, fmt.Errorf("error in `%s` host definition: no config `%s` found", 103 | a.Name, v) 104 | } 105 | case []map[string]interface{}: 106 | interpolated, err := interpolatedConfigProps(variables, v) 107 | if err != nil { 108 | return nil, fmt.Errorf("error in `%s` host definition: %s", a.Name, err.Error()) 109 | } 110 | evaluatedImports := make([]string, 0) 111 | evaluated, err := interpolated.evaluateConfigImports(configsMap, &evaluatedImports) 112 | if err != nil { 113 | return nil, fmt.Errorf("error in `%s` host definition: %s", a.Name, err.Error()) 114 | } 115 | config = sortedCompilerProperties(evaluated) 116 | case nil: 117 | if strings.TrimSpace(a.Hostname) == "" { 118 | return nil, fmt.Errorf("no config nor hostname specified for host `%v`", a.Name) 119 | } 120 | default: 121 | return nil, fmt.Errorf("invalid config definition for host `%v`", a.Name) 122 | } 123 | 124 | interpolatedHostname, err := applyVariablesToString(a.Hostname, variables) 125 | if err != nil { 126 | return nil, fmt.Errorf("error in hostname of `%s` host definition: %s", a.Name, err.Error()) 127 | } 128 | interpolatedAlias, err := applyVariablesToString(a.Alias, variables) 129 | if err != nil { 130 | return nil, fmt.Errorf("error in alias of `%s` host definition: %s", a.Name, err.Error()) 131 | } 132 | inputs = append(inputs, compiler.ExpandingHostConfig{ 133 | AliasName: a.Name, 134 | HostnamePattern: interpolatedHostname, 135 | AliasTemplate: interpolatedAlias, 136 | Config: config, 137 | }) 138 | } 139 | return inputs, nil 140 | } 141 | 142 | func sortedCompilerProperties(props configProps) compiler.ConfigProperties { 143 | var entries = make([]compiler.ConfigProperty, 0, len(props)) 144 | for k, v := range props { 145 | entries = append(entries, compiler.ConfigProperty{Key: sanitize(k), Value: v}) 146 | } 147 | sort.Sort(compiler.ByConfigPropertyKey(entries)) 148 | return entries 149 | } 150 | -------------------------------------------------------------------------------- /compiler/compiler_test.go: -------------------------------------------------------------------------------- 1 | package compiler 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCompile(t *testing.T) { 10 | t.Parallel() 11 | 12 | // given 13 | sshConfig := ConfigProperties{{"identity_file", "~/.ssh/id_rsa"}} 14 | input := ExpandingHostConfig{ 15 | HostnamePattern: "x-master[1..2].myproj-prod.dc1.net", 16 | AliasTemplate: "host{#1}-dc1", 17 | Config: sshConfig, 18 | } 19 | 20 | // when 21 | results, err := NewCompiler().Compile(input) 22 | 23 | // then 24 | assert.NoError(t, err) 25 | assert.Equal(t, []HostEntity{{ 26 | Host: "host1-dc1", 27 | HostName: "x-master1.myproj-prod.dc1.net", 28 | Config: sshConfig, 29 | }, { 30 | Host: "host2-dc1", 31 | HostName: "x-master2.myproj-prod.dc1.net", 32 | Config: sshConfig, 33 | }}, results) 34 | } 35 | 36 | func TestShouldReplaceAllGroupMatchOccurrences(t *testing.T) { 37 | t.Parallel() 38 | 39 | // given 40 | input := ExpandingHostConfig{ 41 | HostnamePattern: "x-[master1].myproj-prod.dc1.net", 42 | AliasTemplate: "{#1}-{#1}-{#1}", 43 | } 44 | 45 | // when 46 | results, err := NewCompiler().Compile(input) 47 | 48 | // then 49 | assert.NoError(t, err) 50 | assert.Len(t, results, 1) 51 | assert.Equal(t, "master1-master1-master1", results[0].Host) 52 | } 53 | 54 | func TestShouldExpandHostnameWithProvidedRange(t *testing.T) { 55 | t.Parallel() 56 | 57 | // given 58 | input := ExpandingHostConfig{ 59 | HostnamePattern: "x-master[4..6].myproj-prod.dc1.net", 60 | AliasTemplate: "m{#1}", 61 | } 62 | 63 | // when 64 | results, err := NewCompiler().Compile(input) 65 | 66 | // then 67 | assert.NoError(t, err) 68 | assert.Equal(t, []HostEntity{{ 69 | Host: "m4", 70 | HostName: "x-master4.myproj-prod.dc1.net", 71 | }, { 72 | Host: "m5", 73 | HostName: "x-master5.myproj-prod.dc1.net", 74 | }, { 75 | Host: "m6", 76 | HostName: "x-master6.myproj-prod.dc1.net", 77 | }}, results) 78 | } 79 | 80 | func TestShouldAllowUnderscoreInHostname(t *testing.T) { 81 | t.Parallel() 82 | 83 | // given 84 | input := ExpandingHostConfig{ 85 | HostnamePattern: "_node1._dc.exa_mple.com_", 86 | AliasTemplate: "node1", 87 | } 88 | 89 | // when 90 | results, err := NewCompiler().Compile(input) 91 | 92 | // then 93 | assert.NoError(t, err) 94 | assert.Len(t, results, 1) 95 | assert.Equal(t, "node1", results[0].Host) 96 | } 97 | 98 | func TestShouldAllowStaticAliasDefinitions(t *testing.T) { 99 | t.Parallel() 100 | 101 | // given 102 | input := ExpandingHostConfig{ 103 | HostnamePattern: "x-master1.myproj-prod.dc1.net", 104 | AliasTemplate: "master1", 105 | } 106 | 107 | // when 108 | results, err := NewCompiler().Compile(input) 109 | 110 | // then 111 | assert.NoError(t, err) 112 | assert.Len(t, results, 1) 113 | assert.Equal(t, "master1", results[0].Host) 114 | } 115 | 116 | func TestShouldAllowHostDefinitionsWithoutHostnamesWhenAliasProvided(t *testing.T) { 117 | t.Parallel() 118 | 119 | // given 120 | input := ExpandingHostConfig{ 121 | AliasTemplate: "*", 122 | } 123 | 124 | // when 125 | results, err := NewCompiler().Compile(input) 126 | 127 | // then 128 | assert.NoError(t, err) 129 | assert.Len(t, results, 1) 130 | assert.Equal(t, "*", results[0].Host) 131 | assert.Equal(t, "", results[0].HostName) 132 | } 133 | 134 | func TestShouldAllowHostDefinitionsWithoutAliasWhenHostnameProvided(t *testing.T) { 135 | t.Parallel() 136 | 137 | // given 138 | input := ExpandingHostConfig{ 139 | HostnamePattern: "*", 140 | } 141 | 142 | // when 143 | results, err := NewCompiler().Compile(input) 144 | 145 | // then 146 | assert.NoError(t, err) 147 | assert.Len(t, results, 1) 148 | assert.Equal(t, "*", results[0].Host) 149 | assert.Equal(t, "", results[0].HostName) 150 | } 151 | 152 | func TestRegexpCompile(t *testing.T) { 153 | t.Parallel() 154 | 155 | // given 156 | sshConfig := ConfigProperties{{"identity_file", "~/.ssh/id_rsa"}} 157 | input := ExpandingHostConfig{ 158 | HostnamePattern: "x-master(\\d+)\\.myproj-([a-z]+)\\.dc1\\.net", 159 | AliasTemplate: "{#2}.host{#1}.dc1", 160 | Config: sshConfig, 161 | } 162 | hosts := InputHosts{ 163 | "y-master1.myproj-prod.dc2", 164 | "x-master2.myproj-prod-dc1.net", 165 | "x-master3.myproj-prod.dc1.net", 166 | "x-master4.other-prod.dc1.net", 167 | "x-master5.myproj-test.dc1.net", 168 | "x-master6.myproj-test.dc1.net x-master7.myproj-dev.dc1.net ddd", 169 | } 170 | 171 | // when 172 | results, err := NewCompiler().CompileRegexp(input, hosts) 173 | 174 | // then 175 | assert.NoError(t, err) 176 | assert.Equal(t, []HostEntity{{ 177 | Host: "prod.host3.dc1", 178 | HostName: "x-master3.myproj-prod.dc1.net", 179 | Config: sshConfig, 180 | }, { 181 | Host: "test.host5.dc1", 182 | HostName: "x-master5.myproj-test.dc1.net", 183 | Config: sshConfig, 184 | }, { 185 | Host: "test.host6.dc1", 186 | HostName: "x-master6.myproj-test.dc1.net", 187 | Config: sshConfig, 188 | }, { 189 | Host: "dev.host7.dc1", 190 | HostName: "x-master7.myproj-dev.dc1.net", 191 | Config: sshConfig, 192 | }}, results) 193 | } 194 | 195 | func TestRegexpCompileWithInvalidAlias(t *testing.T) { 196 | t.Parallel() 197 | 198 | // given 199 | input := ExpandingHostConfig{ 200 | AliasName: "InvalidAlias", 201 | HostnamePattern: "instance(\\d+)\\.example\\.com", 202 | AliasTemplate: "host{#1}.{#2}.dc1", 203 | } 204 | hosts := InputHosts{ 205 | "instance1.example.com", 206 | "instance2.example.com", 207 | } 208 | 209 | // when 210 | results, err := NewCompiler().CompileRegexp(input, hosts) 211 | 212 | // then 213 | assert.Nil(t, results) 214 | assert.Error(t, err) 215 | assert.Equal(t, "error compiling regexp host `InvalidAlias`: alias `host{#1}.{#2}.dc1` contains "+ 216 | "placeholder with index `#2` being out of bounds, `instance(\\d+)\\.example\\.com` allows `#1` as the maximum index", 217 | err.Error()) 218 | } 219 | 220 | func TestCompileWithInvalidAlias(t *testing.T) { 221 | t.Parallel() 222 | 223 | // given 224 | input := ExpandingHostConfig{ 225 | AliasName: "InvalidAlias", 226 | HostnamePattern: "instance[1..2].example.com", 227 | AliasTemplate: "host{#1}.{#2}.dc1", 228 | } 229 | 230 | // when 231 | results, err := NewCompiler().Compile(input) 232 | 233 | // then 234 | assert.Nil(t, results) 235 | assert.Error(t, err) 236 | assert.Equal(t, "error compiling host `InvalidAlias`: alias `host{#1}.{#2}.dc1` contains "+ 237 | "placeholder with index `#2` being out of bounds, `instance[1..2].example.com` allows `#1` as the maximum index", 238 | err.Error()) 239 | } 240 | -------------------------------------------------------------------------------- /compiler/expander_test.go: -------------------------------------------------------------------------------- 1 | package compiler 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestShouldNotExpandForNoOperators(t *testing.T) { 10 | t.Parallel() 11 | // given 12 | hostname := "x-master1.myproj-prod.dc1.net" 13 | 14 | // when 15 | hostnames, err := newExpander().expand(hostname) 16 | 17 | // then 18 | assert.NoError(t, err) 19 | assert.Equal(t, []expandedHostname{{ 20 | Hostname: "x-master1.myproj-prod.dc1.net", 21 | }}, hostnames) 22 | } 23 | 24 | func TestShouldExpandHostnameWithRange(t *testing.T) { 25 | t.Parallel() 26 | 27 | // given 28 | hostname := "x-master[1..3].myproj-prod.dc1.net" 29 | 30 | // when 31 | hostnames, err := newExpander().expand(hostname) 32 | 33 | // then 34 | assert.NoError(t, err) 35 | assert.Equal(t, []expandedHostname{{ 36 | Hostname: "x-master1.myproj-prod.dc1.net", 37 | Replacements: []string{"1"}, 38 | }, { 39 | Hostname: "x-master2.myproj-prod.dc1.net", 40 | Replacements: []string{"2"}, 41 | }, { 42 | Hostname: "x-master3.myproj-prod.dc1.net", 43 | Replacements: []string{"3"}, 44 | }}, hostnames) 45 | } 46 | 47 | func TestShouldReturnErrorOnInvalidRange(t *testing.T) { 48 | t.Parallel() 49 | 50 | // given 51 | hostname := "x-master[120..13].myproj-prod.dc1.net" 52 | 53 | // when 54 | _, err := newExpander().expand(hostname) 55 | 56 | // then 57 | assert.Error(t, err) 58 | assert.Equal(t, "invalid range: 120 is not smaller than 13", err.Error()) 59 | } 60 | 61 | func TestShouldReturnErrorWhenProducedStringIsNotAValidHostname(t *testing.T) { 62 | t.Parallel() 63 | 64 | // given 65 | hostname := "--ddd--[1..2]..." 66 | 67 | // when 68 | _, err := newExpander().expand(hostname) 69 | 70 | // then 71 | assert.Error(t, err) 72 | assert.Equal(t, "produced string `--ddd--1...` is not a valid Hostname", err.Error()) 73 | } 74 | 75 | func TestShouldReturnErrorWhenNoRangeWasFoundAndProducedStringIsNotAValidHostname(t *testing.T) { 76 | t.Parallel() 77 | 78 | // given 79 | hostname := "--ddd--[1..2..." 80 | 81 | // when 82 | _, err := newExpander().expand(hostname) 83 | 84 | // then 85 | assert.Error(t, err) 86 | assert.Equal(t, "produced string `--ddd--[1..2...` is not a valid Hostname", err.Error()) 87 | } 88 | 89 | func TestShouldExpandHostnameWithMultipleRanges(t *testing.T) { 90 | t.Parallel() 91 | // given 92 | hostname := "x-master[1..3].myproj-prod.dc[1..2].net" 93 | 94 | // when 95 | hostnames, err := newExpander().expand(hostname) 96 | 97 | // then 98 | assert.NoError(t, err) 99 | assert.Equal(t, []expandedHostname{{ 100 | Hostname: "x-master1.myproj-prod.dc1.net", 101 | Replacements: []string{"1", "1"}, 102 | }, { 103 | Hostname: "x-master2.myproj-prod.dc1.net", 104 | Replacements: []string{"2", "1"}, 105 | }, { 106 | Hostname: "x-master3.myproj-prod.dc1.net", 107 | Replacements: []string{"3", "1"}, 108 | }, { 109 | Hostname: "x-master1.myproj-prod.dc2.net", 110 | Replacements: []string{"1", "2"}, 111 | }, { 112 | Hostname: "x-master2.myproj-prod.dc2.net", 113 | Replacements: []string{"2", "2"}, 114 | }, { 115 | Hostname: "x-master3.myproj-prod.dc2.net", 116 | Replacements: []string{"3", "2"}, 117 | }}, hostnames) 118 | } 119 | 120 | func TestShouldReturnErrorForSingleVariation(t *testing.T) { 121 | t.Parallel() 122 | 123 | // given 124 | hostname := "server-[prod].myproj.net" 125 | 126 | // when 127 | hostnames, err := newExpander().expand(hostname) 128 | 129 | // then 130 | assert.NoError(t, err) 131 | assert.Equal(t, []expandedHostname{{ 132 | Hostname: "server-prod.myproj.net", 133 | Replacements: []string{"prod"}, 134 | }}, hostnames) 135 | } 136 | 137 | func TestShouldAllowVariationOnBeginningOfHostname(t *testing.T) { 138 | t.Parallel() 139 | 140 | // given 141 | hostname := "[a|b]-server.myproj.net" 142 | 143 | // when 144 | hostnames, err := newExpander().expand(hostname) 145 | 146 | // then 147 | assert.NoError(t, err) 148 | assert.Equal(t, []expandedHostname{{ 149 | Hostname: "a-server.myproj.net", 150 | Replacements: []string{"a"}, 151 | }, { 152 | Hostname: "b-server.myproj.net", 153 | Replacements: []string{"b"}, 154 | }}, hostnames) 155 | } 156 | 157 | func TestShouldAllowVariationOnEndingOfHostname(t *testing.T) { 158 | t.Parallel() 159 | 160 | // given 161 | hostname := "server.myproj.[net|com]" 162 | 163 | // when 164 | hostnames, err := newExpander().expand(hostname) 165 | 166 | // then 167 | assert.NoError(t, err) 168 | assert.Equal(t, []expandedHostname{{ 169 | Hostname: "server.myproj.net", 170 | Replacements: []string{"net"}, 171 | }, { 172 | Hostname: "server.myproj.com", 173 | Replacements: []string{"com"}, 174 | }}, hostnames) 175 | } 176 | 177 | func TestShouldExpandHostnameWithVariations(t *testing.T) { 178 | t.Parallel() 179 | 180 | // given 181 | hostname := "server-[prod|test|dev].myproj.net" 182 | 183 | // when 184 | hostnames, err := newExpander().expand(hostname) 185 | 186 | // then 187 | assert.NoError(t, err) 188 | assert.Equal(t, []expandedHostname{{ 189 | Hostname: "server-prod.myproj.net", 190 | Replacements: []string{"prod"}, 191 | }, { 192 | Hostname: "server-test.myproj.net", 193 | Replacements: []string{"test"}, 194 | }, { 195 | Hostname: "server-dev.myproj.net", 196 | Replacements: []string{"dev"}, 197 | }}, hostnames) 198 | } 199 | 200 | func TestShouldExpandHostnameWithRangesAndVariations(t *testing.T) { 201 | t.Parallel() 202 | 203 | // given 204 | hostname := "host[1..2].server-[prod|test].myproj[5..6].net" 205 | 206 | // when 207 | hostnames, err := newExpander().expand(hostname) 208 | 209 | // then 210 | assert.NoError(t, err) 211 | assert.Equal(t, []expandedHostname{{ 212 | Hostname: "host1.server-prod.myproj5.net", 213 | Replacements: []string{"1", "prod", "5"}, 214 | }, { 215 | Hostname: "host2.server-prod.myproj5.net", 216 | Replacements: []string{"2", "prod", "5"}, 217 | }, { 218 | Hostname: "host1.server-test.myproj5.net", 219 | Replacements: []string{"1", "test", "5"}, 220 | }, { 221 | Hostname: "host2.server-test.myproj5.net", 222 | Replacements: []string{"2", "test", "5"}, 223 | }, { 224 | Hostname: "host1.server-prod.myproj6.net", 225 | Replacements: []string{"1", "prod", "6"}, 226 | }, { 227 | Hostname: "host2.server-prod.myproj6.net", 228 | Replacements: []string{"2", "prod", "6"}, 229 | }, { 230 | Hostname: "host1.server-test.myproj6.net", 231 | Replacements: []string{"1", "test", "6"}, 232 | }, { 233 | Hostname: "host2.server-test.myproj6.net", 234 | Replacements: []string{"2", "test", "6"}, 235 | }}, hostnames) 236 | } 237 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssh-aliases 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/dankraw/ssh-aliases)](https://goreportcard.com/report/github.com/dankraw/ssh-aliases) 3 | 4 | `ssh-aliases` is a command line tool that brings ease to living with `~/.ssh/config`. 5 | 6 | In short, `ssh-aliases`: 7 | * combines multiple [human friendly config files](#configuration-files) into a single `ssh` config file 8 | * is able to generate a list of hosts out of a single entry by using [expanding expressions](#expanding-expressions), 9 | like `instance[1..3].example.com` or `[master|slave].example.com` 10 | * is able to generate aliases for provided (as an input file) lists of hosts [using regular expressions matching](#using-regular-expressions-to-match-existing-hostnames) 11 | * creates aliases for hosts by compiling [templates](#alias-templates) 12 | * allows multiple hosts reuse the same `ssh` configuration 13 | * is a single binary file 14 | 15 | ## Table of contents 16 | 17 | * [Installation](#installation) 18 | * [Configuration files](#configuration-files) 19 | * [Scanned directories](#scanned-directories) 20 | * [Components](#components) 21 | * [Host definitions](#host-definitions) 22 | * [Config properties](#config-properties) 23 | * [Extending configurations](#extending-configurations) 24 | * [Variables](#variables) 25 | * [Expanding hosts](#expanding-hosts) 26 | * [Expanding expressions](#expanding-expressions) 27 | * [Alias templates](#alias-templates) 28 | * [Using regular expressions to match existing hostnames](#using-regular-expressions-to-match-existing-hostnames) 29 | * [Tips and tricks](#tips-and-tricks) 30 | * [Usage (CLI)](#usage-cli) 31 | * [`compile`](#compile---generating-configuration-for-ssh) - generating configuration for `ssh` 32 | * [`list`](#list---listing-aliases-definitions) - listing aliases definitions 33 | * [License](#license) 34 | 35 | 36 | ## Installation 37 | 38 | ### Binary distribution 39 | 40 | Binary releases for Linux and MacOS can be found on [GitHub releases page](https://github.com/dankraw/ssh-aliases/releases). 41 | 42 | ### Homebrew tap 43 | 44 | MacOS users can install `ssh-aliases` easily using [Homebrew](https://brew.sh): 45 | 46 | ``` console 47 | brew tap dankraw/ssh-aliases 48 | brew install ssh-aliases 49 | ``` 50 | 51 | ### Source code 52 | 53 | If you are familiar with Go: 54 | 55 | ``` console 56 | go get github.com/dankraw/ssh-aliases 57 | ``` 58 | 59 | There is a `Makefile`, so you can use it as well: 60 | 61 | ``` console 62 | make test # run tests 63 | make fmt # format code 64 | make lint # run linters 65 | make # build binary to ./target/ssh-aliases 66 | ``` 67 | 68 | ## Configuration files 69 | 70 | `ssh-aliases` is a tool for `~/.ssh/config` file generation. 71 | The input for `ssh-aliases` are [HCL](https://github.com/hashicorp/hcl) config files. 72 | HCL was designed to be written and modified by humans. 73 | In some way it is similar to JSON, but is more expressive and concise at the same time, 74 | allows using comments, etc. 75 | 76 | Looking at examples below will be enough to become familiar with HCL format. 77 | 78 | ### Scanned directories 79 | 80 | `ssh-aliases` allows you to divide your `ssh` configuration into multiple files depending on your needs. 81 | When running `ssh-aliases` you point it to a directory (by default it's `~/.ssh_aliases`) 82 | containing any number of HCL config files. The directory will be scanned for files with `.hcl` extension. 83 | Keep in mind it does not scan recursively - child directories won't be considered. 84 | 85 | ### Components 86 | 87 | A single config file may contain any number of components defined in it. 88 | Currently there are three types of components: 89 | * [Host definitions](#host-definitions) 90 | * [Config properties](#config-properties) 91 | * [Variables](#variables) 92 | 93 | #### Host definitions 94 | 95 | A host definition consists of a `host` keyword and it's globally unique (among all scanned files) name. 96 | Each `host` should contain following attributes: 97 | * `hostname` - is a target hostname, possibly containing [expanding expressions](#expanding-hosts) 98 | or it may be a [regular expression matching selected group of hosts](#using-regular-expressions-to-match-existing-hostnames) 99 | * `alias` - is an alias template for the destination hostname 100 | * `config` - an embedded [config properties](#config-properties) definition, or a name (a `string`) that points 101 | to existing properties definition in the same or any other configuration file 102 | 103 | An example host definition looks like: 104 | 105 | ``` hcl 106 | host "my-service" { 107 | hostname = "instance[1..2].my-service.example.com", 108 | alias = "myservice{#1}" 109 | config = { 110 | user = "ubuntu" 111 | identity_file = "~/.ssh/my_service.pem" 112 | port = 22 113 | // etc. 114 | } 115 | } 116 | ``` 117 | 118 | or (when pointing an external config named `my-service-config`) 119 | 120 | ``` hcl 121 | host "my-service" { 122 | hostname = "instance[1..2].my-service.example.com", 123 | alias = "myservice{#1}" 124 | config = "my-service-config" 125 | } 126 | ``` 127 | 128 | or (when using regular expression to match selected hosts from user input) 129 | 130 | ```hcl 131 | host "my-service" { 132 | hostname = "instance\\-(\\d+)\\.my\\-service\\-([a-z]+)\\..+dc1.+", 133 | alias = "{#2}.myservice{#1}.dc1" 134 | config = "my-service-config" 135 | } 136 | ``` 137 | 138 | #### Config properties 139 | 140 | A config properties definition consists of a `config` keyword and it's globally unique (among all scanned files) name. 141 | It's body is a list of properties that map to `ssh_config` keywords and their values. 142 | A complete list of ssh config keywords can be seen [here](https://linux.die.net/man/5/ssh_config) 143 | or listed via `man ssh_config` in your terminal. 144 | 145 | Each property may contain an underscore (`_`) in its keyword for clarity, 146 | all underscores are removed during config compilation, first character and all letters that follow underscores are capitalized - 147 | this makes generated file easier to read. For example, `identity_file` will become `IdentityFile` in the destination config file. 148 | By design `ssh_config` keywords are case insensitive, and their values are case sensitive. 149 | 150 | Provided properties are not validated by `ssh-aliases`, so it should work even if you have a custom built `ssh` command. 151 | 152 | An example config properties definition may look like: 153 | 154 | ``` hcl 155 | config "some-config" { 156 | user = "ubuntu" 157 | identity_file = "id_rsa.pem" 158 | port = 22 159 | // etc. 160 | } 161 | ``` 162 | 163 | ##### Extending configurations 164 | 165 | A special property `_extend` can be used in order to include properties from other configurations. 166 | Top level properties override lower level properties. 167 | 168 | ```hcl 169 | config "top-level" { 170 | user = "eden" 171 | _extend = "lower-level" 172 | # port = 2222 (will be included from below configuration) 173 | # ... 174 | } 175 | 176 | config "lower-level" { 177 | user = "helix" 178 | port = 2222 179 | # etc. 180 | } 181 | ``` 182 | 183 | A single configuration may extend multiple configurations, in this case an array of configuration names should be provided as the `_extend` property. 184 | 185 | ```hcl 186 | config "top-level" { 187 | user = "eden" 188 | _extend = ["lower-level1", "lower-level2"] 189 | # configurations are included from left to right (and overridden in this order) 190 | # port = 4444 191 | } 192 | 193 | config "lower-level1" { 194 | user = "helix" 195 | port = 2222 196 | # etc. 197 | } 198 | 199 | config "lower-level2" { 200 | user = "torus" 201 | port = 4444 202 | # etc. 203 | } 204 | ``` 205 | 206 | #### Variables 207 | 208 | Variables are declared in object blocks marked with `var` keyword. There may be many `var` blocks distributed along multiple files, but variable names have global scope, so each one can be declared only once. 209 | 210 | Example variables block may look like: 211 | 212 | ```hcl 213 | var { 214 | dc1 = "my.domain1.example.com" 215 | dc2 = "some.other.domain2.net" 216 | keys { 217 | service_a = "/path/to/a_key.pem" 218 | service_b = "/path/to/b_key.pem" 219 | } 220 | nodes { 221 | service_a = 5 222 | } 223 | users { 224 | a = "eden" 225 | b = "helix" 226 | } 227 | } 228 | ``` 229 | 230 | Variables can be nested, their lookup names are flattened during processing with `.` separator. 231 | In this example we have defined following variables: `dc1`, `dc2`, `keys.service_a`, `keys.service_b`, `nodes.service_a`, `users.a`, `users.b`. 232 | 233 | Variables can be used in: 234 | * Aliases 235 | * Hostnames 236 | * Config property values 237 | 238 | String interpolation with variables is done by using a `${name}` placeholder, for example: 239 | 240 | ```hcl 241 | host "service-a" { 242 | hostname = "instance[1..${nodes.service_a}].${dc1}", 243 | alias = "myservice{#1}" 244 | config = { 245 | user = "${users.a}" 246 | identity_file = "${keys.service_a}" 247 | } 248 | } 249 | ``` 250 | 251 | ### Expanding hosts 252 | 253 | One of the most important features of `ssh-aliases` is *hosts expansion*. 254 | It's a mechanism of generating multiple `Host ...` entries in the destination `ssh_config` out of a single [host definition](#host-definitions). 255 | It is done by using *expanding expressions* in hostnames and compiling host aliases from templates. 256 | 257 | #### Expanding expressions 258 | 259 | There are two types of *expanding expressions* available: 260 | * ranges 261 | * sets 262 | 263 | A **range** is represented as `[m..n]`, where `m` and `n` are positive integers that `m < n`. 264 | For example, a hostname `instance[1..3].example.com` will be expanded to: 265 | 266 | ``` console 267 | instance1.example.com 268 | instance2.example.com 269 | instance3.example.com 270 | ``` 271 | 272 | A **set** is represented as `[a]`, `[a|b]`, `[a|b|c]` and so on, 273 | where `a`, `b`, `c`... are some arbitrary strings of characters allowed in hostnames. 274 | For example, a hostname `server.[dev|test|prod].example.com` will be expanded to: 275 | 276 | ``` console 277 | server.dev.example.com 278 | server.test.example.com 279 | server.prod.example.com 280 | ``` 281 | 282 | Of course ranges and sets can be used together multiple times each. 283 | A final result will be a [cartesian product](https://en.wikipedia.org/wiki/Cartesian_product) of all expanding expressions provided. 284 | For example, a hostname `server[1..2].[dev|test].example.com` would be expanded to: 285 | 286 | ``` console 287 | server1.dev.example.com 288 | server1.test.example.com 289 | server2.dev.example.com 290 | server2.test.example.com 291 | ``` 292 | 293 | #### Alias templates 294 | 295 | Each generated `Host ...` entry needs to have an alias that is provided in [host definition](#host-definitions). 296 | If hostnames are [expanded](#expanding-expressions) it is required to provide **placeholders** 297 | for all expanding expressions used. Otherwise `ssh-aliases` would generate 298 | the same alias for multiple hostnames, and that simply makes no sense. 299 | 300 | An expanding expression placeholder is represented as `{#n}`, where `n=1,2...k`, 301 | `n` points the `n-th` expression used in hostname (sequence from left to right) 302 | and so `k` is the number of expanding expressions used in total. 303 | 304 | For example, `{#1}` points the first expression used in hostname, `{#2}` points the second, and so on. 305 | 306 | If we look at the hostname example from above section `server[1..2].[dev|test].example.com`, we have two expressions used: 307 | 1. `[1..2]` 308 | 2. `[dev|test]` 309 | 310 | We can declare an alias template like `{#2}.server{#1}`, 311 | which would compile following aliases for the generated hostnames: 312 | 313 | ``` console 314 | dev.server1 315 | test.server1 316 | dev.server2 317 | test.server2 318 | ``` 319 | 320 | ### Using regular expressions to match existing hostnames 321 | 322 | Alternatively, instead of defining [expanding expressions](#expanding-expressions) by hand, user can provide 323 | a list of existing hosts as an input file and define regular expressions that match selected groups of hosts, 324 | `ssh-aliases` will generate aliases and hook proper configurations to matched hosts. 325 | 326 | For example, the user is able to fetch from some kind of cloud service API or 327 | [an asset management system](https://github.com/allegro/ralph) 328 | (or will just `cat ~/.ssh/known_hosts | cut -f 1 -d ',' | cut -f 1 -d ' '`) a list of nodes that is allowed to log into: 329 | 330 | ``` 331 | instance1.my-service-evo.example.com 332 | instance1.my-service-dev.example.com 333 | instance1.my-service-test.example.com 334 | instance2.my-service-test.example.com 335 | instance1.my-service-prod.example.com 336 | instance2.my-service-prod.example.com 337 | instance3.my-service-prod.example.com 338 | ``` 339 | 340 | If there are patterns existing between some of these hosts, a regular expression can be defined to match them, 341 | in this case `"instance(\\d+)\\.my\\-service\\-(dev|prod|test)\\..+"`. 342 | Note there are two groups being captured `(\\d+)` for instance number and `(dev|prod|test)` for host environment 343 | (we want to omit the experimental `evo` environment). 344 | In order to place the captured group value into the alias use the `{#n}` placeholder, 345 | same as for [expanding expressions](#alias-templates). 346 | 347 | [Host definitions](#host-definitions) with a hostname containing at least a single group capturing statement 348 | (starting with a "`(`" parenthesis) are considered as regexp hosts type, and [expanding expressions](#expanding-expressions) 349 | are not applied to them. Of course, both types of hosts can be mixed in the same `ssh-aliases` configuration (file or directory). 350 | 351 | ```hcl 352 | host "dc1-services" { 353 | hostname = "instance(\\d+)\\.my\\-service\\-(dev|prod|test)\\..+" 354 | alias = "host{#1}.{#2}" 355 | config { 356 | user = "abc" 357 | identity_file = "~/.ssh/key.pem" 358 | } 359 | } 360 | ``` 361 | 362 | [Compiling the aliases](#compile---generating-configuration-for-ssh) with `--hosts-file /path/to/hosts_file.txt` option will generate: 363 | 364 | ``` 365 | Host host1.dev 366 | HostName instance1.my-service-dev.example.com 367 | IdentityFile ~/.ssh/key.pem 368 | User abc 369 | 370 | Host host1.test 371 | HostName instance1.my-service-test.example.com 372 | IdentityFile ~/.ssh/key.pem 373 | User abc 374 | 375 | Host host2.test 376 | HostName instance2.my-service-test.example.com 377 | IdentityFile ~/.ssh/key.pem 378 | User abc 379 | 380 | Host host1.prod 381 | HostName instance1.my-service-prod.example.com 382 | IdentityFile ~/.ssh/key.pem 383 | User abc 384 | 385 | Host host2.prod 386 | HostName instance2.my-service-prod.example.com 387 | IdentityFile ~/.ssh/key.pem 388 | User abc 389 | 390 | Host host3.prod 391 | HostName instance3.my-service-prod.example.com 392 | IdentityFile ~/.ssh/key.pem 393 | User abc 394 | ``` 395 | 396 | [Printing the list of aliases](#list---listing-aliases-definitions) accordingly would print: 397 | 398 | ``` 399 | config.hcl (1): 400 | 401 | dc1-services (6): 402 | host1.dev: instance1.my-service-dev.example.com 403 | host1.test: instance1.my-service-test.example.com 404 | host2.test: instance2.my-service-test.example.com 405 | host1.prod: instance1.my-service-prod.example.com 406 | host2.prod: instance2.my-service-prod.example.com 407 | host3.prod: instance3.my-service-prod.example.com 408 | 409 | ``` 410 | 411 | 412 | ### Tips and tricks 413 | 414 | * Generated `ssh_config` configuration can be used not only with `ssh` command, but with other OpenSSH client commands, like `scp` and `sftp` 415 | * Multiple alias templates may be provided for the same host definition, for example: 416 | 417 | ```hcl 418 | host "my-service" { 419 | hostname = "instance[1..2].myservice.example.com", 420 | alias = "myservice{#1} ms{#1}" # separated with space 421 | config = "some-config" 422 | } 423 | ``` 424 | 425 | * `ssh_config` (v7.2+) ships with `Include` directive ([see docs](https://man.openbsd.org/ssh_config.5#Include)) that can be used to include other files. This can be useful for mixing `ssh-aliases` generated configs with pure `ssh_config` files: 426 | 427 | ```ssh_config 428 | Include path/to/ssh-aliases/generated/ssh_config 429 | 430 | # below some legacy ssh config that one day may be migrated to ssh-aliases 431 | Host myservice 432 | HostName myservice.example.com 433 | User myself 434 | # ... 435 | ``` 436 | 437 | * `config` properties are optional when `alias` is provided: 438 | 439 | ```hcl 440 | host "example" { 441 | hostname = "my.service[1..2].example.com" 442 | alias = "myservice{#1}" 443 | } 444 | ``` 445 | 446 | * `alias` (or `hostname` when `alias` is provided) is optional when `config` properties are provided. This can be useful for creating wildcard (`*`) configurations that match any host: 447 | 448 | ```hcl 449 | host "all-hosts" { 450 | hostname = "*" # or alias = "*" 451 | config { 452 | # ... 453 | } 454 | } 455 | ``` 456 | 457 | ## Usage (CLI) 458 | 459 | Run `ssh-aliases --help` to see available options of the `ssh-aliases` command line interface (CLI). 460 | 461 | In general, there are only two commands available: 462 | * `compile` - prints (or saves to a file) compiled `ssh` config 463 | * `list` - prints preview of generates aliases and hostnames 464 | 465 | Both commands share the same global option: `--scan` or `-s` which should point to the directory 466 | containing [input config files](#configuration-files). 467 | If omitted, `ssh-aliases` will look for `~/.ssh_aliases` directory. 468 | This option should be passed *before* the selected command name. 469 | 470 | ### `compile` - generating configuration for `ssh` 471 | 472 | `compile` is the primary command in `ssh-aliases` - it combines together all input config files 473 | and compiles configuration for `ssh`. 474 | 475 | Options for `compile` 476 | 477 | * `--hosts-file` - input hosts file for regexp compilation (each hostname in new line) 478 | * `--save` - adding this option makes `ssh-aliases` save the output to the file instead of printing to `stdout`, 479 | asks for confirmation if the file exists (unless `--force` is used) and overwrites its contents if accepted 480 | * `--file ` - when using `--save` it tells where should the file be saved, defaults to `~/.ssh/config` 481 | * `--force` - when using `--save` it will overwrite possibly existing file without confirmation 482 | * `--help` - shows command usage 483 | 484 | Example command run with all options provided: 485 | 486 | ```console 487 | $ ssh-aliases --scan ~/my_custom_dir compile --save --file ~/.ssh/ssh_aliases_config --force 488 | ``` 489 | 490 | Now, let's suppose we have `./examples/readme` directory that contains 3 files: 491 | 492 | ```hcl 493 | # ./example/readme/example_service_1.hcl 494 | host "abc" { 495 | hostname = "node[1..2].abc.[dev|test].example.com" 496 | alias = "{#2}.abc{#1}" 497 | config = "abc-config" 498 | } 499 | 500 | config "abc-config" { 501 | user = "ubuntu" 502 | identity_file = "${keys.abc}" 503 | port = 22 504 | } 505 | ``` 506 | 507 | ```hcl 508 | # ./example/readme/example_service_2.hcl 509 | host "other" { 510 | hostname = "other[1..2].example.com" 511 | alias = "other{#1}" 512 | config { 513 | user = "lurker" 514 | identity_file = "${keys.other}" 515 | port = 22 516 | } 517 | } 518 | ``` 519 | 520 | ```hcl 521 | # ./example/readme/variables.hcl 522 | var { 523 | keys { 524 | abc = "~/.ssh/abc.pem" 525 | other = "~/.ssh/other.pem" 526 | } 527 | } 528 | ``` 529 | 530 | Let's run `compile` command for that directory: 531 | 532 | ``` console 533 | $ ssh-aliases --scan ./examples/readme compile 534 | ``` 535 | 536 | `ssh-aliases` will print: 537 | 538 | ``` console 539 | Host dev.abc1 540 | HostName node1.abc.dev.example.com 541 | IdentityFile ~/.ssh/abc.pem 542 | Port 22 543 | User ubuntu 544 | 545 | Host dev.abc2 546 | HostName node2.abc.dev.example.com 547 | IdentityFile ~/.ssh/abc.pem 548 | Port 22 549 | User ubuntu 550 | 551 | Host test.abc1 552 | HostName node1.abc.test.example.com 553 | IdentityFile ~/.ssh/abc.pem 554 | Port 22 555 | User ubuntu 556 | 557 | Host test.abc2 558 | HostName node2.abc.test.example.com 559 | IdentityFile ~/.ssh/abc.pem 560 | Port 22 561 | User ubuntu 562 | 563 | Host other1 564 | HostName other1.example.com 565 | IdentityFile ~/.ssh/other.pem 566 | Port 22 567 | User lurker 568 | 569 | Host other2 570 | HostName other2.example.com 571 | IdentityFile ~/.ssh/other.pem 572 | Port 22 573 | User lurker 574 | ``` 575 | 576 | ### `list` - listing aliases definitions 577 | 578 | `list` command should be used to check correctness of declared [hostname patterns](#host-definitions) 579 | and [alias templates](#alias-templates). 580 | It will print a concise list of compiled results, yet omitting linked [config properties](#config-properties). 581 | 582 | Options for `list` 583 | * `--hosts-file` - input hosts file for regexp compilation (each hostname in new line) 584 | 585 | For example, let's run `list` for `./examples/readme` directory from previous paragraph: 586 | 587 | ``` console 588 | $ ssh-aliases --scan ./examples/readme list 589 | ``` 590 | The printed result will be: 591 | 592 | ``` console 593 | readme/example_service_1.hcl (1): 594 | 595 | abc (4): 596 | dev.abc1: node1.abc.dev.example.com 597 | dev.abc2: node2.abc.dev.example.com 598 | test.abc1: node1.abc.test.example.com 599 | test.abc2: node2.abc.test.example.com 600 | 601 | readme/example_service_2.hcl (1): 602 | 603 | other (2): 604 | other1: other1.example.com 605 | other2: other2.example.com 606 | ``` 607 | 608 | ## License 609 | 610 | `ssh-aliases` is published under [MIT License](LICENSE). 611 | --------------------------------------------------------------------------------