Sitelet https://github.com/NodeOps-app/createos-cli/commit/56e6a7ad7b49b587f4fd89a945404c773f5e1290
Skip to content

Commit 56e6a7a

Browse files
fix: auto sweep and name alias
1 parent 78cf9a0 commit 56e6a7a

1 file changed

Lines changed: 146 additions & 4 deletions

File tree

‎cmd/sandbox/editor.go‎

Lines changed: 146 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ var (
2828
editorUserRE = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$`)
2929
editorHostRE = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,253}$`)
3030
editorAliasRE = regexp.MustCompile(`^sb-[0-9a-z]{26}$`)
31+
// editorNameRE gates a sandbox's friendly name before it lands on the
32+
// `Host <id> <name>` line. SSH's Host aliases forbid whitespace, `?`,
33+
// and `*`; we further restrict to plain identifier chars so a mutated
34+
// name can't smuggle newlines / control bytes.
35+
editorNameRE = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`)
3136
)
3237

3338
// One block per sandbox id in ~/.ssh/config; re-runs rewrite in place.
@@ -105,6 +110,10 @@ positional).`,
105110
Name: "remove",
106111
Usage: "Remove this sandbox's block from ~/.ssh/config and exit",
107112
},
113+
&cli.BoolFlag{
114+
Name: "no-sweep",
115+
Usage: "Skip auto-cleanup of ~/.ssh/config blocks for sandboxes that no longer exist",
116+
},
108117
},
109118
Action: runEditor,
110119
}
@@ -240,12 +249,26 @@ func runEditor(c *cli.Context) error {
240249
}
241250
sp.Success("sshd running in the sandbox")
242251

252+
// Sweep other ~/.ssh/config blocks that reference sandboxes the user
253+
// no longer owns / that no longer exist. Runs before we write the
254+
// fresh block so the atomic tmp-rewrite flushes the pruning too.
255+
if !c.Bool("no-sweep") {
256+
if pruned, serr := sweepStaleBlocks(c.Context, client, alias); serr == nil && len(pruned) > 0 {
257+
pterm.Info.Printfln("cleaned up %d stale entr%s: %s",
258+
len(pruned), plural(len(pruned), "y", "ies"), strings.Join(pruned, ", "))
259+
}
260+
}
261+
243262
// --- 6. Write the ~/.ssh/config block -----------------------------------
244263
gwHost, gwPort := gatewayAddr()
245264
if !editorHostRE.MatchString(gwHost) {
246265
return fmt.Errorf("refusing shell-unsafe gateway host %q", gwHost)
247266
}
248-
block, err := renderSSHBlock(alias, mode, id, sbIP, gwHost, gwPort, user, privPath)
267+
sbName := ""
268+
if sb.Name != nil {
269+
sbName = *sb.Name
270+
}
271+
block, err := renderSSHBlock(alias, mode, id, sbIP, gwHost, gwPort, user, privPath, sbName)
249272
if err != nil {
250273
return err
251274
}
@@ -441,10 +464,23 @@ func bytesTrimRight(b []byte, cutset string) []byte {
441464
return b
442465
}
443466

467+
// hostLine builds the `Host` line — dual alias when a friendly name
468+
// passes the regex, id-only otherwise. Same-name-as-id, blanks, and
469+
// anything with unsafe chars fall back to id-only.
470+
func hostLine(alias, name string) string {
471+
name = strings.TrimSpace(name)
472+
if name == "" || name == alias || !editorNameRE.MatchString(name) {
473+
return alias
474+
}
475+
return alias + " " + name
476+
}
477+
444478
// renderSSHBlock builds the ~/.ssh/config stanza for the sandbox.
445-
func renderSSHBlock(alias, mode, sandboxID, sbIP, gwHost string, gwPort int, user, identity string) (string, error) {
479+
// `name` is the sandbox's friendly name; empty or unsafe → id-only alias.
480+
func renderSSHBlock(alias, mode, sandboxID, sbIP, gwHost string, gwPort int, user, identity, name string) (string, error) {
446481
begin := fmt.Sprintf(sshConfigBlockBegin, alias)
447482
end := fmt.Sprintf(sshConfigBlockEnd, alias)
483+
host := hostLine(alias, name)
448484
switch mode {
449485
case "vpn":
450486
return fmt.Sprintf(`%s
@@ -456,7 +492,7 @@ Host %s
456492
StrictHostKeyChecking accept-new
457493
UserKnownHostsFile ~/.ssh/known_hosts_createos
458494
%s
459-
`, begin, alias, sbIP, user, identity, end), nil
495+
`, begin, host, sbIP, user, identity, end), nil
460496
case "tunnel":
461497
// The inner `ssh -W` for the gateway needs its own
462498
// StrictHostKeyChecking + UserKnownHostsFile — it doesn't inherit
@@ -472,7 +508,7 @@ Host %s
472508
StrictHostKeyChecking accept-new
473509
UserKnownHostsFile ~/.ssh/known_hosts_createos
474510
%s
475-
`, begin, alias, user, identity, sandboxID, gwHost, gwPort, identity, end), nil
511+
`, begin, host, user, identity, sandboxID, gwHost, gwPort, identity, end), nil
476512
default:
477513
return "", fmt.Errorf("unknown mode %q", mode)
478514
}
@@ -763,3 +799,109 @@ func printFollowup(alias string) {
763799
pterm.Info.Printfln(" code --remote ssh-remote+%s /root", alias)
764800
pterm.Info.Printfln(" cursor --remote ssh-remote+%s /root", alias)
765801
}
802+
803+
// sweepStaleBlocks walks ~/.ssh/config for our editor-owned blocks and
804+
// prunes any whose sandbox the server can't find or considers dead
805+
// (destroyed / failed). Skips the alias the caller is about to write.
806+
// Returns the aliases actually removed.
807+
//
808+
// Concurrency budget: parallel GetSandbox calls with a small pool so the
809+
// sweep doesn't inflate editor-command latency on users with many
810+
// entries. Errors that aren't "not found" (network hiccups, 5xx) leave
811+
// the block alone — we never destroy user config on transient failures.
812+
func sweepStaleBlocks(ctx context.Context, client *api.SandboxClient, keep string) ([]string, error) {
813+
path, err := sshConfigPath()
814+
if err != nil {
815+
return nil, err
816+
}
817+
data, err := os.ReadFile(path) //nolint:gosec // #nosec G304 -- own ssh config
818+
if err != nil {
819+
if os.IsNotExist(err) {
820+
return nil, nil
821+
}
822+
return nil, err
823+
}
824+
825+
aliases := collectCreateosAliases(string(data))
826+
if len(aliases) == 0 {
827+
return nil, nil
828+
}
829+
830+
// Check each alias in parallel — bounded pool of 8.
831+
type verdict struct {
832+
alias string
833+
gone bool
834+
}
835+
sem := make(chan struct{}, 8)
836+
ch := make(chan verdict, len(aliases))
837+
for _, a := range aliases {
838+
if a == keep {
839+
continue
840+
}
841+
sem <- struct{}{}
842+
go func() {
843+
defer func() { <-sem }()
844+
gone := isSandboxGone(ctx, client, a)
845+
ch <- verdict{alias: a, gone: gone}
846+
}()
847+
}
848+
// Drain semaphore so we know all goroutines finished.
849+
for i := 0; i < cap(sem); i++ {
850+
sem <- struct{}{}
851+
}
852+
close(ch)
853+
854+
pruned := make([]string, 0, len(aliases))
855+
for v := range ch {
856+
if !v.gone {
857+
continue
858+
}
859+
if _, rerr := removeSSHBlock(v.alias); rerr == nil {
860+
removeDedicatedKey(v.alias)
861+
pruned = append(pruned, v.alias)
862+
}
863+
}
864+
return pruned, nil
865+
}
866+
867+
// collectCreateosAliases returns every alias marked by our "# BEGIN
868+
// createos <alias>" delimiter in ~/.ssh/config.
869+
func collectCreateosAliases(cfg string) []string {
870+
out := make([]string, 0)
871+
const prefix = "# BEGIN createos "
872+
for _, line := range strings.Split(cfg, "\n") {
873+
if !strings.HasPrefix(line, prefix) {
874+
continue
875+
}
876+
alias := strings.TrimSpace(strings.TrimPrefix(line, prefix))
877+
if editorAliasRE.MatchString(alias) {
878+
out = append(out, alias)
879+
}
880+
}
881+
return out
882+
}
883+
884+
// isSandboxGone returns true only when we're confident the sandbox no
885+
// longer belongs to the user or has reached a terminal state. A network
886+
// error, 5xx, or auth failure returns false so we don't nuke config on
887+
// transient issues.
888+
func isSandboxGone(ctx context.Context, client *api.SandboxClient, alias string) bool {
889+
sctx, cancel := context.WithTimeout(ctx, 4*time.Second)
890+
defer cancel()
891+
sb, err := client.GetSandbox(sctx, alias)
892+
if err != nil {
893+
return api.IsNotFound(err)
894+
}
895+
switch sb.Status {
896+
case "destroyed", "failed":
897+
return true
898+
}
899+
return false
900+
}
901+
902+
func plural(n int, singular, plural string) string {
903+
if n == 1 {
904+
return singular
905+
}
906+
return plural
907+
}

0 commit comments

Comments
 (0)