package sysadm import ( "fmt" "os" "os/user" "path/filepath" "strconv" "strings" "ssh-access-tui/internal/model" ) // managedMarker delimits the block we own inside authorized_keys, so the // file can in principle carry other content without us clobbering it. const managedMarker = "# ssh-access-tui managed key — do not edit by hand, use the TUI" // BuildKeyOptions renders the authorized_keys option string that is the // actual security boundary for a jump/forward-only account: // - restrict disables pty, X11, agent-forwarding, user-rc, etc. // - port-forwarding re-enables (only) local/dynamic forwarding // - permitopen=... whitelists exactly the address:port pairs this key // may forward to — this is what makes "only these destinations" a // protocol-level guarantee rather than a client-side convention. Every // service on every host the user's groups grant becomes one // permitopen= entry here, independent of how many services share a // host or how many hosts share a port number. // // Reverse forwarding (-R) is intentionally never granted here. func BuildKeyOptions(forwards []model.Forward) string { if len(forwards) == 0 { // No forwards assigned yet: restrict everything, permit nothing, so // the key authenticates but cannot open forwarding channels anywhere. return "restrict" } opts := []string{"restrict", "port-forwarding"} for _, f := range forwards { opts = append(opts, fmt.Sprintf(`permitopen="%s:%s"`, f.Address, strconv.Itoa(f.Port))) } return strings.Join(opts, ",") } // WriteAuthorizedKeys writes the single managed key line (with its // permitopen restrictions) into ~/.ssh/authorized_keys, replacing // any previous ssh-access-tui-managed line for that user. func WriteAuthorizedKeys(username, pubKeyLine string, forwards []model.Forward) error { u, err := user.Lookup(username) if err != nil { return fmt.Errorf("lookup user %s: %w", username, err) } sshDir := filepath.Join(u.HomeDir, ".ssh") if err := os.MkdirAll(sshDir, 0o700); err != nil { return fmt.Errorf("create .ssh dir: %w", err) } line := fmt.Sprintf("%s %s", BuildKeyOptions(forwards), strings.TrimSpace(pubKeyLine)) content := managedMarker + "\n" + line + "\n" akPath := filepath.Join(sshDir, "authorized_keys") if err := os.WriteFile(akPath, []byte(content), 0o600); err != nil { return fmt.Errorf("write authorized_keys: %w", err) } if err := chownRecursive(sshDir, u); err != nil { return fmt.Errorf("chown .ssh dir: %w", err) } return nil } // DisableAuthorizedKeys comments out the managed key so the account cannot // authenticate at all, without deleting the record of what the key was // (useful when re-enabling a returning employee later). func DisableAuthorizedKeys(username string) error { u, err := user.Lookup(username) if err != nil { return fmt.Errorf("lookup user %s: %w", username, err) } akPath := filepath.Join(u.HomeDir, ".ssh", "authorized_keys") data, err := os.ReadFile(akPath) if os.IsNotExist(err) { return nil } if err != nil { return fmt.Errorf("read authorized_keys: %w", err) } lines := strings.Split(string(data), "\n") for i, l := range lines { if strings.HasPrefix(l, "restrict") { lines[i] = "# LOCKED: " + l } } return os.WriteFile(akPath, []byte(strings.Join(lines, "\n")), 0o600) } func chownRecursive(dir string, u *user.User) error { uid, err := strconv.Atoi(u.Uid) if err != nil { return err } gid, err := strconv.Atoi(u.Gid) if err != nil { return err } if err := os.Chown(dir, uid, gid); err != nil { return err } entries, err := os.ReadDir(dir) if err != nil { return err } for _, e := range entries { if err := os.Chown(filepath.Join(dir, e.Name()), uid, gid); err != nil { return err } } return nil }