diff --git a/README.md b/README.md index acbcf86..b2bd375 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ | | diagnostics | `completion` | `hover` | `code-action` | `definition` | `rename` | `signature-help` | |-------------|-------------|--------------|---------|---------------|--------------|----------|------------------| -| aliases | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| fstab | ✅ | ✅ | ✅ | ❓ | ❓ | ❓ | 🟡 | -| hosts | ✅ | ✅ | ✅ | ✅ | ❓ | ❓ | 🟡 | -| sshd_config | ✅ | ✅ | ✅ | ❓ | ✅ | ❓ | 🟡 | -| wireguard | ✅ | ✅ | ✅ | ✅ | ❓ | ❓ | 🟡 | +| aliases | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| fstab | ✅ | ✅ | ✅ | ❓ | ❓ | ❓ | 🟡 | +| hosts | ✅ | ✅ | ✅ | ✅ | ❓ | ❓ | 🟡 | +| ssh_config | ✅ | ✅ | ✅ | ✅ | ✅ | ❓ | ✅ | +| sshd_config | ✅ | ✅ | ✅ | ❓ | ✅ | ❓ | ✅ | +| wireguard | ✅ | ✅ | ✅ | ✅ | ❓ | ❓ | 🟡 | ✅ = Supported @@ -14,3 +15,4 @@ ❓ = No idea what to implement here, please let me know if you have any ideas + diff --git a/server/doc-values/value-array.go b/server/doc-values/value-array.go index 3d330f7..5d9c7c4 100644 --- a/server/doc-values/value-array.go +++ b/server/doc-values/value-array.go @@ -3,6 +3,7 @@ package docvalues import ( "config-lsp/utils" "fmt" + "regexp" "strings" protocol "github.com/tliron/glsp/protocol_3_16" @@ -42,6 +43,9 @@ type ArrayValue struct { // This is used to extract the value from the user input, // because you may want to preprocess the value before checking for duplicates DuplicatesExtractor *(func(string) string) + + // If true, array ArrayValue ignores the `Separator` if it's within quotes + RespectQuotes bool } func (v ArrayValue) GetTypeDescription() []string { @@ -53,9 +57,18 @@ func (v ArrayValue) GetTypeDescription() []string { ) } +// TODO: Add support for quotes func (v ArrayValue) DeprecatedCheckIsValid(value string) []*InvalidValue { errors := []*InvalidValue{} - values := strings.Split(value, v.Separator) + var values []string + + if v.RespectQuotes { + splitPattern := *regexp.MustCompile(fmt.Sprintf(`".+?"|[^%s]+`, v.Separator)) + + values = splitPattern.FindAllString(value, -1) + } else { + values = strings.Split(value, v.Separator) + } if *v.DuplicatesExtractor != nil { valuesOccurrences := utils.SliceToMap( @@ -122,9 +135,27 @@ func (v ArrayValue) getCurrentValue(line string, cursor uint32) (string, uint32) MIN := uint32(0) MAX := uint32(len(line) - 1) + var cursorSearchStart = cursor + var cursorSearchEnd = cursor + var start uint32 var end uint32 + // Hello,world,how,are,you + // Hello,"world,how",are,you + if v.RespectQuotes { + quotes := utils.GetQuoteRanges(line) + + if len(quotes) > 0 { + quote := quotes.GetQuoteForIndex(int(cursor)) + + if quote != nil { + cursorSearchStart = uint32(quote[0]) + cursorSearchEnd = uint32(quote[1]) + } + } + } + // hello,w[o]rld,and,more // [h]ello,world // hello,[w]orld @@ -135,7 +166,7 @@ func (v ArrayValue) getCurrentValue(line string, cursor uint32) (string, uint32) relativePosition, found := utils.FindPreviousCharacter( line, v.Separator, - int(cursor), + int(cursorSearchStart), ) if found { @@ -151,7 +182,7 @@ func (v ArrayValue) getCurrentValue(line string, cursor uint32) (string, uint32) relativePosition, found = utils.FindNextCharacter( line, v.Separator, - int(start), + int(cursorSearchEnd), ) if found { diff --git a/server/handlers/aliases/handlers/completions.go b/server/handlers/aliases/handlers/completions.go index b7d43a7..db9b0ef 100644 --- a/server/handlers/aliases/handlers/completions.go +++ b/server/handlers/aliases/handlers/completions.go @@ -66,14 +66,14 @@ func GetCompletionsForEntry( return completions, nil } - switch (*value).(type) { + switch value.(type) { case ast.AliasValueUser: return getUserCompletions( i, excludedUsers, ), nil case ast.AliasValueError: - errorValue := (*value).(ast.AliasValueError) + errorValue := value.(ast.AliasValueError) isAtErrorCode := errorValue.Code == nil && errorValue.Location.IsPositionAfterStart(cursor) && diff --git a/server/handlers/aliases/handlers/get-value.go b/server/handlers/aliases/handlers/get-value.go index ce6219b..1e658e7 100644 --- a/server/handlers/aliases/handlers/get-value.go +++ b/server/handlers/aliases/handlers/get-value.go @@ -9,7 +9,7 @@ import ( func GetValueAtPosition( position common.Position, entry *ast.AliasEntry, -) *ast.AliasValueInterface { +) ast.AliasValueInterface { if entry.Values == nil || len(entry.Values.Values) == 0 { return nil } @@ -36,5 +36,5 @@ func GetValueAtPosition( return nil } - return &entry.Values.Values[index] + return entry.Values.Values[index] } diff --git a/server/handlers/aliases/handlers/signature_help.go b/server/handlers/aliases/handlers/signature_help.go index b85bf73..fad0625 100644 --- a/server/handlers/aliases/handlers/signature_help.go +++ b/server/handlers/aliases/handlers/signature_help.go @@ -28,8 +28,8 @@ func GetRootSignatureHelp( }, { Label: []uint32{ - uint32(len(":")), - uint32(len(":") + len("")), + uint32(len(": ")), + uint32(len(": ") + len("")), }, Documentation: "A value to associate with the alias", }, diff --git a/server/handlers/aliases/lsp/text-document-completion.go b/server/handlers/aliases/lsp/text-document-completion.go index 1d5d429..2b6da3a 100644 --- a/server/handlers/aliases/lsp/text-document-completion.go +++ b/server/handlers/aliases/lsp/text-document-completion.go @@ -28,11 +28,7 @@ func TextDocumentCompletion(context *glsp.Context, params *protocol.CompletionPa entry := rawEntry.(*ast.AliasEntry) - if entry.Key == nil { - return handlers.GetAliasesCompletions(d.Indexes), nil - } - - if entry.Key.Location.ContainsPosition(cursor) { + if entry.Key == nil || entry.Key.Location.ContainsPosition(cursor) { return handlers.GetAliasesCompletions(d.Indexes), nil } @@ -40,13 +36,9 @@ func TextDocumentCompletion(context *glsp.Context, params *protocol.CompletionPa return nil, nil } - if entry.Separator.IsPositionBeforeEnd(cursor) { - return handlers.GetCompletionsForEntry( - cursor, - entry, - d.Indexes, - ) - } - - return nil, nil + return handlers.GetCompletionsForEntry( + cursor, + entry, + d.Indexes, + ) } diff --git a/server/handlers/aliases/lsp/text-document-definition.go b/server/handlers/aliases/lsp/text-document-definition.go index 5976f5d..5018568 100644 --- a/server/handlers/aliases/lsp/text-document-definition.go +++ b/server/handlers/aliases/lsp/text-document-definition.go @@ -32,7 +32,7 @@ func TextDocumentDefinition(context *glsp.Context, params *protocol.DefinitionPa return handlers.GetDefinitionLocationForValue( *d.Indexes, - *rawValue, + rawValue, params.TextDocument.URI, ), nil } diff --git a/server/handlers/aliases/lsp/text-document-hover.go b/server/handlers/aliases/lsp/text-document-hover.go index a1c97fd..638f524 100644 --- a/server/handlers/aliases/lsp/text-document-hover.go +++ b/server/handlers/aliases/lsp/text-document-hover.go @@ -49,10 +49,10 @@ func TextDocumentHover( } contents := []string{} - contents = append(contents, handlers.GetAliasValueTypeInfo(*value)...) + contents = append(contents, handlers.GetAliasValueTypeInfo(value)...) contents = append(contents, "") contents = append(contents, "#### Value") - contents = append(contents, handlers.GetAliasValueHoverInfo(*document.Indexes, *value)) + contents = append(contents, handlers.GetAliasValueHoverInfo(*document.Indexes, value)) text := strings.Join(contents, "\n") diff --git a/server/handlers/aliases/lsp/text-document-prepare-rename.go b/server/handlers/aliases/lsp/text-document-prepare-rename.go index 56e2dd1..c44fc3e 100644 --- a/server/handlers/aliases/lsp/text-document-prepare-rename.go +++ b/server/handlers/aliases/lsp/text-document-prepare-rename.go @@ -34,9 +34,9 @@ func TextDocumentPrepareRename(context *glsp.Context, params *protocol.PrepareRe return nil, nil } - switch (*rawValue).(type) { + switch rawValue.(type) { case ast.AliasValueUser: - userValue := (*rawValue).(ast.AliasValueUser) + userValue := rawValue.(ast.AliasValueUser) return userValue.Location.ToLSPRange(), nil } diff --git a/server/handlers/aliases/lsp/text-document-rename.go b/server/handlers/aliases/lsp/text-document-rename.go index d51f092..61d1c69 100644 --- a/server/handlers/aliases/lsp/text-document-rename.go +++ b/server/handlers/aliases/lsp/text-document-rename.go @@ -44,9 +44,9 @@ func TextDocumentRename(context *glsp.Context, params *protocol.RenameParams) (* return nil, nil } - switch (*rawValue).(type) { + switch rawValue.(type) { case ast.AliasValueUser: - userValue := (*rawValue).(ast.AliasValueUser) + userValue := rawValue.(ast.AliasValueUser) changes := handlers.RenameAlias( *d.Indexes, diff --git a/server/handlers/aliases/lsp/text-document-signature-help.go b/server/handlers/aliases/lsp/text-document-signature-help.go index 913fe62..77ab565 100644 --- a/server/handlers/aliases/lsp/text-document-signature-help.go +++ b/server/handlers/aliases/lsp/text-document-signature-help.go @@ -14,7 +14,7 @@ func TextDocumentSignatureHelp(context *glsp.Context, params *protocol.Signature document := aliases.DocumentParserMap[params.TextDocument.URI] line := params.Position.Line - cursor := common.LSPCharacterAsCursorPosition(common.CursorToCharacterIndex(params.Position.Character)) + cursor := common.LSPCharacterAsCursorPosition(params.Position.Character) if _, found := document.Parser.CommentLines[line]; found { // Comment @@ -36,17 +36,15 @@ func TextDocumentSignatureHelp(context *glsp.Context, params *protocol.Signature if entry.Values != nil && entry.Values.Location.ContainsPosition(cursor) { value := handlers.GetValueAtPosition(cursor, entry) - if value == nil { + if value == nil || value.GetAliasValue().Value == "" { // For some reason, this does not really work, // When we return all, and then a user value is entered // and the `GetValueSignatureHelp` is called, still the old // signatures with all signature are shown - // return handlers.GetAllValuesSignatureHelp(), nil - - return nil, nil + return handlers.GetAllValuesSignatureHelp(), nil } - return handlers.GetValueSignatureHelp(cursor, *value), nil + return handlers.GetValueSignatureHelp(cursor, value), nil } return nil, nil diff --git a/server/handlers/ssh_config/analyzer/analyzer.go b/server/handlers/ssh_config/analyzer/analyzer.go index 48adb23..447bb34 100644 --- a/server/handlers/ssh_config/analyzer/analyzer.go +++ b/server/handlers/ssh_config/analyzer/analyzer.go @@ -35,7 +35,27 @@ func Analyze( d.Indexes = i + analyzeIncludeValues(ctx) + + if len(ctx.diagnostics) == 0 { + for _, include := range d.Indexes.Includes { + for _, value := range include.Values { + for _, path := range value.Paths { + _, err := parseFile(string(path)) + + if err != nil { + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: value.LocationRange.ToLSPRange(), + Message: err.Error(), + }) + } + } + } + } + } + analyzeValuesAreValid(ctx) + analyzeTokens(ctx) analyzeIgnoreUnknownHasNoUnnecessary(ctx) analyzeDependents(ctx) analyzeBlocks(ctx) diff --git a/server/handlers/ssh_config/analyzer/include.go b/server/handlers/ssh_config/analyzer/include.go new file mode 100644 index 0000000..8cb5115 --- /dev/null +++ b/server/handlers/ssh_config/analyzer/include.go @@ -0,0 +1,143 @@ +package analyzer + +import ( + "config-lsp/common" + sshconfig "config-lsp/handlers/ssh_config" + "config-lsp/handlers/ssh_config/ast" + "config-lsp/handlers/ssh_config/fields" + "config-lsp/handlers/ssh_config/indexes" + "config-lsp/utils" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + protocol "github.com/tliron/glsp/protocol_3_16" +) + +var whitespacePattern = regexp.MustCompile(`\S+`) +var environmtalVariablePattern = regexp.MustCompile(`\${.+?}`) + +func analyzeIncludeValues( + ctx *analyzerContext, +) { + for _, include := range ctx.document.Indexes.Includes { + for _, value := range include.Values { + if !canBeAnalyzed(value.Value) { + continue + } + + validPaths, err := createIncludePaths(value.Value) + + if err != nil { + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: value.LocationRange.ToLSPRange(), + Message: err.Error(), + Severity: &common.SeverityError, + }) + } else { + value.Paths = validPaths + } + } + } +} + +// We can't evaluate environmental variables or tokens as we don't know the actual +// values +func canBeAnalyzed( + path string, +) bool { + if environmtalVariablePattern.MatchString(path) { + return false + } + + for token := range fields.AvailableTokens { + if strings.Contains(path, token) { + return false + } + } + + return true +} + +func createIncludePaths( + suggestedPath string, +) ([]indexes.ValidPath, error) { + var absolutePath string + + if path.IsAbs(suggestedPath) { + absolutePath = suggestedPath + } else if strings.HasPrefix(suggestedPath, "~") { + homeFolder, err := os.UserHomeDir() + + if err != nil { + return nil, errors.New(fmt.Sprintf("Could not find home folder (error: %s)", err)) + } + + absolutePath = path.Join(homeFolder, suggestedPath[1:]) + } else { + homeFolder, err := os.UserHomeDir() + + if err != nil { + return nil, errors.New(fmt.Sprintf("Could not find home folder (error: %s)", err)) + } + + absolutePath = path.Join(homeFolder, ".ssh", suggestedPath) + } + + files, err := filepath.Glob(absolutePath) + + if err != nil { + return nil, errors.New(fmt.Sprintf("Could not find file %s (error: %s)", absolutePath, err)) + } + + if len(files) == 0 { + return nil, errors.New(fmt.Sprintf("Could not find file %s", absolutePath)) + } + + return utils.Map( + files, + func(file string) indexes.ValidPath { + return indexes.ValidPath(file) + }, + ), nil +} + +func parseFile( + filePath string, +) (*sshconfig.SSHDocument, error) { + if d, ok := sshconfig.DocumentParserMap[filePath]; ok { + return d, nil + } + + c := ast.NewSSHConfig() + + content, err := os.ReadFile(filePath) + + if err != nil { + return nil, err + } + + parseErrors := c.Parse(string(content)) + + if len(parseErrors) > 0 { + return nil, errors.New(fmt.Sprintf("Errors in %s", filePath)) + } + + d := &sshconfig.SSHDocument{ + Config: c, + } + + errs := Analyze(d) + + if len(errs) > 0 { + return nil, errors.New(fmt.Sprintf("Errors in %s", filePath)) + } + + sshconfig.DocumentParserMap[filePath] = d + + return d, nil +} diff --git a/server/handlers/ssh_config/analyzer/quotes.go b/server/handlers/ssh_config/analyzer/quotes.go index 945bd1a..2155fe9 100644 --- a/server/handlers/ssh_config/analyzer/quotes.go +++ b/server/handlers/ssh_config/analyzer/quotes.go @@ -30,7 +30,7 @@ func checkIsUsingDoubleQuotes( singleQuotePosition := strings.Index(value.Raw, "'") // Single quote - if singleQuotePosition != -1 && !quoteRanges.IsCharInside(singleQuotePosition) { + if singleQuotePosition != -1 && !quoteRanges.IsIndexInsideQuotes(singleQuotePosition) { ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ Range: valueRange.ToLSPRange(), Message: "ssh_config does not support single quotes. Use double quotes (\") instead.", diff --git a/server/handlers/ssh_config/analyzer/tokens.go b/server/handlers/ssh_config/analyzer/tokens.go new file mode 100644 index 0000000..6d35585 --- /dev/null +++ b/server/handlers/ssh_config/analyzer/tokens.go @@ -0,0 +1,49 @@ +package analyzer + +import ( + "config-lsp/common" + "config-lsp/handlers/ssh_config/fields" + "config-lsp/utils" + "fmt" + "strings" + + protocol "github.com/tliron/glsp/protocol_3_16" +) + +func analyzeTokens( + ctx *analyzerContext, +) { + for _, info := range ctx.document.Config.GetAllOptions() { + if info.Option.Key == nil || info.Option.OptionValue == nil { + continue + } + + key := info.Option.Key.Key + text := info.Option.OptionValue.Value.Value + var tokens []string + + if foundTokens, found := fields.OptionsTokensMap[key]; found { + tokens = foundTokens + } else { + tokens = []string{} + } + + disallowedTokens := utils.Without(utils.KeysOfMap(fields.AvailableTokens), tokens) + + for _, token := range disallowedTokens { + if strings.Contains(text, token) { + optionName := string(key) + + if formatted, found := fields.FieldsNameFormattedMap[key]; found { + optionName = formatted + } + + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: info.Option.OptionValue.ToLSPRange(), + Message: fmt.Sprintf("Token '%s' is not allowed for option '%s'", token, optionName), + Severity: &common.SeverityError, + }) + } + } + } +} diff --git a/server/handlers/ssh_config/analyzer/tokens_test.go b/server/handlers/ssh_config/analyzer/tokens_test.go new file mode 100644 index 0000000..cd2358e --- /dev/null +++ b/server/handlers/ssh_config/analyzer/tokens_test.go @@ -0,0 +1,62 @@ +package analyzer + +import ( + testutils_test "config-lsp/handlers/ssh_config/test_utils" + "testing" + + protocol "github.com/tliron/glsp/protocol_3_16" +) + +func TestInvalidTokensForNonExisting( + t *testing.T, +) { + d := testutils_test.DocumentFromInput(t, ` +ThisOptionDoesNotExist Hello%%World +`) + ctx := &analyzerContext{ + document: d, + diagnostics: make([]protocol.Diagnostic, 0), + } + + analyzeTokens(ctx) + + if !(len(ctx.diagnostics) == 1) { + t.Errorf("Expected 1 error, got %v", len(ctx.diagnostics)) + } +} + +func TestInvalidTokensForExistingOption( + t *testing.T, +) { + d := testutils_test.DocumentFromInput(t, ` +Tunnel Hello%%World +`) + ctx := &analyzerContext{ + document: d, + diagnostics: make([]protocol.Diagnostic, 0), + } + + analyzeTokens(ctx) + + if !(len(ctx.diagnostics) == 1) { + t.Errorf("Expected 1 error, got %v", len(ctx.diagnostics)) + } +} + +func TestValidTokens( + t *testing.T, +) { + d := testutils_test.DocumentFromInput(t, ` +LocalCommand Hello World %% and %d +`) + ctx := &analyzerContext{ + document: d, + diagnostics: make([]protocol.Diagnostic, 0), + } + + analyzeTokens(ctx) + + if len(ctx.diagnostics) > 0 { + t.Fatalf("Expected no errors, but got %v", len(ctx.diagnostics)) + } +} diff --git a/server/handlers/ssh_config/fields/fields.go b/server/handlers/ssh_config/fields/fields.go index 59caccf..179beb1 100644 --- a/server/handlers/ssh_config/fields/fields.go +++ b/server/handlers/ssh_config/fields/fields.go @@ -98,6 +98,7 @@ var Options = map[NormalizedOptionName]docvalues.DocumentationValue{ Value: docvalues.ArrayValue{ Separator: ",", DuplicatesExtractor: &docvalues.SimpleDuplicatesExtractor, + RespectQuotes: true, SubValue: docvalues.StringValue{}, }, }, @@ -127,6 +128,7 @@ rsa-sha2-512,rsa-sha2-256 SubValue: docvalues.ArrayValue{ Separator: ",", DuplicatesExtractor: &docvalues.DuplicatesAllowedExtractor, + RespectQuotes: true, // TODO: Add SubValue: docvalues.StringValue{}, }, @@ -170,6 +172,7 @@ The default is not to expire channels of any type for inactivity.`, Value: docvalues.ArrayValue{ Separator: " ", DuplicatesExtractor: &channelTimeoutExtractor, + RespectQuotes: true, SubValue: docvalues.KeyValueAssignmentValue{ ValueIsOptional: false, Separator: "=", @@ -361,6 +364,7 @@ aes128-gcm@openssh.com,aes256-gcm@openssh.com Value: docvalues.ArrayValue{ Separator: " ", DuplicatesExtractor: &docvalues.SimpleDuplicatesExtractor, + RespectQuotes: true, SubValue: docvalues.PathValue{ RequiredType: docvalues.PathTypeFile, }, @@ -479,6 +483,7 @@ rsa-sha2-512,rsa-sha2-256 Value: docvalues.ArrayValue{ Separator: " ", DuplicatesExtractor: &docvalues.SimpleDuplicatesExtractor, + RespectQuotes: true, SubValue: docvalues.StringValue{}, }, }, @@ -493,7 +498,9 @@ rsa-sha2-512,rsa-sha2-256 }, }, docvalues.ArrayValue{ - Separator: " ", + Separator: " ", + DuplicatesExtractor: &docvalues.SimpleDuplicatesExtractor, + RespectQuotes: true, SubValue: docvalues.EnumValue{ EnforceValues: true, Values: []docvalues.EnumString{ @@ -539,6 +546,7 @@ rsa-sha2-512,rsa-sha2-256 Value: docvalues.ArrayValue{ Separator: ",", DuplicatesExtractor: &docvalues.SimpleDuplicatesExtractor, + RespectQuotes: true, SubValue: docvalues.EnumValue{ EnforceValues: true, Values: []docvalues.EnumString{ @@ -951,7 +959,9 @@ rsa-sha2-512,rsa-sha2-256 ~/.ssh/known_hosts, ~/.ssh/known_hosts2.`, Value: docvalues.ArrayValue{ - Separator: " ", + Separator: " ", + DuplicatesExtractor: &docvalues.SimpleDuplicatesExtractor, + RespectQuotes: true, SubValue: docvalues.PathValue{ RequiredType: docvalues.PathTypeFile, }, diff --git a/server/handlers/ssh_config/fields/tokens.go b/server/handlers/ssh_config/fields/tokens.go new file mode 100644 index 0000000..42b0f54 --- /dev/null +++ b/server/handlers/ssh_config/fields/tokens.go @@ -0,0 +1,73 @@ +package fields + +import "config-lsp/utils" + +var AvailableTokens = map[string]string{ + "%%": "A literal ‘%’.", + "%C": "Hash of %l%h%p%r%j.", + "%d": "Local user's home directory.", + "%f": "The fingerprint of the server's host key.", + "%H": "The known_hosts hostname or address that is being searched for.", + "%h": "The remote hostname.", + "%I": "A string describing the reason for a KnownHostsCommand execution: either ADDRESS when looking up a host by address (only when CheckHostIP is enabled), HOSTNAME when searching by hostname, or ORDER when preparing the host key algorithm preference list to use for the destination host.", + "%i": "The local user ID.", + "%j": "The contents of the ProxyJump option, or the empty string if this option is unset.", + "%K": "The base64 encoded host key.", + "%k": "The host key alias if specified, otherwise the original remote hostname given on the command line.", + "%L": "The local hostname.", + "%l": "The local hostname, including the domain name.", + "%n": "The original remote hostname, as given on the command line.", + "%p": "The remote port.", + "%r": "The remote username.", + "%T": "The local tun(4) or tap(4) network interface assigned if tunnel forwarding was requested, or \"NONE\" otherwise.", + "%t": "The type of the server host key, e.g. ssh-ed25519.", + "%u": "The local username.", +} + +// A map of