vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
c717d4eb
Keenan Nemetz
13d ago
|
1 | package web |
| 2 | ||
| 3 | // The code browser: a persistent file tree beside the file, directory or README | |
| 4 | // it has open, plus the commit history for a ref. Only the main pane is replaced | |
| 5 | // on navigation; the tree, branch list and latest commit belong to the full page. | |
| 6 | // | |
| 7 | // The repository overview (overview.go) is the page that summarises a repository; | |
| 8 | // this is the page that reads it. | |
| 9 | ||
| 10 | import ( | |
| 11 | "fmt" | |
| 12 | "html/template" | |
|
4581690d
Keenan Nemetz
13d ago
|
13 | "io" |
|
268b9368
Keenan Nemetz
21h ago
|
14 | "net" |
|
c717d4eb
Keenan Nemetz
13d ago
|
15 | "net/http" |
| 16 | "sort" | |
|
4581690d
Keenan Nemetz
13d ago
|
17 | "strconv" |
|
c717d4eb
Keenan Nemetz
13d ago
|
18 | "strings" |
| 19 | "unicode/utf8" | |
| 20 | ||
|
d37d301f
Keenan Nemetz
18h ago
|
21 | "rodent.software/vole/core/git" |
| 22 | "rodent.software/vole/forge/config" | |
| 23 | "rodent.software/vole/forge/repo" | |
|
c717d4eb
Keenan Nemetz
13d ago
|
24 | ) |
| 25 | ||
| 26 | const maxInlineBytes = 1 << 20 | |
| 27 | ||
| 28 | // codeView is the data shared by the full code page and the HTMX fragment that | |
| 29 | // replaces its main pane. Fields under the blob section are only populated for | |
| 30 | // blob views; the tree and shell fields are only populated for full pages. | |
| 31 | type codeView struct { | |
| 32 | Page | |
| 33 | Name string | |
| 34 | Ref string | |
| 35 | Commit string | |
| 36 | Count int | |
| 37 | Branches []string | |
|
55615c7a
Keenan Nemetz
11d ago
|
38 | Tags []string |
| 39 | OnBranch bool // ref names a branch, so the branch picker shows it | |
| 40 | OnTag bool // ref names a tag, so the tag picker shows it | |
|
c717d4eb
Keenan Nemetz
13d ago
|
41 | Latest []git.Commit |
| 42 | Tree []*treeNode | |
| 43 | TreeTruncated bool | |
| 44 | ||
| 45 | Mode string | |
| 46 | Path string | |
| 47 | Parent string | |
| 48 | Crumbs []crumb | |
| 49 | Entries []git.Entry | |
| 50 | ||
| 51 | Readme template.HTML | |
| 52 | ReadmeName string | |
| 53 | ||
| 54 | Content string | |
| 55 | Markdown template.HTML | |
| 56 | Binary bool | |
| 57 | Truncated bool | |
| 58 | LineCut bool | |
| 59 | Lines []string | |
|
306f36fc
Keenan Nemetz
12d ago
|
60 | Language string |
|
c717d4eb
Keenan Nemetz
13d ago
|
61 | Size int64 |
| 62 | IsMarkdown bool | |
| 63 | Preview bool | |
| 64 | } | |
| 65 | ||
| 66 | // Code is the file browser: a persistent tree beside the file it has open. The | |
| 67 | // repository overview (Overview) is the page that summarises the repository. | |
| 68 | func (w *Web) Code(rw http.ResponseWriter, r *http.Request) { | |
| 69 | m, ok := w.open(rw, r) | |
| 70 | if !ok { | |
| 71 | return | |
| 72 | } | |
| 73 | ref := r.URL.Query().Get("ref") | |
| 74 | if ref == "" { | |
| 75 | ref = m.DefaultBranch | |
| 76 | } | |
| 77 | g := m.Git() | |
| 78 | commit, _ := g.RevParse(ref) | |
| 79 | var readme template.HTML | |
| 80 | readmeName := "" | |
| 81 | if commit != "" { | |
|
796ccd90
Keenan Nemetz
13d ago
|
82 | readme, readmeName = w.readmeAt(m, ref, commit) |
|
c717d4eb
Keenan Nemetz
13d ago
|
83 | } |
| 84 | // The README is what the main pane opens with, so it is also the tree row | |
| 85 | // that should look current when no file has been selected yet. | |
| 86 | full := !htmxRequest(r) | |
| 87 | view := w.codeShell(r, m, ref, commit, readmeName, full) | |
| 88 | view.Mode = "overview" | |
| 89 | if commit != "" { | |
| 90 | view.Entries, _ = g.LsTree(commit) | |
| 91 | sortEntries(view.Entries) | |
| 92 | view.Readme, view.ReadmeName = readme, readmeName | |
| 93 | if readmeName != "" { | |
| 94 | view.Path = readmeName | |
| 95 | view.Crumbs = crumbs(readmeName) | |
| 96 | } | |
| 97 | } | |
| 98 | if !full { | |
| 99 | w.render(rw, "code_main", view, 0) | |
| 100 | return | |
| 101 | } | |
| 102 | w.render(rw, "code", view, 0) | |
| 103 | } | |
| 104 | ||
| 105 | // codeShell gathers everything the full code page keeps outside the main pane: | |
|
55615c7a
Keenan Nemetz
11d ago
|
106 | // the branch and tag lists, the file tree, the latest commit, and |
| 107 | // contributor-branch state. HTMX fragment requests skip the work they will not | |
| 108 | // render. | |
|
c717d4eb
Keenan Nemetz
13d ago
|
109 | func (w *Web) codeShell(r *http.Request, m *repo.Meta, ref, commit, active string, full bool) codeView { |
| 110 | g := m.Git() | |
| 111 | v := codeView{ | |
|
816cc543
Keenan Nemetz
11d ago
|
112 | Page: w.page(r, m.Name+" · code"), |
| 113 | Name: m.Name, | |
| 114 | Ref: ref, | |
| 115 | Commit: commit, | |
|
c717d4eb
Keenan Nemetz
13d ago
|
116 | } |
| 117 | if !full { | |
| 118 | return v | |
| 119 | } | |
| 120 | if commit != "" { | |
| 121 | v.Latest, _ = g.Log(commit, 1) | |
|
73ec800b
Keenan Nemetz
10d ago
|
122 | v.Count, _ = w.countCommits(g, commit) |
|
c717d4eb
Keenan Nemetz
13d ago
|
123 | v.Tree, v.TreeTruncated = buildFileTree(g, commit, active) |
| 124 | } | |
|
55615c7a
Keenan Nemetz
11d ago
|
125 | // Tags share the ref picker's target: a tag names a commit, so browsing a |
|
34cbc99f
Keenan Nemetz
10d ago
|
126 | // tag is the same page as browsing a branch. One listing answers both pickers; |
| 127 | // branches stay alphabetical and tags stay newest first. | |
| 128 | refs, _ := g.Refs("refs/heads/", "refs/tags/") | |
| 129 | var tags []git.RefEntry | |
| 130 | for _, rf := range refs { | |
| 131 | switch { | |
| 132 | case strings.HasPrefix(rf.Name, "refs/heads/"): | |
| 133 | name := strings.TrimPrefix(rf.Name, "refs/heads/") | |
| 134 | v.Branches = append(v.Branches, name) | |
| 135 | if name == ref { | |
| 136 | v.OnBranch = true | |
| 137 | } | |
| 138 | case strings.HasPrefix(rf.Name, "refs/tags/"): | |
| 139 | tags = append(tags, rf) | |
| 140 | if strings.TrimPrefix(rf.Name, "refs/tags/") == ref { | |
|
55615c7a
Keenan Nemetz
11d ago
|
141 | v.OnTag = true |
| 142 | } | |
| 143 | } | |
| 144 | } | |
|
34cbc99f
Keenan Nemetz
10d ago
|
145 | sort.Strings(v.Branches) |
| 146 | sort.SliceStable(tags, func(i, j int) bool { | |
| 147 | if tags[i].When != tags[j].When { | |
| 148 | return tags[i].When > tags[j].When | |
| 149 | } | |
| 150 | return tags[i].Name < tags[j].Name | |
| 151 | }) | |
| 152 | v.Tags = make([]string, 0, len(tags)) | |
| 153 | for _, tag := range tags { | |
| 154 | v.Tags = append(v.Tags, strings.TrimPrefix(tag.Name, "refs/tags/")) | |
| 155 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
156 | return v |
| 157 | } | |
| 158 | ||
| 159 | func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) { | |
| 160 | m, ok := w.open(rw, r) | |
| 161 | if !ok { | |
| 162 | return | |
| 163 | } | |
| 164 | ref, p := r.PathValue("ref"), r.PathValue("path") | |
| 165 | if !safePath(p) { | |
| 166 | http.Error(rw, "invalid path", http.StatusBadRequest) | |
| 167 | return | |
| 168 | } | |
| 169 | g := m.Git() | |
| 170 | commit, _ := g.RevParse(ref) | |
| 171 | if commit == "" { | |
| 172 | w.notFound(rw, r) | |
| 173 | return | |
| 174 | } | |
| 175 | treeish := commit | |
| 176 | if p != "" { | |
| 177 | treeish = commit + ":" + p | |
| 178 | } | |
| 179 | entries, err := g.LsTree(treeish) | |
| 180 | if err != nil { | |
| 181 | http.Error(rw, "not a directory", http.StatusBadRequest) | |
| 182 | return | |
| 183 | } | |
| 184 | sortEntries(entries) | |
| 185 | full := !htmxRequest(r) | |
| 186 | view := w.codeShell(r, m, ref, commit, p, full) | |
| 187 | view.Mode = "tree" | |
| 188 | view.Path = p | |
| 189 | view.Parent = parentPath(p) | |
| 190 | view.Entries = entries | |
| 191 | view.Crumbs = crumbs(p) | |
| 192 | if !full { | |
| 193 | w.render(rw, "code_main", view, 0) | |
| 194 | return | |
| 195 | } | |
| 196 | view.Page = w.page(r, m.Name+" · "+ref) | |
| 197 | w.render(rw, "code", view, 0) | |
| 198 | } | |
| 199 | ||
| 200 | func (w *Web) Blob(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, false) } | |
| 201 | ||
| 202 | func (w *Web) Raw(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, true) } | |
| 203 | ||
| 204 | func (w *Web) blob(rw http.ResponseWriter, r *http.Request, raw bool) { | |
| 205 | m, ok := w.open(rw, r) | |
| 206 | if !ok { | |
| 207 | return | |
| 208 | } | |
| 209 | ref, p := r.PathValue("ref"), r.PathValue("path") | |
| 210 | if !safePath(p) { | |
| 211 | http.Error(rw, "invalid path", http.StatusBadRequest) | |
| 212 | return | |
| 213 | } | |
| 214 | g := m.Git() | |
| 215 | commit, _ := g.RevParse(ref) | |
| 216 | if commit == "" { | |
| 217 | w.notFound(rw, r) | |
| 218 | return | |
| 219 | } | |
|
4581690d
Keenan Nemetz
13d ago
|
220 | stream, err := g.OpenBlobAt(commit, p) |
|
c717d4eb
Keenan Nemetz
13d ago
|
221 | if err != nil { |
| 222 | w.notFound(rw, r) | |
| 223 | return | |
| 224 | } | |
|
4581690d
Keenan Nemetz
13d ago
|
225 | defer stream.Close() |
|
c717d4eb
Keenan Nemetz
13d ago
|
226 | if raw { |
|
4581690d
Keenan Nemetz
13d ago
|
227 | // The download is the one place that should carry every byte, so it is |
| 228 | // streamed rather than read and written: a 100 MiB asset costs one copy | |
|
26c9be33
Keenan Nemetz
10d ago
|
229 | // buffer per request, not 100 MiB of heap. A byte range skips the bytes |
| 230 | // before it in the stream (git cannot seek) and copies only the requested | |
| 231 | // part, so the win is what does not cross the network. | |
|
4581690d
Keenan Nemetz
13d ago
|
232 | h := rw.Header() |
| 233 | h.Set("Content-Type", "text/plain; charset=utf-8") | |
|
306f36fc
Keenan Nemetz
12d ago
|
234 | // The announced length is part of the contract, so a text blob must not be |
| 235 | // re-encoded on the way out; a compressed body has no such length. | |
|
25343521
Keenan Nemetz
3d ago
|
236 | h.Set("Cache-Control", w.downloadCacheControl(m)+", no-transform") |
|
26c9be33
Keenan Nemetz
10d ago
|
237 | h.Set("Accept-Ranges", "bytes") |
| 238 | br, partial, unsatisfiable := parseByteRange(r.Header.Get("Range"), stream.Size) | |
| 239 | if unsatisfiable { | |
| 240 | h.Set("Content-Range", fmt.Sprintf("bytes */%d", stream.Size)) | |
| 241 | rw.WriteHeader(http.StatusRequestedRangeNotSatisfiable) | |
| 242 | return | |
| 243 | } | |
| 244 | if partial { | |
| 245 | h.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", br.start, br.start+br.length-1, stream.Size)) | |
| 246 | h.Set("Content-Length", strconv.FormatInt(br.length, 10)) | |
| 247 | rw.WriteHeader(http.StatusPartialContent) | |
| 248 | if r.Method == http.MethodHead { | |
| 249 | return | |
| 250 | } | |
| 251 | if br.start > 0 { | |
| 252 | if _, err := io.CopyN(io.Discard, stream, br.start); err != nil { | |
| 253 | w.Logger("raw %s %s: skip: %v", m.Name, p, err) | |
| 254 | return | |
| 255 | } | |
| 256 | } | |
| 257 | if _, err := io.CopyN(rw, stream, br.length); err != nil { | |
| 258 | // The client hung up mid-download; headers are already out. | |
| 259 | w.Logger("raw %s %s: %v", m.Name, p, err) | |
| 260 | } | |
| 261 | return | |
| 262 | } | |
| 263 | h.Set("Content-Length", strconv.FormatInt(stream.Size, 10)) | |
| 264 | if r.Method == http.MethodHead { | |
| 265 | return | |
| 266 | } | |
|
4581690d
Keenan Nemetz
13d ago
|
267 | if _, err := io.Copy(rw, stream); err != nil { |
| 268 | // The client hung up mid-download; headers are already out. | |
| 269 | w.Logger("raw %s %s: %v", m.Name, p, err) | |
| 270 | } | |
| 271 | return | |
| 272 | } | |
|
306f36fc
Keenan Nemetz
12d ago
|
273 | // Everything the page shows is a preview: the tree and the line numbers are |
| 274 | // bounded, so there is no reason to hold the whole file. | |
|
4581690d
Keenan Nemetz
13d ago
|
275 | data, truncated, err := stream.ReadPrefix(maxInlineBytes) |
| 276 | if err != nil { | |
| 277 | w.serverError(rw, r, "cannot read "+p+": "+err.Error()) | |
|
c717d4eb
Keenan Nemetz
13d ago
|
278 | return |
| 279 | } | |
| 280 | content := string(data) | |
| 281 | binary := !utf8.Valid(data) | |
| 282 | // A table row per line is pleasant to read and cheap for a source file, but a | |
| 283 | // million blank lines would be neither. Past the cap the page falls back to a | |
| 284 | // preformatted block. | |
| 285 | const maxNumberedLines = 5000 | |
| 286 | lines := strings.Split(content, "\n") | |
| 287 | lineCut := false | |
| 288 | if len(lines) > maxNumberedLines { | |
| 289 | lines = lines[:maxNumberedLines] | |
| 290 | lineCut = true | |
| 291 | } | |
|
306f36fc
Keenan Nemetz
12d ago
|
292 | // The browser colours the same bytes the table shows. All the server does is |
| 293 | // name the language, and a language it cannot name stays plain, which is | |
| 294 | // better than guessing wrong about its grammar. | |
| 295 | language := "" | |
|
c717d4eb
Keenan Nemetz
13d ago
|
296 | if !binary { |
|
306f36fc
Keenan Nemetz
12d ago
|
297 | language = prismLanguage(p) |
|
c717d4eb
Keenan Nemetz
13d ago
|
298 | } |
| 299 | markdown := !binary && isMarkdown(p) | |
| 300 | // A markdown file opens as source like any other file. Rendering it is an | |
| 301 | // explicit choice, so the code browser never hides the bytes by default. | |
| 302 | preview := markdown && !truncated && r.URL.Query().Get("preview") == "1" | |
| 303 | var rendered template.HTML | |
| 304 | if preview { | |
|
796ccd90
Keenan Nemetz
13d ago
|
305 | // The document knows where it lives, so a relative link in it can be |
| 306 | // resolved against its directory instead of the URL it is being read at. | |
| 307 | rendered = w.renderTreeMarkdown(treeDoc{Repo: m.Name, Ref: ref, Path: p}, content) | |
|
c717d4eb
Keenan Nemetz
13d ago
|
308 | } |
| 309 | full := !htmxRequest(r) | |
| 310 | view := w.codeShell(r, m, ref, commit, p, full) | |
| 311 | view.Mode = "blob" | |
| 312 | view.Path = p | |
| 313 | view.Parent = parentPath(p) | |
| 314 | view.Crumbs = crumbs(p) | |
| 315 | view.Content = content | |
| 316 | view.Markdown = rendered | |
| 317 | view.Binary = binary | |
| 318 | view.Truncated = truncated | |
| 319 | view.LineCut = lineCut | |
| 320 | view.Lines = lines | |
|
306f36fc
Keenan Nemetz
12d ago
|
321 | view.Language = language |
|
4581690d
Keenan Nemetz
13d ago
|
322 | view.Size = stream.Size |
|
c717d4eb
Keenan Nemetz
13d ago
|
323 | view.IsMarkdown = markdown |
| 324 | view.Preview = preview | |
| 325 | if !full { | |
| 326 | w.render(rw, "code_main", view, 0) | |
| 327 | return | |
| 328 | } | |
| 329 | view.Page = w.page(r, m.Name+" · "+p) | |
| 330 | w.render(rw, "code", view, 0) | |
| 331 | } | |
| 332 | ||
| 333 | func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) { | |
| 334 | m, ok := w.open(rw, r) | |
| 335 | if !ok { | |
| 336 | return | |
| 337 | } | |
| 338 | ref := r.PathValue("ref") | |
|
701232cf
Keenan Nemetz
12d ago
|
339 | release, ok := w.gitSlot(rw, r) |
| 340 | if !ok { | |
| 341 | return | |
| 342 | } | |
| 343 | defer release() | |
| 344 | g, cancel := gitRepo(r, m) | |
| 345 | defer cancel() | |
|
c717d4eb
Keenan Nemetz
13d ago
|
346 | commit, _ := g.RevParse(ref) |
| 347 | if commit == "" { | |
| 348 | w.notFound(rw, r) | |
| 349 | return | |
| 350 | } | |
| 351 | commits, err := g.Log(commit, 100) | |
| 352 | if err != nil { | |
| 353 | w.serverError(rw, r, "cannot read commit history: "+err.Error()) | |
| 354 | return | |
| 355 | } | |
| 356 | w.render(rw, "commits", struct { | |
| 357 | Page | |
| 358 | Name string | |
| 359 | Ref string | |
| 360 | Commits []git.Commit | |
| 361 | }{w.page(r, m.Name+" · history"), m.Name, ref, commits}, 0) | |
| 362 | } | |
| 363 | ||
|
701232cf
Keenan Nemetz
12d ago
|
364 | // cloneBase is the read-only HTTP transport root this instance advertises. It is |
| 365 | // what a visitor who wants to clone without an account is shown. | |
| 366 | // | |
| 367 | // The configured public_url wins whenever it is set: a download or clone URL that | |
| 368 | // follows the request's Host header is a URL an attacker can choose, and the | |
| 369 | // browser is not the only consumer — a copied clone URL ends up in somebody's | |
| 370 | // terminal. Without a public_url the origin the browser used is the only | |
| 371 | // information available, and forwarded headers are honoured only when the instance | |
| 372 | // declares a trusted proxy. | |
| 373 | func (w *Web) cloneBase(r *http.Request) string { | |
|
ed03b3d1
Keenan Nemetz
6h ago
|
374 | return w.canonicalBase(r) + "/" |
|
701232cf
Keenan Nemetz
12d ago
|
375 | } |
| 376 | ||
| 377 | func (w *Web) cloneURL(r *http.Request, name string) string { | |
| 378 | return w.cloneBase(r) + name + ".git" | |
| 379 | } | |
| 380 | ||
| 381 | // canonicalBase is the origin generated links use, without a trailing slash. | |
| 382 | func (w *Web) canonicalBase(r *http.Request) string { | |
| 383 | if w.Cfg != nil { | |
| 384 | if base := w.Cfg.BaseURL(); base != "" { | |
| 385 | return base | |
| 386 | } | |
| 387 | } | |
| 388 | return requestOrigin(r, w.trustsProxy()) | |
| 389 | } | |
| 390 | ||
| 391 | // requestOrigin reconstructs the origin a request reached, using the forwarding | |
| 392 | // header only when the operator has declared a trusted proxy. A proxy that does not | |
| 393 | // set it is not evidence for anything, and a client that sets it must not be | |
| 394 | // believed. | |
| 395 | func requestOrigin(r *http.Request, trustForwarded bool) string { | |
|
c717d4eb
Keenan Nemetz
13d ago
|
396 | scheme := "http" |
| 397 | if r.TLS != nil { | |
| 398 | scheme = "https" | |
| 399 | } | |
|
701232cf
Keenan Nemetz
12d ago
|
400 | if trustForwarded { |
| 401 | if p := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))); p == "https" || p == "http" { | |
| 402 | scheme = p | |
| 403 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
404 | } |
|
701232cf
Keenan Nemetz
12d ago
|
405 | return scheme + "://" + r.Host |
| 406 | } | |
| 407 | ||
| 408 | // trustsProxy reports whether the instance is configured to believe forwarded | |
| 409 | // headers. The client-address header is the instance's statement that something in | |
| 410 | // front of it rewrites them. | |
| 411 | func (w *Web) trustsProxy() bool { | |
| 412 | return w.Cfg != nil && strings.TrimSpace(w.Cfg.GitHTTPClientIPHeader) != "" | |
|
c717d4eb
Keenan Nemetz
13d ago
|
413 | } |
| 414 | ||
|
701232cf
Keenan Nemetz
12d ago
|
415 | // siteURL is where a repository's site is served. A forge that is reached over |
| 416 | // HTTPS links to its sites over HTTPS: a link that downgrades the scheme is a | |
| 417 | // mixed-content warning at best and a broken site at worst. | |
| 418 | func (w *Web) siteURL(r *http.Request, name string) string { | |
| 419 | if w.Cfg == nil || w.Cfg.PagesDomain == "" { | |
| 420 | return "" | |
| 421 | } | |
| 422 | scheme := "http" | |
| 423 | if base := w.canonicalBase(r); strings.HasPrefix(base, "https://") { | |
| 424 | scheme = "https" | |
| 425 | } | |
| 426 | return scheme + "://" + name + "." + w.Cfg.PagesDomain | |
|
c717d4eb
Keenan Nemetz
13d ago
|
427 | } |
| 428 | ||
|
268b9368
Keenan Nemetz
21h ago
|
429 | // sshURL is the SSH transport the repository header offers. The host is the |
| 430 | // instance's configured name, from public_url, when there is one: ssh_addr is | |
| 431 | // the address the SSH listener binds, and a bind address such as 0.0.0.0 is not | |
| 432 | // something a person can connect to. The port always comes from ssh_addr, | |
| 433 | // because that is the port the listener answers on. Without a public_url there | |
| 434 | // is no configured name, so the listener's own address is all there is. | |
| 435 | func (w *Web) sshURL(name string) string { | |
| 436 | host, port := "", "" | |
| 437 | if w.Cfg != nil { | |
| 438 | host, port = splitSSHAddr(w.Cfg.SSHAddr) | |
| 439 | if u, err := config.ParsePublicURL(w.Cfg.PublicURL); err == nil && u != nil { | |
| 440 | host = u.Hostname() | |
| 441 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
442 | } |
| 443 | return fmt.Sprintf("ssh://git@%s/%s.git", netJoin(host, port), name) | |
| 444 | } | |
| 445 | ||
|
268b9368
Keenan Nemetz
21h ago
|
446 | // splitSSHAddr separates a listen address into the host and the port, tolerating |
| 447 | // a bare address with no port the way config validation does. An IPv6 host is | |
| 448 | // returned bare; netJoin puts the brackets back. | |
| 449 | func splitSSHAddr(addr string) (host, port string) { | |
| 450 | if h, p, err := net.SplitHostPort(addr); err == nil { | |
| 451 | return h, p | |
| 452 | } | |
| 453 | return addr, "" | |
| 454 | } | |
| 455 | ||
|
c717d4eb
Keenan Nemetz
13d ago
|
456 | func netJoin(host, port string) string { |
| 457 | if port == "" || port == "22" { | |
|
268b9368
Keenan Nemetz
21h ago
|
458 | if strings.Contains(host, ":") { |
| 459 | return "[" + host + "]" | |
| 460 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
461 | return host |
| 462 | } | |
|
268b9368
Keenan Nemetz
21h ago
|
463 | return net.JoinHostPort(host, port) |
|
c717d4eb
Keenan Nemetz
13d ago
|
464 | } |