Files
ssh-access-tui/internal/keymgr/pack.go
T
2026-07-23 12:38:13 +03:00

100 lines
3.4 KiB
Go

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. proxyJump
// selects the client config/instructions style (see model.User.ProxyJump);
// when true, local ports are never allocated at all — they're not needed.
func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, forwards []model.Forward, proxyJump bool) (*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)
var cfg, instr string
if proxyJump {
cfg = RenderClientConfigProxyJump(jumpHost, jumpPort, username, keyFileName, forwards)
instr = RenderInstructionsProxyJump(username, keyFileName, forwards)
} else {
allocated := AllocateLocalPorts(forwards)
cfg = RenderClientConfig(jumpHost, jumpPort, username, keyFileName, allocated)
instr = RenderInstructions(username, keyFileName, allocated)
}
if err := os.WriteFile(filepath.Join(userScratch, "config"), []byte(cfg), 0o600); err != nil {
return nil, fmt.Errorf("write config: %w", err)
}
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)
}