From 912b17e1f139e5a3f927438df761b336b68c213a Mon Sep 17 00:00:00 2001 From: mirivlad Date: Wed, 3 Jun 2026 10:15:55 +0800 Subject: [PATCH] =?UTF-8?q?sshkeeper:=20v0.2.0=20=E2=80=94=20Phase=203:=20?= =?UTF-8?q?Port=20Forwarding=20Manager=20(DB,=20TUI=20screens,=20SSH=20arg?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/connect.go | 2 + cmd/tui.go | 9 + internal/db/servers.go | 5 + internal/ssh/command.go | 47 ++++- internal/ssh/route_test.go | 4 +- internal/tui/app.go | 115 ++++++++++++ internal/tui/forward.go | 368 +++++++++++++++++++++++++++++++++++++ 7 files changed, 543 insertions(+), 7 deletions(-) create mode 100644 internal/tui/forward.go diff --git a/cmd/connect.go b/cmd/connect.go index ce7a606..97c0776 100644 --- a/cmd/connect.go +++ b/cmd/connect.go @@ -41,6 +41,7 @@ var connectCmd = &cobra.Command{ AuthMethod: server.AuthMethod, IdentityFile: server.IdentityFile, ProxyJump: server.ProxyJump, + Route: server.Route, }, vaultFunc); err != nil { return err } @@ -82,6 +83,7 @@ var testCmd = &cobra.Command{ AuthMethod: server.AuthMethod, IdentityFile: server.IdentityFile, ProxyJump: server.ProxyJump, + Route: server.Route, }, vaultFunc) if ok { diff --git a/cmd/tui.go b/cmd/tui.go index 0dfe755..bfa2446 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -124,6 +124,15 @@ func runTUI() error { } return ssh.RunCommandOutput(cfg, fresh, vaultFunc, command) } + tui.ListForwards = func(serverID int64) ([]*model.Forward, error) { + return appDB.GetForwards(serverID) + } + tui.SaveForward = func(fwd *model.Forward) error { + return appDB.AddForward(fwd.ServerID, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort) + } + tui.DeleteForward = func(forwardID int64) error { + return appDB.DeleteForward(forwardID) + } tui.UpdateTestResult = func(alias string, status model.TestStatus, testErr string) error { return appDB.UpdateTestResult(alias, status, testErr) } diff --git a/internal/db/servers.go b/internal/db/servers.go index bb26cc2..f3e5d62 100644 --- a/internal/db/servers.go +++ b/internal/db/servers.go @@ -349,6 +349,11 @@ func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) { return forwards, rows.Err() } +func (db *DB) DeleteForward(forwardID int64) error { + _, err := db.conn.Exec("DELETE FROM forwards WHERE id=?", forwardID) + return err +} + // Ensure time import is used var _ time.Time diff --git a/internal/ssh/command.go b/internal/ssh/command.go index bf49344..52574ab 100644 --- a/internal/ssh/command.go +++ b/internal/ssh/command.go @@ -13,7 +13,7 @@ import ( type VaultFunc func(serverAlias string, secretType string) (string, error) func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error { - args := BuildSSHArgs(server) + args := BuildSSHArgsSimple(server) if strings.TrimSpace(server.StartupCommand) != "" { args = append(args, server.StartupCommand) } @@ -49,7 +49,7 @@ func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error } func RunCommand(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) error { - args := BuildSSHArgs(server) + args := BuildSSHArgsSimple(server) args = append(args, command) switch server.AuthMethod { @@ -78,7 +78,7 @@ func RunCommand(cfg *config.Config, server *model.Server, getVault VaultFunc, co } func RunCommandOutput(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) (string, error) { - args := BuildSSHArgs(server) + args := BuildSSHArgsSimple(server) args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec)) switch server.AuthMethod { @@ -116,7 +116,7 @@ func RunCommandOutput(cfg *config.Config, server *model.Server, getVault VaultFu } func Test(cfg *config.Config, server *model.Server, getVault VaultFunc) (bool, string) { - args := BuildSSHArgs(server) + args := BuildSSHArgsSimple(server) args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec)) switch server.AuthMethod { @@ -178,8 +178,31 @@ func testWithPassword(cfg *config.Config, args []string, password string) (bool, return false, result } +// BuildForwardArgs builds SSH port forwarding arguments. +func BuildForwardArgs(forwards []*model.Forward, exitOnForwardFailure bool) []string { + var args []string + for _, f := range forwards { + switch f.Type { + case model.ForwardLocal: + listen := fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort) + target := fmt.Sprintf("%s:%d", f.RemoteAddr, f.RemotePort) + args = append(args, "-L", listen+":"+target) + case model.ForwardRemote: + listen := fmt.Sprintf("%s:%d", f.RemoteAddr, f.RemotePort) + target := fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort) + args = append(args, "-R", listen+":"+target) + case model.ForwardDynamic: + args = append(args, "-D", fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort)) + } + } + if exitOnForwardFailure && len(forwards) > 0 { + args = append(args, "-o", "ExitOnForwardFailure=yes") + } + return args +} + // BuildSSHArgs builds the SSH command arguments for a server profile. -func BuildSSHArgs(server *model.Server) []string { +func BuildSSHArgs(server *model.Server, forwards []*model.Forward, forwardOnly bool) []string { var args []string args = append(args, "-p", fmt.Sprintf("%d", server.Port)) @@ -196,10 +219,24 @@ func BuildSSHArgs(server *model.Server) []string { args = append(args, "-J", server.ProxyJump) } + // Port forwarding + if len(forwards) > 0 { + args = append(args, BuildForwardArgs(forwards, true)...) + } + args = append(args, "-o", "StrictHostKeyChecking=accept-new") + if forwardOnly { + args = append(args, "-N") + } + target := fmt.Sprintf("%s@%s", server.User, server.Host) args = append(args, target) return args } + +// BuildSSHArgsSimple builds SSH args without forwards (backward compatible). +func BuildSSHArgsSimple(server *model.Server) []string { + return BuildSSHArgs(server, nil, false) +} diff --git a/internal/ssh/route_test.go b/internal/ssh/route_test.go index 1e98b21..7d963c1 100644 --- a/internal/ssh/route_test.go +++ b/internal/ssh/route_test.go @@ -105,7 +105,7 @@ func TestBuildSSHArgs_WithRoute(t *testing.T) { {Alias: "bastion", IsProfile: true}, }}, } - args := BuildSSHArgs(server) + args := BuildSSHArgsSimple(server) // Should contain -J bastion found := false for i, a := range args { @@ -126,7 +126,7 @@ func TestBuildSSHArgs_FallbackToProxyJump(t *testing.T) { User: "root", ProxyJump: "old-bastion", } - args := BuildSSHArgs(server) + args := BuildSSHArgsSimple(server) found := false for i, a := range args { if a == "-J" && i+1 < len(args) && args[i+1] == "old-bastion" { diff --git a/internal/tui/app.go b/internal/tui/app.go index 0840f9a..11f5db6 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -84,6 +84,16 @@ type templateRunRequestMsg struct { command string } +type forwardsLoadedMsg struct { + forwards []*model.Forward + err error +} + +type forwardDeletedMsg struct { + id int64 + err error +} + // --- List items --- type serverItem struct { @@ -144,6 +154,9 @@ var ( SaveCommandTemplate func(oldName string, template *model.CommandTemplate) error DeleteCommandTemplate func(name string) error RunTemplateBackground func(server *model.Server, command string) (string, error) + ListForwards func(serverID int64) ([]*model.Forward, error) + SaveForward func(fwd *model.Forward) error + DeleteForward func(forwardID int64) error ) // --- Screen type --- @@ -163,6 +176,8 @@ const ( screenBackgroundResults screenHelp screenActionMenu + screenForwardList + screenForwardForm ) // --- Result type — returned from TUI to caller --- @@ -201,6 +216,8 @@ type tuiModel struct { result *TUIResult helpScreen *helpScreenModel actionMenu *actionMenuModel + forwardScreen *forwardScreenModel + forwardForm *forwardFormModel } func New(servers []*model.Server) *tuiModel { @@ -317,6 +334,24 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pendingTemplate = nil return m, nil + case forwardsLoadedMsg: + if m.forwardScreen != nil { + if msg.err != nil { + m.forwardScreen.err = msg.err + } else { + m.forwardScreen.forwards = msg.forwards + m.forwardScreen.rebuildList() + } + } + return m, nil + + case forwardDeletedMsg: + if m.forwardScreen != nil && msg.err == nil { + // Reload forwards + return m, m.forwardScreen.loadForwards() + } + return m, nil + case testDoneMsg: if m.form != nil { m.form.testing = false @@ -399,6 +434,10 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateHelp(msg) case screenActionMenu: return m.updateActionMenu(msg) + case screenForwardList: + return m.updateForwardList(msg) + case screenForwardForm: + return m.updateForwardForm(msg) } } @@ -496,6 +535,14 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.screen = screenHelp return m, nil + case tea.KeyCtrlW: + // Open forward manager for selected server + if item, ok := m.list.SelectedItem().(serverItem); ok { + m.forwardScreen = newForwardScreenModel(item.server.ID, item.server.Alias, m.width, m.height) + m.screen = screenForwardList + return m, m.forwardScreen.loadForwards() + } + case tea.KeyCtrlX: m.actionMenu = newActionMenuModel(m.width, m.height) m.screen = screenActionMenu @@ -850,6 +897,16 @@ func (m *tuiModel) View() string { if m.actionMenu != nil { b.WriteString(m.actionMenu.View()) } + + case screenForwardList: + if m.forwardScreen != nil { + b.WriteString(m.forwardScreen.View()) + } + + case screenForwardForm: + if m.forwardForm != nil { + b.WriteString(m.forwardForm.View()) + } } if m.err != nil { @@ -931,6 +988,63 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.screen = screenList + m.forwardScreen = nil + return m, nil + case tea.KeyCtrlA: + // Add forward + if m.forwardScreen != nil { + m.forwardForm = newForwardFormModel(m.forwardScreen.serverID, m.width, m.height) + m.screen = screenForwardForm + return m, nil + } + case tea.KeyCtrlD: + if m.forwardScreen != nil { + return m, m.forwardScreen.deleteSelected() + } + case tea.KeyRunes: + switch msg.String() { + case "a", "A": + if m.forwardScreen != nil { + m.forwardForm = newForwardFormModel(m.forwardScreen.serverID, m.width, m.height) + m.screen = screenForwardForm + return m, nil + } + case "d", "D": + if m.forwardScreen != nil { + return m, m.forwardScreen.deleteSelected() + } + } + } + var cmd tea.Cmd + m.forwardScreen.list, cmd = m.forwardScreen.list.Update(msg) + return m, cmd +} + +func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.Type == tea.KeyEsc { + m.screen = screenForwardList + m.forwardForm = nil + return m, nil + } + updated, cmd := m.forwardForm.Update(msg) + if fm, ok := updated.(*forwardFormModel); ok { + m.forwardForm = fm + if fm.saved { + m.screen = screenForwardList + m.forwardForm = nil + // Reload forward list + if m.forwardScreen != nil { + return m, m.forwardScreen.loadForwards() + } + } + } + return m, cmd +} + func (m *tuiModel) viewServerList() string { var b strings.Builder selectedAlias := "" @@ -1379,6 +1493,7 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) [] helpItem{Key: "Ctrl+F", Action: "search"}, helpItem{Key: "Ctrl+P", Action: "tmpl"}, helpItem{Key: "Ctrl+G", Action: "tags"}, + helpItem{Key: "Ctrl+W", Action: "forwards"}, helpItem{Key: "Ins", Action: insAction}, helpItem{Key: "?", Action: "help"}, helpItem{Key: "Ctrl+Q", Action: "quit"}, diff --git a/internal/tui/forward.go b/internal/tui/forward.go new file mode 100644 index 0000000..013f3c8 --- /dev/null +++ b/internal/tui/forward.go @@ -0,0 +1,368 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbletea" + "github.com/mirivlad/sshkeeper/internal/model" +) + +// --- Forward list screen model --- + +type forwardScreenModel struct { + serverID int64 + serverAlias string + list list.Model + forwards []*model.Forward + width int + height int + err error +} + +func newForwardScreenModel(serverID int64, serverAlias string, w, h int) *forwardScreenModel { + l := list.New([]list.Item{}, list.NewDefaultDelegate(), w, h-6) + l.Title = "Port Forwards — " + serverAlias + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.Styles.Title = titleStyle + + return &forwardScreenModel{ + serverID: serverID, + serverAlias: serverAlias, + list: l, + width: w, + height: h, + } +} + +type forwardItem struct { + forward *model.Forward +} + +func (i forwardItem) Title() string { + return fmt.Sprintf("[%s] %s", i.forward.Type, forwardSummary(i.forward)) +} +func (i forwardItem) Description() string { + return forwardPreview(i.forward) +} +func (i forwardItem) FilterValue() string { + return string(i.forward.Type) + " " + fmt.Sprintf("%d", i.forward.LocalPort) + " " + i.forward.RemoteAddr +} + +func forwardSummary(f *model.Forward) string { + switch f.Type { + case model.ForwardLocal: + return fmt.Sprintf("%s:%d → %s:%d", f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort) + case model.ForwardRemote: + return fmt.Sprintf("%s:%d ← %s:%d", f.RemoteAddr, f.RemotePort, f.LocalAddr, f.LocalPort) + case model.ForwardDynamic: + return fmt.Sprintf("SOCKS %s:%d", f.LocalAddr, f.LocalPort) + default: + return fmt.Sprintf("%s %s:%d", f.Type, f.LocalAddr, f.LocalPort) + } +} + +func forwardPreview(f *model.Forward) string { + switch f.Type { + case model.ForwardLocal: + return fmt.Sprintf("-L %s:%d:%s:%d", f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort) + case model.ForwardRemote: + return fmt.Sprintf("-R %s:%d:%s:%d", f.RemoteAddr, f.RemotePort, f.LocalAddr, f.LocalPort) + case model.ForwardDynamic: + return fmt.Sprintf("-D %s:%d", f.LocalAddr, f.LocalPort) + default: + return string(f.Type) + } +} + +func (m *forwardScreenModel) loadForwards() tea.Cmd { + return func() tea.Msg { + if ListForwards == nil { + return forwardsLoadedMsg{err: fmt.Errorf("forward storage is unavailable")} + } + forwards, err := ListForwards(m.serverID) + return forwardsLoadedMsg{forwards: forwards, err: err} + } +} + +func (m *forwardScreenModel) rebuildList() { + items := make([]list.Item, len(m.forwards)) + for i, f := range m.forwards { + items[i] = forwardItem{forward: f} + } + m.list.SetItems(items) +} + +func (m *forwardScreenModel) deleteSelected() tea.Cmd { + if item, ok := m.list.SelectedItem().(forwardItem); ok && DeleteForward != nil { + f := item.forward + return func() tea.Msg { + return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)} + } + } + return nil +} + +func (m *forwardScreenModel) View() string { + var b strings.Builder + b.WriteString(m.list.View()) + b.WriteString("\n\n") + b.WriteString(renderHelp([]helpItem{ + {Key: "Ctrl+A (a)", Action: "add"}, + {Key: "Ctrl+D (d)", Action: "delete"}, + {Key: "Esc", Action: "back"}, + }, m.width)) + return b.String() +} + +// --- Forward form screen model --- + +type forwardFormModel struct { + serverID int64 + inputs []textinput.Model + labels []string + focusIdx int + err error + saved bool + typeList list.Model + showList bool + width int + height int +} + +func newForwardFormModel(serverID int64, w, h int) *forwardFormModel { + labels := []string{"Type (local/remote/dynamic)", "Listen Addr", "Listen Port", "Target Addr", "Target Port"} + inputs := make([]textinput.Model, len(labels)) + for i, label := range labels { + inputs[i] = textinput.New() + inputs[i].Placeholder = forwardPlaceholder(label) + inputs[i].CharLimit = 128 + } + inputs[0].Focus() + + typeList := newStringList([]string{"local", "remote", "dynamic"}, "Select type", 30, 8) + + return &forwardFormModel{ + serverID: serverID, + inputs: inputs, + labels: labels, + focusIdx: 0, + typeList: typeList, + width: w, + height: h, + } +} + +func forwardPlaceholder(label string) string { + switch label { + case "Type (local/remote/dynamic)": + return "local" + case "Listen Addr": + return "0.0.0.0" + case "Listen Port": + return "8080" + case "Target Addr": + return "internal.web" + case "Target Port": + return "80" + default: + return label + } +} + +func (fm *forwardFormModel) Init() tea.Cmd { + return nil +} + +func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case saveDoneMsg: + fm.saved = (msg.err == nil) + fm.err = msg.err + return fm, nil + } + + if fm.showList { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEsc: + fm.showList = false + return fm, nil + case tea.KeyEnter: + if item, ok := fm.typeList.SelectedItem().(groupItem); ok { + fm.inputs[0].SetValue(item.name) + } + fm.showList = false + return fm, nil + } + } + var cmd tea.Cmd + fm.typeList, cmd = fm.typeList.Update(msg) + return fm, cmd + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyTab: + fm.focusIdx++ + total := len(fm.inputs) + 1 + if fm.focusIdx >= total { + fm.focusIdx = 0 + } + fm.updateFocus() + return fm, nil + case tea.KeyShiftTab: + fm.focusIdx-- + if fm.focusIdx < 0 { + total := len(fm.inputs) + 1 + fm.focusIdx = total - 1 + } + fm.updateFocus() + return fm, nil + case tea.KeyRunes: + if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 0 { + fm.showList = true + return fm, nil + } + case tea.KeyEnter: + if fm.focusIdx == len(fm.inputs) { + return fm, fm.runSave() + } + fm.focusIdx++ + fm.updateFocus() + return fm, nil + case tea.KeyEsc: + return fm, nil + case tea.KeyDown: + fm.focusIdx++ + total := len(fm.inputs) + 1 + if fm.focusIdx >= total { + fm.focusIdx = 0 + } + fm.updateFocus() + return fm, nil + case tea.KeyUp: + fm.focusIdx-- + if fm.focusIdx < 0 { + total := len(fm.inputs) + 1 + fm.focusIdx = total - 1 + } + fm.updateFocus() + return fm, nil + } + } + + if fm.focusIdx < len(fm.inputs) { + var cmd tea.Cmd + fm.inputs[fm.focusIdx], cmd = fm.inputs[fm.focusIdx].Update(msg) + return fm, cmd + } + + return fm, nil +} + +func (fm *forwardFormModel) updateFocus() { + for i := range fm.inputs { + fm.inputs[i].Blur() + fm.inputs[i].Prompt = blurredStyle.Render(fm.labels[i] + ": ") + } + if fm.focusIdx < len(fm.inputs) { + fm.inputs[fm.focusIdx].Focus() + fm.inputs[fm.focusIdx].Prompt = focusedStyle.Render(fm.labels[fm.focusIdx] + "> ") + } +} + +func (fm *forwardFormModel) runSave() tea.Cmd { + return func() tea.Msg { + fwdType := model.ForwardType(strings.TrimSpace(fm.inputs[0].Value())) + if fwdType == "" { + fwdType = model.ForwardLocal + } + localPort := 0 + fmt.Sscanf(fm.inputs[2].Value(), "%d", &localPort) + remotePort := 0 + fmt.Sscanf(fm.inputs[4].Value(), "%d", &remotePort) + + fwd := &model.Forward{ + ServerID: fm.serverID, + Type: fwdType, + LocalAddr: fm.inputs[1].Value(), + LocalPort: localPort, + RemoteAddr: fm.inputs[3].Value(), + RemotePort: remotePort, + } + + if SaveForward == nil { + return saveDoneMsg{err: fmt.Errorf("forward storage is unavailable")} + } + if err := SaveForward(fwd); err != nil { + return saveDoneMsg{err: err} + } + return saveDoneMsg{} + } +} + +func (fm *forwardFormModel) View() string { + var b strings.Builder + b.WriteString(titleStyle.Render("Add Port Forward")) + b.WriteString("\n\n") + + for i := range fm.inputs { + b.WriteString(fm.inputs[i].View()) + b.WriteString("\n") + if i == 0 && fm.showList { + b.WriteString("\n" + renderDropdown(fm.typeList) + "\n") + b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)) + return b.String() + } + } + + // Preview + fwdType := strings.TrimSpace(fm.inputs[0].Value()) + localAddr := fm.inputs[1].Value() + localPort := fm.inputs[2].Value() + remoteAddr := fm.inputs[3].Value() + remotePort := fm.inputs[4].Value() + + if fwdType != "" && localPort != "" { + b.WriteString("\n" + sectionStyle.Render("Preview") + "\n") + switch fwdType { + case "local": + b.WriteString(fmt.Sprintf(" -L %s:%s:%s:%s\n", localAddr, localPort, remoteAddr, remotePort)) + case "remote": + b.WriteString(fmt.Sprintf(" -R %s:%s:%s:%s\n", remoteAddr, remotePort, localAddr, localPort)) + case "dynamic": + b.WriteString(fmt.Sprintf(" -D %s:%s\n", localAddr, localPort)) + } + b.WriteString(" -o ExitOnForwardFailure=yes\n") + } + + button := "\n[ Save ]" + if fm.focusIdx == len(fm.inputs) { + button = selectedStyle.Render(button) + } + b.WriteString(button) + b.WriteString("\n\n") + + if fm.err != nil { + b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n\n") + } + if fm.saved { + b.WriteString(successStyle.Render("✓ Saved.") + "\n\n") + } + + b.WriteString(renderHelp([]helpItem{ + {Key: "Tab/↓", Action: "next"}, + {Key: "↑", Action: "prev"}, + {Key: "/", Action: "pick type"}, + {Key: "Enter", Action: "save"}, + {Key: "Esc", Action: "back"}, + }, fm.width)) + + return b.String() +}