blob: a285ffd5fedf7c29a49f11f22abd5b0367f9a432 [file] [log] [blame]
giolekva8aa73e82022-07-09 11:34:39 +04001package soft
2
3import (
Giorgi Lekveishvilia1e77902023-11-06 14:48:27 +04004 "errors"
giolekva8aa73e82022-07-09 11:34:39 +04005 "fmt"
giolekva8aa73e82022-07-09 11:34:39 +04006 "log"
7 "net"
8 "os"
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +04009 "regexp"
gio266c04f2024-07-03 14:18:45 +040010 "slices"
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +040011 "strings"
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +040012 "time"
giolekva8aa73e82022-07-09 11:34:39 +040013
Davit Tabidzea5ea5092024-08-01 15:28:09 +040014 "golang.org/x/crypto/ssh"
15
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +040016 "github.com/cenkalti/backoff/v4"
giolekva8aa73e82022-07-09 11:34:39 +040017 "github.com/go-git/go-billy/v5/memfs"
18 "github.com/go-git/go-git/v5"
Giorgi Lekveishvilia1e77902023-11-06 14:48:27 +040019 "github.com/go-git/go-git/v5/plumbing/transport"
giolekva8aa73e82022-07-09 11:34:39 +040020 gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
21 "github.com/go-git/go-git/v5/storage/memory"
giolekva8aa73e82022-07-09 11:34:39 +040022)
23
gio0eaf2712024-04-14 13:08:46 +040024var ErrorAlreadyExists = errors.New("already exists")
25
gioe72b54f2024-04-22 10:44:41 +040026type Client interface {
27 Address() string
28 Signer() ssh.Signer
29 GetPublicKeys() ([]string, error)
gio33059762024-07-05 13:19:07 +040030 RepoExists(name string) (bool, error)
gioe72b54f2024-04-22 10:44:41 +040031 GetRepo(name string) (RepoIO, error)
giocafd4e62024-07-31 10:53:40 +040032 GetAllRepos() ([]string, error)
gioe72b54f2024-04-22 10:44:41 +040033 GetRepoAddress(name string) string
34 AddRepository(name string) error
gio33059762024-07-05 13:19:07 +040035 UserExists(name string) (bool, error)
36 FindUser(pubKey string) (string, error)
Davit Tabidzea5ea5092024-08-01 15:28:09 +040037 GetAllUsers() ([]string, error)
gioe72b54f2024-04-22 10:44:41 +040038 AddUser(name, pubKey string) error
Davit Tabidzea5ea5092024-08-01 15:28:09 +040039 RemoveUser(user string) error
gioe72b54f2024-04-22 10:44:41 +040040 AddPublicKey(user string, pubKey string) error
41 RemovePublicKey(user string, pubKey string) error
Davit Tabidzea5ea5092024-08-01 15:28:09 +040042 GetUserPublicKeys(user string) ([]string, error)
gioe72b54f2024-04-22 10:44:41 +040043 MakeUserAdmin(name string) error
44 AddReadWriteCollaborator(repo, user string) error
45 AddReadOnlyCollaborator(repo, user string) error
gio0eaf2712024-04-14 13:08:46 +040046 AddWebhook(repo, url string, opts ...string) error
gioe72b54f2024-04-22 10:44:41 +040047}
48
49type realClient struct {
50 addr string
51 signer ssh.Signer
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +040052 log *log.Logger
53 pemBytes []byte
giolekva8aa73e82022-07-09 11:34:39 +040054}
55
gioe72b54f2024-04-22 10:44:41 +040056func NewClient(addr string, clientPrivateKey []byte, log *log.Logger) (Client, error) {
giolekva8aa73e82022-07-09 11:34:39 +040057 signer, err := ssh.ParsePrivateKey(clientPrivateKey)
58 if err != nil {
59 return nil, err
60 }
Giorgi Lekveishvili23ef7f82023-05-26 11:57:48 +040061 log.SetPrefix("SOFT-SERVE: ")
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +040062 log.Printf("Created signer")
gioe72b54f2024-04-22 10:44:41 +040063 return &realClient{
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +040064 addr,
giolekva8aa73e82022-07-09 11:34:39 +040065 signer,
66 log,
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +040067 clientPrivateKey,
giolekva8aa73e82022-07-09 11:34:39 +040068 }, nil
69}
70
gioe72b54f2024-04-22 10:44:41 +040071type ClientGetter interface {
72 Get(addr string, clientPrivateKey []byte, log *log.Logger) (Client, error)
73}
74
75type RealClientGetter struct{}
76
77func (c RealClientGetter) Get(addr string, clientPrivateKey []byte, log *log.Logger) (Client, error) {
78 var client Client
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +040079 err := backoff.RetryNotify(func() error {
80 var err error
81 client, err = NewClient(addr, clientPrivateKey, log)
82 if err != nil {
83 return err
84 }
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +040085 if _, err := client.GetPublicKeys(); err != nil {
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +040086 return err
87 }
88 return nil
89 }, backoff.NewConstantBackOff(5*time.Second), func(err error, _ time.Duration) {
90 log.Printf("Failed to create client: %s\n", err.Error())
91 })
92 return client, err
93}
94
gioe72b54f2024-04-22 10:44:41 +040095func (ss *realClient) Address() string {
96 return ss.addr
97}
98
99func (ss *realClient) Signer() ssh.Signer {
100 return ss.signer
101}
102
Davit Tabidzea5ea5092024-08-01 15:28:09 +0400103func (ss *realClient) GetAllUsers() ([]string, error) {
104 log.Printf("Getting all users")
105 out, err := ss.RunCommand("user", "list")
106 if err != nil {
107 return nil, err
108 }
109 return strings.Fields(out), nil
110}
111
gio33059762024-07-05 13:19:07 +0400112func (ss *realClient) UserExists(name string) (bool, error) {
gio11617ac2024-07-15 16:09:04 +0400113 log.Printf("Checking user exists %s", name)
gio33059762024-07-05 13:19:07 +0400114 out, err := ss.RunCommand("user", "list")
115 if err != nil {
116 return false, err
117 }
118 return slices.Contains(strings.Fields(out), name), nil
119}
120
121func (ss *realClient) FindUser(pubKey string) (string, error) {
122 log.Printf("Finding user %s", pubKey)
123 pk := strings.Join(strings.Fields(pubKey)[:2], " ")
124 out, err := ss.RunCommand("user", "list")
125 if err != nil {
126 return "", err
127 }
128 for _, user := range strings.Fields(out) {
129 info, err := ss.RunCommand("user", "info", user)
130 if err != nil {
131 return "", err
132 }
133 if strings.Contains(info, pk) {
134 return user, nil
135 }
136 }
137 return "", nil
138}
139
gioe72b54f2024-04-22 10:44:41 +0400140func (ss *realClient) AddUser(name, pubKey string) error {
Giorgi Lekveishvili23ef7f82023-05-26 11:57:48 +0400141 log.Printf("Adding user %s", name)
gio266c04f2024-07-03 14:18:45 +0400142 if _, err := ss.RunCommand("user", "create", name); err != nil {
giolekva8aa73e82022-07-09 11:34:39 +0400143 return err
144 }
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +0400145 return ss.AddPublicKey(name, pubKey)
giolekva8aa73e82022-07-09 11:34:39 +0400146}
147
Davit Tabidzea5ea5092024-08-01 15:28:09 +0400148func (ss *realClient) RemoveUser(user string) error {
149 log.Printf("Removing user: %s\n", user)
150 _, err := ss.RunCommand("user", "delete", user)
151 return err
152}
153
gioe72b54f2024-04-22 10:44:41 +0400154func (ss *realClient) MakeUserAdmin(name string) error {
Giorgi Lekveishvili23ef7f82023-05-26 11:57:48 +0400155 log.Printf("Making user %s admin", name)
gio266c04f2024-07-03 14:18:45 +0400156 _, err := ss.RunCommand("user", "set-admin", name, "true")
157 return err
Giorgi Lekveishvili23ef7f82023-05-26 11:57:48 +0400158}
159
gioe72b54f2024-04-22 10:44:41 +0400160func (ss *realClient) AddPublicKey(user string, pubKey string) error {
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +0400161 log.Printf("Adding public key: %s %s\n", user, pubKey)
gio266c04f2024-07-03 14:18:45 +0400162 _, err := ss.RunCommand("user", "add-pubkey", user, pubKey)
163 return err
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +0400164}
165
gioe72b54f2024-04-22 10:44:41 +0400166func (ss *realClient) RemovePublicKey(user string, pubKey string) error {
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400167 log.Printf("Removing public key: %s %s\n", user, pubKey)
gio266c04f2024-07-03 14:18:45 +0400168 _, err := ss.RunCommand("user", "remove-pubkey", user, pubKey)
169 return err
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +0400170}
171
Davit Tabidzea5ea5092024-08-01 15:28:09 +0400172func (ss *realClient) GetUserPublicKeys(user string) ([]string, error) {
173 log.Printf("Getting public keys for user: %s\n", user)
174 out, err := ss.RunCommand("user", "info", user)
175 if err != nil {
176 return nil, err
177 }
178 return extractPublicKeys(out), nil
179}
180
181func extractPublicKeys(userInfo string) []string {
182 var keys []string
183 lines := strings.Split(userInfo, "\n")
184 gettingKeys := false
185 for _, line := range lines {
186 if strings.HasPrefix(line, "Public keys:") {
187 gettingKeys = true
188 continue
189 }
190 if gettingKeys {
191 keys = append(keys, strings.TrimSpace(line))
192 }
193 }
194 return keys
195}
196
gio266c04f2024-07-03 14:18:45 +0400197func (ss *realClient) RunCommand(args ...string) (string, error) {
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +0400198 cmd := strings.Join(args, " ")
Giorgi Lekveishvili23ef7f82023-05-26 11:57:48 +0400199 log.Printf("Running command %s", cmd)
gioe72b54f2024-04-22 10:44:41 +0400200 client, err := ssh.Dial("tcp", ss.addr, ss.sshClientConfig())
giolekva8aa73e82022-07-09 11:34:39 +0400201 if err != nil {
gio266c04f2024-07-03 14:18:45 +0400202 return "", err
giolekva8aa73e82022-07-09 11:34:39 +0400203 }
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400204 defer client.Close()
giolekva8aa73e82022-07-09 11:34:39 +0400205 session, err := client.NewSession()
206 if err != nil {
gio266c04f2024-07-03 14:18:45 +0400207 return "", err
giolekva8aa73e82022-07-09 11:34:39 +0400208 }
209 defer session.Close()
gio266c04f2024-07-03 14:18:45 +0400210 var buf strings.Builder
211 session.Stdout = &buf
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +0400212 session.Stderr = os.Stderr
gio266c04f2024-07-03 14:18:45 +0400213 err = session.Run(cmd)
214 return buf.String(), err
215}
216
gio33059762024-07-05 13:19:07 +0400217func (ss *realClient) RepoExists(name string) (bool, error) {
gio266c04f2024-07-03 14:18:45 +0400218 out, err := ss.RunCommand("repo", "list")
219 if err != nil {
220 return false, err
221 }
222 return slices.Contains(strings.Fields(out), name), nil
giolekva8aa73e82022-07-09 11:34:39 +0400223}
224
gioe72b54f2024-04-22 10:44:41 +0400225func (ss *realClient) AddRepository(name string) error {
giolekva8aa73e82022-07-09 11:34:39 +0400226 log.Printf("Adding repository %s", name)
gio33059762024-07-05 13:19:07 +0400227 if ok, err := ss.RepoExists(name); ok {
gio0eaf2712024-04-14 13:08:46 +0400228 return ErrorAlreadyExists
gio266c04f2024-07-03 14:18:45 +0400229 } else if err != nil {
230 return err
gio0eaf2712024-04-14 13:08:46 +0400231 }
gio266c04f2024-07-03 14:18:45 +0400232 _, err := ss.RunCommand("repo", "create", name)
233 return err
Giorgi Lekveishvili23ef7f82023-05-26 11:57:48 +0400234}
235
gioe72b54f2024-04-22 10:44:41 +0400236func (ss *realClient) AddReadWriteCollaborator(repo, user string) error {
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400237 log.Printf("Adding read-write collaborator %s %s", repo, user)
gio266c04f2024-07-03 14:18:45 +0400238 _, err := ss.RunCommand("repo", "collab", "add", repo, user, "read-write")
239 return err
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400240}
241
gioe72b54f2024-04-22 10:44:41 +0400242func (ss *realClient) AddReadOnlyCollaborator(repo, user string) error {
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400243 log.Printf("Adding read-only collaborator %s %s", repo, user)
gio266c04f2024-07-03 14:18:45 +0400244 _, err := ss.RunCommand("repo", "collab", "add", repo, user, "read-only")
245 return err
giolekva8aa73e82022-07-09 11:34:39 +0400246}
247
gio0eaf2712024-04-14 13:08:46 +0400248func (ss *realClient) AddWebhook(repo, url string, opts ...string) error {
249 log.Printf("Adding webhook %s %s", repo, url)
gio266c04f2024-07-03 14:18:45 +0400250 _, err := ss.RunCommand(append(
gio0eaf2712024-04-14 13:08:46 +0400251 []string{"repo", "webhook", "create", repo, url},
252 opts...,
253 )...)
gio266c04f2024-07-03 14:18:45 +0400254 return err
gio0eaf2712024-04-14 13:08:46 +0400255}
256
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400257type Repository struct {
258 *git.Repository
259 Addr RepositoryAddress
260}
261
gioe72b54f2024-04-22 10:44:41 +0400262func (ss *realClient) GetRepo(name string) (RepoIO, error) {
263 r, err := CloneRepository(RepositoryAddress{ss.addr, name}, ss.signer)
264 if err != nil {
265 return nil, err
266 }
267 return NewRepoIO(r, ss.signer)
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400268}
269
giocafd4e62024-07-31 10:53:40 +0400270func (ss *realClient) GetAllRepos() ([]string, error) {
271 log.Printf("Getting all repos")
272 out, err := ss.RunCommand("repo", "list")
273 if err != nil {
274 return nil, err
275 }
276 return strings.Fields(out), nil
277}
278
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400279type RepositoryAddress struct {
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400280 Addr string
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400281 Name string
282}
283
284func ParseRepositoryAddress(addr string) (RepositoryAddress, error) {
Giorgi Lekveishvilia1e77902023-11-06 14:48:27 +0400285 items := regexp.MustCompile(`ssh://(.*)/(.*)`).FindStringSubmatch(addr)
286 if len(items) != 3 {
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400287 return RepositoryAddress{}, fmt.Errorf("Invalid address")
288 }
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400289 return RepositoryAddress{items[1], items[2]}, nil
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400290}
291
292func (r RepositoryAddress) FullAddress() string {
293 return fmt.Sprintf("ssh://%s/%s", r.Addr, r.Name)
294}
295
Giorgi Lekveishvili57dffb32023-08-07 15:45:43 +0400296func CloneRepository(addr RepositoryAddress, signer ssh.Signer) (*Repository, error) {
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +0400297 fmt.Printf("Cloning repository: %s %s\n", addr.Addr, addr.Name)
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400298 c, err := git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{
299 URL: addr.FullAddress(),
300 Auth: &gitssh.PublicKeys{
301 User: "git",
302 Signer: signer,
303 HostKeyCallbackHelper: gitssh.HostKeyCallbackHelper{
304 HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
305 // TODO(giolekva): verify server public key
306 fmt.Printf("--- %+v\n", ssh.MarshalAuthorizedKey(key))
307 return nil
308 },
309 },
310 },
Giorgi Lekveishvili87be4ae2023-06-11 23:41:09 +0400311 RemoteName: "origin",
Giorgi Lekveishvili3550b432023-06-09 19:37:51 +0400312 ReferenceName: "refs/heads/master",
giof5ffedb2024-06-19 14:14:43 +0400313 SingleBranch: true,
Giorgi Lekveishvili3550b432023-06-09 19:37:51 +0400314 Depth: 1,
315 InsecureSkipTLS: true,
316 Progress: os.Stdout,
317 })
Giorgi Lekveishvilia1e77902023-11-06 14:48:27 +0400318 if err != nil && !errors.Is(err, transport.ErrEmptyRemoteRepository) {
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400319 return nil, err
320 }
giof5ffedb2024-06-19 14:14:43 +0400321 wt, err := c.Worktree()
322 if err != nil {
323 return nil, err
324 }
325 sb, err := wt.Submodules()
326 if err != nil {
327 return nil, err
328 }
329 if err := sb.Init(); err != nil {
330 return nil, err
331 }
332 if err := sb.Update(&git.SubmoduleUpdateOptions{
333 Depth: 1,
334 }); err != nil {
335 return nil, err
336 }
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400337 return &Repository{
338 Repository: c,
339 Addr: addr,
340 }, nil
Giorgi Lekveishvili3550b432023-06-09 19:37:51 +0400341}
342
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400343// TODO(giolekva): dead code
gioe72b54f2024-04-22 10:44:41 +0400344func (ss *realClient) authSSH() gitssh.AuthMethod {
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +0400345 a, err := gitssh.NewPublicKeys("git", ss.pemBytes, "")
giolekva8aa73e82022-07-09 11:34:39 +0400346 if err != nil {
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +0400347 panic(err)
giolekva8aa73e82022-07-09 11:34:39 +0400348 }
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +0400349 a.HostKeyCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error {
350 // TODO(giolekva): verify server public key
351 ss.log.Printf("--- %+v\n", ssh.MarshalAuthorizedKey(key))
352 return nil
giolekva8aa73e82022-07-09 11:34:39 +0400353 }
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +0400354 return a
355 // return &gitssh.PublicKeys{
356 // User: "git",
357 // Signer: ss.Signer,
358 // HostKeyCallbackHelper: gitssh.HostKeyCallbackHelper{
359 // HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
360 // // TODO(giolekva): verify server public key
361 // ss.log.Printf("--- %+v\n", ssh.MarshalAuthorizedKey(key))
362 // return nil
363 // },
364 // },
365 // }
giolekva8aa73e82022-07-09 11:34:39 +0400366}
367
gioe72b54f2024-04-22 10:44:41 +0400368func (ss *realClient) authGit() *gitssh.PublicKeys {
giolekva8aa73e82022-07-09 11:34:39 +0400369 return &gitssh.PublicKeys{
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +0400370 User: "git",
gioe72b54f2024-04-22 10:44:41 +0400371 Signer: ss.signer,
giolekva8aa73e82022-07-09 11:34:39 +0400372 HostKeyCallbackHelper: gitssh.HostKeyCallbackHelper{
373 HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
374 // TODO(giolekva): verify server public key
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +0400375 ss.log.Printf("--- %+v\n", ssh.MarshalAuthorizedKey(key))
giolekva8aa73e82022-07-09 11:34:39 +0400376 return nil
377 },
378 },
379 }
380}
381
gioe72b54f2024-04-22 10:44:41 +0400382func (ss *realClient) GetPublicKeys() ([]string, error) {
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +0400383 var ret []string
Giorgi Lekveishvili23ef7f82023-05-26 11:57:48 +0400384 config := &ssh.ClientConfig{
385 Auth: []ssh.AuthMethod{
gioe72b54f2024-04-22 10:44:41 +0400386 ssh.PublicKeys(ss.signer),
Giorgi Lekveishvili23ef7f82023-05-26 11:57:48 +0400387 },
388 HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +0400389 ret = append(ret, string(ssh.MarshalAuthorizedKey(key)))
Giorgi Lekveishvili23ef7f82023-05-26 11:57:48 +0400390 return nil
391 },
392 }
gioe72b54f2024-04-22 10:44:41 +0400393 client, err := ssh.Dial("tcp", ss.addr, config)
Giorgi Lekveishvili23ef7f82023-05-26 11:57:48 +0400394 if err != nil {
395 return nil, err
396 }
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400397 defer client.Close()
Giorgi Lekveishvili23ef7f82023-05-26 11:57:48 +0400398 return ret, nil
399}
400
gioe72b54f2024-04-22 10:44:41 +0400401func (ss *realClient) sshClientConfig() *ssh.ClientConfig {
giolekva8aa73e82022-07-09 11:34:39 +0400402 return &ssh.ClientConfig{
403 Auth: []ssh.AuthMethod{
gioe72b54f2024-04-22 10:44:41 +0400404 ssh.PublicKeys(ss.signer),
giolekva8aa73e82022-07-09 11:34:39 +0400405 },
406 HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
407 // TODO(giolekva): verify server public key
408 // fmt.Printf("## %s || %s -- \n", serverPubKey, ssh.MarshalAuthorizedKey(key))
Giorgi Lekveishvili23ef7f82023-05-26 11:57:48 +0400409 fmt.Printf("%s %s %s", hostname, remote, ssh.MarshalAuthorizedKey(key))
giolekva8aa73e82022-07-09 11:34:39 +0400410 return nil
411 },
412 }
413}
414
gioe72b54f2024-04-22 10:44:41 +0400415func (ss *realClient) GetRepoAddress(name string) string {
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +0400416 return fmt.Sprintf("%s/%s", ss.addressGit(), name)
417}
418
gioe72b54f2024-04-22 10:44:41 +0400419func (ss *realClient) addressGit() string {
420 return fmt.Sprintf("ssh://%s", ss.addr)
giolekva8aa73e82022-07-09 11:34:39 +0400421}