init
This commit is contained in:
commit
23c7133fa9
33 files changed
+2896
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user