v0.2.0
This commit is contained in:
1 parent
23c7133fa9
commit
8c1b93c14e
16 files changed
+1187
-295
No files matched your search
@@ -3,42 +3,37 @@ 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.
|
||||
// Windows' built-in OpenSSH client, macOS, and Linux). It defines exactly
|
||||
// one connection, "jump", carrying one LocalForward per allowed service —
|
||||
// the user opens it once (see INSTRUCTIONS.txt) and every service becomes
|
||||
// reachable at once on the listed local ports, for as long as that one
|
||||
// connection stays open.
|
||||
//
|
||||
// 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 {
|
||||
// these destinations 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, forwards []AllocatedForward) 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, "# Generated %s. Regenerated whenever your allowed services change.\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)
|
||||
fmt.Fprintf(&b, " IdentitiesOnly yes\n")
|
||||
for _, f := range forwards {
|
||||
comment := f.HostName + "/" + f.ServiceName
|
||||
if f.Description != "" {
|
||||
comment += ": " + f.Description
|
||||
}
|
||||
b.WriteString("\n")
|
||||
fmt.Fprintf(&b, " LocalForward %d %s:%d # %s\n", f.LocalPort, f.Address, f.Port, comment)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package keymgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
)
|
||||
|
||||
// ConfigOnlyResult describes a lightweight config-refresh archive: no
|
||||
// keypair is generated or touched.
|
||||
type ConfigOnlyResult struct {
|
||||
Username string
|
||||
ArchivePath string
|
||||
}
|
||||
|
||||
// BuildConfigOnlyArchive packages just a fresh client config + instructions
|
||||
// (no key files at all) reflecting the user's CURRENT allowed forwards.
|
||||
//
|
||||
// This exists for the common case where only a user's group membership
|
||||
// changed: server-side access is already updated immediately (see
|
||||
// sysadm.WriteAuthorizedKeys / resyncGroupUsers, called the moment groups
|
||||
// change), and their existing private key is still perfectly valid — only
|
||||
// the local ssh config listing which services are reachable is now stale.
|
||||
// Reissuing a whole new keypair for that would be needless churn (and
|
||||
// needless key material handed around). The config's IdentityFile line
|
||||
// references the same deterministic filename GeneratePair always used
|
||||
// (username + "_jump_key"), so it matches the key the user already has.
|
||||
func BuildConfigOnlyArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, forwards []model.Forward) (*ConfigOnlyResult, error) {
|
||||
userScratch := filepath.Join(scratchDir, username+"_configonly")
|
||||
if err := os.MkdirAll(userScratch, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create scratch dir: %w", err)
|
||||
}
|
||||
defer Wipe(userScratch)
|
||||
|
||||
keyFileName := username + "_jump_key"
|
||||
allocated := AllocateLocalPorts(forwards)
|
||||
|
||||
cfg := RenderClientConfig(jumpHost, jumpPort, username, keyFileName, allocated)
|
||||
if err := os.WriteFile(filepath.Join(userScratch, "config"), []byte(cfg), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
|
||||
instr := RenderConfigUpdateInstructions(username, allocated)
|
||||
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+"_config_update.7z")
|
||||
_ = os.Remove(archivePath)
|
||||
|
||||
cmd := exec.Command("7z", "a",
|
||||
"-p"+password,
|
||||
"-mhe=on",
|
||||
"-mx=9",
|
||||
archivePath,
|
||||
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 &ConfigOnlyResult{Username: username, ArchivePath: archivePath}, nil
|
||||
}
|
||||
+104
-55
@@ -4,79 +4,128 @@ 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 {
|
||||
// RenderInstructions produces a plain-Russian, no-jargon README for a
|
||||
// non-technical Windows user whose only guaranteed tool is the OpenSSH
|
||||
// client bundled with Windows 10/11. Deliberately says nothing about
|
||||
// "rights", "forwarding", "jump hosts" etc — just: run this one command and
|
||||
// keep it open, then these local ports lead to these services.
|
||||
func RenderInstructions(username, keyFileName string, forwards []AllocatedForward) 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("КАК ПОДКЛЮЧИТЬСЯ (Windows)\n")
|
||||
b.WriteString("===========================\n\n")
|
||||
fmt.Fprintf(&b, "Ваш логин: %s\n\n", username)
|
||||
|
||||
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("В этом архиве 4 файла:\n")
|
||||
fmt.Fprintf(&b, " - %s — ваш секретный ключ (никому не передавайте и не пересылайте)\n", keyFileName)
|
||||
fmt.Fprintf(&b, " - %s.pub — открытая часть ключа (можно не трогать)\n", keyFileName)
|
||||
b.WriteString(" - config — настройка подключения\n")
|
||||
b.WriteString(" - INSTRUCTIONS.txt — этот файл\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("ШАГ 1. Проверьте, что на компьютере есть SSH\n")
|
||||
b.WriteString("--------------------------------------------\n")
|
||||
b.WriteString("Откройте программу PowerShell (найдите её через Пуск) и введите:\n\n")
|
||||
b.WriteString(" ssh -V\n\n")
|
||||
b.WriteString("Если появилась ошибка — зайдите в Параметры Windows -> Приложения ->\n")
|
||||
b.WriteString("Дополнительные компоненты -> Добавить компонент -> найдите «OpenSSH Client»\n")
|
||||
b.WriteString("-> Установить.\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("ШАГ 2. Разложите файлы по нужной папке\n")
|
||||
b.WriteString("---------------------------------------\n")
|
||||
b.WriteString("Создайте (если её ещё нет) папку:\n\n")
|
||||
b.WriteString(" C:\\Users\\ВАШЕ_ИМЯ\\.ssh\\\n\n")
|
||||
fmt.Fprintf(&b, "И скопируйте туда все 3 файла из архива (%s, %s.pub, config).\n", keyFileName, keyFileName)
|
||||
b.WriteString("Важно: у файла config не должно быть никакого расширения (не config.txt).\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("ШАГ 3. Настройте доступ к ключу\n")
|
||||
b.WriteString("---------------------------------\n")
|
||||
b.WriteString("В PowerShell по очереди выполните две команды (замените ничего не нужно,\n")
|
||||
b.WriteString("копируйте как есть):\n\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("ШАГ 4. Подключитесь\n")
|
||||
b.WriteString("---------------------\n")
|
||||
b.WriteString("В PowerShell введите команду:\n\n")
|
||||
b.WriteString(" ssh -N jump\n\n")
|
||||
b.WriteString("Окно «зависнет» и ничего не будет происходить — это нормально, так и должно\n")
|
||||
b.WriteString("быть. НЕ ЗАКРЫВАЙТЕ это окно — пока оно открыто, работают все сервисы из\n")
|
||||
b.WriteString("таблицы ниже. Закрыли окно (или выключили компьютер) — доступ пропал, просто\n")
|
||||
b.WriteString("откройте заново командой выше, когда снова понадобится.\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("ШАГ 5. Пользуйтесь\n")
|
||||
b.WriteString("--------------------\n")
|
||||
b.WriteString("Эта настройка открывает у вас на компьютере (локально) следующие адреса.\n")
|
||||
b.WriteString("В программе, которая должна подключиться к сервису (браузер, клиент базы\n")
|
||||
b.WriteString("данных и т.п.), укажите вместо настоящего адреса тот, что указан ниже как\n")
|
||||
b.WriteString("«localhost:порт» — и она подключится к нужному сервису через это соединение.\n\n")
|
||||
|
||||
// A numbered list rather than an aligned table: this file is opened in
|
||||
// Notepad with whatever font the user has, so column alignment based on
|
||||
// character counts (especially mixing Cyrillic and digits) can't be
|
||||
// relied on to line up — a simple list reads correctly regardless.
|
||||
for i, f := range forwards {
|
||||
what := f.ServiceName
|
||||
if f.Description != "" {
|
||||
what = fmt.Sprintf("%s (%s)", f.ServiceName, f.Description)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
fmt.Fprintf(&b, " %d. localhost:%d\n", i+1, f.LocalPort)
|
||||
fmt.Fprintf(&b, " Это: %s\n", what)
|
||||
fmt.Fprintf(&b, " Реально находится на: %s:%d\n\n", f.HostName, f.Port)
|
||||
}
|
||||
if len(forwards) == 0 {
|
||||
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")
|
||||
b.WriteString("ЕСЛИ ЧТО-ТО НЕ РАБОТАЕТ\n")
|
||||
b.WriteString("------------------------\n")
|
||||
b.WriteString("Обратитесь к администратору, который выдал вам этот архив. Возможно,\n")
|
||||
b.WriteString("изменился список сервисов — тогда вам пришлют обновлённый архив.\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RenderConfigUpdateInstructions is a short follow-up notice for a user who
|
||||
// already completed the full setup once (RenderInstructions) and just needs
|
||||
// to know their service list changed — their key did not change and needs
|
||||
// no attention, only the config file is replaced.
|
||||
func RenderConfigUpdateInstructions(username string, forwards []AllocatedForward) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("ОБНОВЛЕНИЕ СПИСКА СЕРВИСОВ\n")
|
||||
b.WriteString("===========================\n\n")
|
||||
fmt.Fprintf(&b, "Ваш логин: %s\n\n", username)
|
||||
b.WriteString("Ваш ключ НЕ менялся — трогать его не нужно, продолжайте пользоваться тем же.\n")
|
||||
b.WriteString("Изменился только список сервисов, поэтому нужно заменить один файл.\n\n")
|
||||
|
||||
b.WriteString("Замените файл config (из этого архива) на:\n\n")
|
||||
b.WriteString(" C:\\Users\\ВАШЕ_ИМЯ\\.ssh\\config\n\n")
|
||||
b.WriteString("(тот же путь, что и раньше — просто перезапишите старый файл новым).\n\n")
|
||||
|
||||
b.WriteString("Дальше подключайтесь как обычно, командой:\n\n")
|
||||
b.WriteString(" ssh -N jump\n\n")
|
||||
|
||||
b.WriteString("Актуальный список сервисов:\n\n")
|
||||
for i, f := range forwards {
|
||||
what := f.ServiceName
|
||||
if f.Description != "" {
|
||||
what = fmt.Sprintf("%s (%s)", f.ServiceName, f.Description)
|
||||
}
|
||||
fmt.Fprintf(&b, " %d. localhost:%d\n", i+1, f.LocalPort)
|
||||
fmt.Fprintf(&b, " Это: %s\n", what)
|
||||
fmt.Fprintf(&b, " Реально находится на: %s:%d\n\n", f.HostName, f.Port)
|
||||
}
|
||||
if len(forwards) == 0 {
|
||||
b.WriteString(" (сервисы не назначены — обратитесь к администратору)\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("Если что-то не работает — обратитесь к администратору.\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -23,7 +23,7 @@ type BuildResult struct {
|
||||
// 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) {
|
||||
func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, forwards []model.Forward) (*BuildResult, error) {
|
||||
userScratch := filepath.Join(scratchDir, username)
|
||||
if err := os.MkdirAll(userScratch, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create scratch dir: %w", err)
|
||||
@@ -36,12 +36,14 @@ func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, usernam
|
||||
}
|
||||
keyFileName := filepath.Base(kp.PrivateKeyPath)
|
||||
|
||||
cfg := RenderClientConfig(jumpHost, jumpPort, username, keyFileName, hosts)
|
||||
allocated := AllocateLocalPorts(forwards)
|
||||
|
||||
cfg := RenderClientConfig(jumpHost, jumpPort, username, keyFileName, allocated)
|
||||
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)
|
||||
instr := RenderInstructions(username, keyFileName, allocated)
|
||||
if err := os.WriteFile(filepath.Join(userScratch, "INSTRUCTIONS.txt"), []byte(instr), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write instructions: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package keymgr
|
||||
|
||||
import "ssh-access-tui/internal/model"
|
||||
|
||||
// AllocatedForward is a model.Forward together with the local port it will
|
||||
// be bound to on the user's machine.
|
||||
type AllocatedForward struct {
|
||||
model.Forward
|
||||
LocalPort int
|
||||
}
|
||||
|
||||
// localPortBase is where fallback local ports are handed out from once a
|
||||
// forward's natural remote port is already taken locally by another
|
||||
// forward. Chosen high enough to avoid colliding with well-known ports the
|
||||
// user's own machine might already be using.
|
||||
const localPortBase = 20000
|
||||
|
||||
// AllocateLocalPorts assigns every forward a local port, all of which the
|
||||
// user opens at once over a single ssh connection (one LocalForward line
|
||||
// per forward — see RenderClientConfig). This is the actual answer to
|
||||
// "avoid collisions when forwarding everything simultaneously": many hosts
|
||||
// commonly expose the same remote port (e.g. 22, or an app port like 7888
|
||||
// repeated across ten hosts), but only one of them can bind to that same
|
||||
// port number locally. Each forward keeps its remote port as the local
|
||||
// port when that's still free (so the common case reads naturally, e.g.
|
||||
// remote 7888 -> local 7888), and falls back to the next free port from
|
||||
// localPortBase upward on collision. Assignment is a pure function of the
|
||||
// input order and set, so re-exporting an unchanged host list reproduces
|
||||
// the same local ports.
|
||||
func AllocateLocalPorts(forwards []model.Forward) []AllocatedForward {
|
||||
used := make(map[int]bool, len(forwards))
|
||||
next := localPortBase
|
||||
out := make([]AllocatedForward, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
lp := f.Port
|
||||
if lp <= 0 || lp > 65535 || used[lp] {
|
||||
for used[next] {
|
||||
next++
|
||||
}
|
||||
lp = next
|
||||
next++
|
||||
}
|
||||
used[lp] = true
|
||||
out = append(out, AllocatedForward{Forward: f, LocalPort: lp})
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user