This commit is contained in:
redlineo committed 2026-07-22 14:14:53 +03:00
commit 23c7133fa9
33 files changed
+2896

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
package keymgr
import (
"fmt"
"strings"
"ssh-access-tui/internal/model"
)
// RenderClientConfig produces an OpenSSH client config (works identically on
// Windows' built-in OpenSSH client, macOS, and Linux) that gives the user a
// "jump" alias plus one alias per host they're allowed to reach.
//
// This file is convenience only, not a security boundary: nothing stops a
// user from editing it or invoking ssh by hand. The actual restriction to
// these hosts is enforced server-side via authorized_keys permitopen=
// (see internal/sysadm/authorizedkeys.go) independent of what this file says.
func RenderClientConfig(jumpHost string, jumpPort int, username, keyFileName string, hosts []model.Host) string {
var b strings.Builder
fmt.Fprintf(&b, "# ssh-access-tui client config for %s\n", username)
fmt.Fprintf(&b, "# Generated %s. Regenerated whenever your allowed host list changes.\n\n", genTimestamp())
fmt.Fprintf(&b, "Host jump\n")
fmt.Fprintf(&b, " HostName %s\n", jumpHost)
fmt.Fprintf(&b, " Port %d\n", jumpPort)
fmt.Fprintf(&b, " User %s\n", username)
fmt.Fprintf(&b, " IdentityFile ~/.ssh/%s\n", keyFileName)
fmt.Fprintf(&b, " IdentitiesOnly yes\n\n")
for _, h := range hosts {
fmt.Fprintf(&b, "Host %s\n", h.Name)
fmt.Fprintf(&b, " HostName %s\n", h.Address)
fmt.Fprintf(&b, " Port %d\n", h.Port)
fmt.Fprintf(&b, " User %s\n", username)
fmt.Fprintf(&b, " ProxyJump jump\n")
fmt.Fprintf(&b, " IdentityFile ~/.ssh/%s\n", keyFileName)
fmt.Fprintf(&b, " IdentitiesOnly yes\n")
if h.Description != "" {
fmt.Fprintf(&b, " # %s\n", h.Description)
}
b.WriteString("\n")
}
return b.String()
}
+82
View File
@@ -0,0 +1,82 @@
package keymgr
import (
"fmt"
"strings"
"time"
"ssh-access-tui/internal/model"
)
func genTimestamp() string {
return time.Now().UTC().Format("2006-01-02 15:04 UTC")
}
// RenderInstructions produces a plain-text README aimed at a Windows user
// whose only guaranteed tool is the OpenSSH client bundled with Windows
// 10/11 ("Optional Features" -> OpenSSH Client, or already present).
func RenderInstructions(username, jumpHost, keyFileName string, hosts []model.Host) string {
var b strings.Builder
b.WriteString("SSH ACCESS — SETUP INSTRUCTIONS (Windows)\n")
b.WriteString("==========================================\n\n")
fmt.Fprintf(&b, "Account: %s\n", username)
fmt.Fprintf(&b, "Jump host: %s\n\n", jumpHost)
b.WriteString("This archive contains:\n")
fmt.Fprintf(&b, " - %s your PRIVATE key (keep secret, do not share or send onward)\n", keyFileName)
fmt.Fprintf(&b, " - %s.pub the matching public key (for reference only)\n", keyFileName)
b.WriteString(" - config SSH client config with ready-made host shortcuts\n")
b.WriteString(" - INSTRUCTIONS.txt this file\n\n")
b.WriteString("1. Check you have the SSH client\n")
b.WriteString(" Open PowerShell and run:\n")
b.WriteString(" ssh -V\n")
b.WriteString(" If this fails: Settings -> Apps -> Optional Features -> Add a feature ->\n")
b.WriteString(" \"OpenSSH Client\" -> Install. (Windows 10 1809+/11 ship this by default.)\n\n")
b.WriteString("2. Put the files in place\n")
b.WriteString(" Create the folder (if it doesn't exist) and copy the 3 files into it:\n")
b.WriteString(" %USERPROFILE%\\.ssh\\\n")
b.WriteString(" i.e. C:\\Users\\<you>\\.ssh\\\n")
b.WriteString(" The \"config\" file should be merged with (or placed as) that exact filename,\n")
b.WriteString(" with no file extension: C:\\Users\\<you>\\.ssh\\config\n\n")
b.WriteString("3. Fix the private key permissions (Windows OpenSSH refuses keys that are\n")
b.WriteString(" readable by other users). In PowerShell:\n")
fmt.Fprintf(&b, " icacls $env:USERPROFILE\\.ssh\\%s /inheritance:r\n", keyFileName)
fmt.Fprintf(&b, " icacls $env:USERPROFILE\\.ssh\\%s /grant:r \"$env:USERNAME:(R)\"\n\n", keyFileName)
b.WriteString("4. Connect\n")
b.WriteString(" You have shortcuts for each host you're allowed to reach. To open a local\n")
b.WriteString(" port forward to one of them (example: a web service on port 8443):\n\n")
if len(hosts) > 0 {
h := hosts[0]
fmt.Fprintf(&b, " ssh -N -L 8443:localhost:%d %s\n", h.Port, h.Name)
b.WriteString(" Then browse to https://localhost:8443 (or whatever local port you chose)\n")
b.WriteString(" while that command keeps running. Ctrl+C to stop.\n\n")
} else {
b.WriteString(" ssh -N -L <local-port>:localhost:<remote-port> <host-alias>\n\n")
}
b.WriteString(" Your allowed hosts (aliases from the config file):\n")
for _, h := range hosts {
fmt.Fprintf(&b, " - %-20s -> %s:%d", h.Name, h.Address, h.Port)
if h.Description != "" {
fmt.Fprintf(&b, " (%s)", h.Description)
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString("5. You will NOT get an interactive shell\n")
b.WriteString(" Running plain \"ssh jump\" will connect and immediately close — this account\n")
b.WriteString(" is jump/port-forwarding only, by design. Use the -L/-D forwarding commands\n")
b.WriteString(" above (via the host aliases, which already route through the jump host).\n\n")
b.WriteString("If access stops working, contact your administrator — your account may have\n")
b.WriteString("been locked, or the allowed host list may have changed (ask for an updated\n")
b.WriteString("config in that case).\n")
return b.String()
}
+66
View File
@@ -0,0 +1,66 @@
// Package keymgr generates per-user SSH keypairs and packages them, together
// with a ready-to-use client config and Windows-oriented instructions, into
// a password-protected 7z archive for delivery.
//
// The private key only ever exists on disk for the lifetime of one export
// operation: generated into a temp directory, packed into the archive, then
// securely wiped (see Wipe in pack.go). It is never written into the state
// store and therefore never ends up in a backup.
package keymgr
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// KeyPair is the result of generating one user's SSH keypair.
type KeyPair struct {
PrivateKeyPath string
PublicKeyLine string // full "ssh-ed25519 AAAA... comment" line for authorized_keys
Fingerprint string // "SHA256:xxxxx" as reported by ssh-keygen -lf
}
// GeneratePair creates a new passphrase-less ed25519 keypair for username in
// dir. The key itself has no passphrase because the archive it ships in is
// already encrypted (see pack.go); a second passphrase would only add
// friction without a corresponding threat it defends against once the
// archive's own encryption and the server-side no-shell restriction are in
// place.
func GeneratePair(dir, username string) (*KeyPair, error) {
priv := filepath.Join(dir, username+"_jump_key")
pub := priv + ".pub"
cmd := exec.Command("ssh-keygen",
"-t", "ed25519",
"-N", "", // no passphrase
"-C", username+"@jump",
"-f", priv,
)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("ssh-keygen: %w: %s", err, out)
}
pubData, err := os.ReadFile(pub)
if err != nil {
return nil, fmt.Errorf("read public key: %w", err)
}
fpOut, err := exec.Command("ssh-keygen", "-lf", pub).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("fingerprint: %w: %s", err, fpOut)
}
fields := strings.Fields(string(fpOut))
fingerprint := ""
if len(fields) >= 2 {
fingerprint = fields[1]
}
return &KeyPair{
PrivateKeyPath: priv,
PublicKeyLine: strings.TrimSpace(string(pubData)),
Fingerprint: fingerprint,
}, nil
}
+89
View File
@@ -0,0 +1,89 @@
package keymgr
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"ssh-access-tui/internal/model"
)
// BuildResult describes one user's finished delivery archive.
type BuildResult struct {
Username string
ArchivePath string
Fingerprint string
PublicKeyLine string // capture before the scratch dir (and its .pub file) is wiped
}
// BuildUserArchive generates a fresh keypair for username, writes the
// client config and Windows instructions alongside it in a scratch
// directory, packs everything into a single AES-256-encrypted 7z archive
// under outDir, and then wipes the scratch directory (including the
// plaintext private key) from disk. password is shared across one export
// batch and must be communicated to the recipient out-of-band.
func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, hosts []model.Host) (*BuildResult, error) {
userScratch := filepath.Join(scratchDir, username)
if err := os.MkdirAll(userScratch, 0o700); err != nil {
return nil, fmt.Errorf("create scratch dir: %w", err)
}
defer Wipe(userScratch)
kp, err := GeneratePair(userScratch, username)
if err != nil {
return nil, fmt.Errorf("generate keypair: %w", err)
}
keyFileName := filepath.Base(kp.PrivateKeyPath)
cfg := RenderClientConfig(jumpHost, jumpPort, username, keyFileName, hosts)
if err := os.WriteFile(filepath.Join(userScratch, "config"), []byte(cfg), 0o600); err != nil {
return nil, fmt.Errorf("write config: %w", err)
}
instr := RenderInstructions(username, jumpHost, keyFileName, hosts)
if err := os.WriteFile(filepath.Join(userScratch, "INSTRUCTIONS.txt"), []byte(instr), 0o600); err != nil {
return nil, fmt.Errorf("write instructions: %w", err)
}
if err := os.MkdirAll(outDir, 0o750); err != nil {
return nil, fmt.Errorf("create output dir: %w", err)
}
archivePath := filepath.Join(outDir, username+"_ssh_access.7z")
_ = os.Remove(archivePath) // 7z refuses to overwrite silently-appends; start clean
cmd := exec.Command("7z", "a",
"-p"+password,
"-mhe=on", // encrypt file names/headers too, not just contents
"-mx=9",
archivePath,
filepath.Join(userScratch, keyFileName),
filepath.Join(userScratch, keyFileName+".pub"),
filepath.Join(userScratch, "config"),
filepath.Join(userScratch, "INSTRUCTIONS.txt"),
)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("7z: %w: %s", err, out)
}
return &BuildResult{
Username: username,
ArchivePath: archivePath,
Fingerprint: kp.Fingerprint,
PublicKeyLine: kp.PublicKeyLine,
}, nil
}
// Wipe securely removes a scratch directory (best-effort shred of file
// contents, then unlink) so the private key does not linger in free disk
// space or in any backup that might sweep the tool's working directory.
func Wipe(dir string) {
_ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
_ = exec.Command("shred", "-u", "-n", "1", path).Run()
return nil
})
_ = os.RemoveAll(dir)
}
+30
View File
@@ -0,0 +1,30 @@
package keymgr
import (
"crypto/rand"
"math/big"
)
// charset avoids visually ambiguous characters (0/O, 1/l/I) since this
// password is normally read aloud or typed by hand on the recipient side.
const passwordCharset = "23456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz"
// GenerateBatchPassword returns a cryptographically random password shared
// by every archive produced in one export batch. It is shown to the admin
// exactly once (see the export result screen) and must be relayed to each
// recipient over a channel separate from the archive itself.
func GenerateBatchPassword(length int) (string, error) {
if length <= 0 {
length = 20
}
out := make([]byte, length)
max := big.NewInt(int64(len(passwordCharset)))
for i := range out {
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
out[i] = passwordCharset[n.Int64()]
}
return string(out), nil
}