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-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("КАК ПОДКЛЮЧИТЬСЯ (Windows)\n") b.WriteString("===========================\n\n") fmt.Fprintf(&b, "Ваш логин: %s\n\n", username) 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. Разложите файлы по нужной папке\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("ШАГ 2. Настройте доступ к файлу ключа\n") b.WriteString("----------------------------------------\n") b.WriteString("Windows откажется использовать секретный ключ, если его может прочитать\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("Если файлы лежат не в стандартной папке .ssh, а где-то ещё — замените путь\n") b.WriteString("в обеих командах на реальный путь к файлу ключа (в кавычках, как выше).\n\n") b.WriteString("ШАГ 3. Подключитесь\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("ШАГ 4. Пользуйтесь\n") b.WriteString("--------------------\n") b.WriteString("Эта настройка открывает у вас на компьютере (локально) следующие адреса.\n") b.WriteString("В программе, которая должна подключиться к сервису (браузер, клиент базы\n") b.WriteString("данных и т.п.), укажите вместо настоящего адреса тот, что указан ниже как\n") b.WriteString("«127.0.0.1:порт» — и она подключится к нужному сервису через это соединение.\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) } fmt.Fprintf(&b, " %d. 127.0.0.1:%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("ЕСЛИ ЧТО-ТО НЕ РАБОТАЕТ\n") b.WriteString("------------------------\n") b.WriteString("Написано «This account is currently not available» и соединение сразу\n") b.WriteString("обрывается? Это нормально, если вы вводили команду БЕЗ \"-N\" (например,\n") b.WriteString("просто \"ssh jump\") — обычный вход сюда специально отключён, используйте\n") b.WriteString("именно команду из шага 3 (\"ssh -N jump\").\n\n") b.WriteString("Если не работает именно шаг 3 или шаг 4 — обратитесь к администратору,\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. 127.0.0.1:%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() } // RenderInstructionsProxyJump is the ProxyJump-mode counterpart of // RenderInstructions: same audience and same 4-file archive, but each // service gets its own "Host " entry (see // RenderClientConfigProxyJump) reached with a plain "ssh " instead of // one always-open tunnel exposing local ports — steps 3/4 are adapted // accordingly. func RenderInstructionsProxyJump(username, keyFileName string, forwards []model.Forward) string { var b strings.Builder b.WriteString("КАК ПОДКЛЮЧИТЬСЯ (Windows)\n") b.WriteString("===========================\n\n") fmt.Fprintf(&b, "Ваш логин: %s\n\n", username) 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. Разложите файлы по нужной папке\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("ШАГ 2. Настройте доступ к файлу ключа\n") b.WriteString("----------------------------------------\n") b.WriteString("Windows откажется использовать секретный ключ, если его может прочитать\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("Если файлы лежат не в стандартной папке .ssh, а где-то ещё — замените путь\n") b.WriteString("в обеих командах на реальный путь к файлу ключа (в кавычках, как выше).\n\n") b.WriteString("ШАГ 3. Подключайтесь\n") b.WriteString("---------------------\n") b.WriteString("Никакое окно держать открытым не нужно. Когда понадобится один из сервисов\n") b.WriteString("из списка ниже, просто выполните в PowerShell соответствующую ему команду\n") b.WriteString("\"ssh <название>\" — она сама пройдёт через промежуточный узел, отдельно\n") b.WriteString("ничего для этого включать не нужно.\n\n") b.WriteString("ШАГ 4. Ваши сервисы\n") b.WriteString("---------------------\n") aliases := hostAliases(forwards) for i, f := range forwards { what := f.ServiceName if f.Description != "" { what = fmt.Sprintf("%s (%s)", f.ServiceName, f.Description) } fmt.Fprintf(&b, " %d. ssh %s\n", i+1, aliases[i]) 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("ЕСЛИ ЧТО-ТО НЕ РАБОТАЕТ\n") b.WriteString("------------------------\n") b.WriteString("Если команда из шага 4 сразу обрывается или пишет ошибку — обратитесь к\n") b.WriteString("администратору, который выдал вам этот архив. Возможно, изменился список\n") b.WriteString("сервисов — тогда вам пришлют обновлённый архив.\n") return b.String() } // RenderConfigUpdateInstructionsProxyJump is the ProxyJump-mode counterpart // of RenderConfigUpdateInstructions. func RenderConfigUpdateInstructionsProxyJump(username string, forwards []model.Forward) 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") aliases := hostAliases(forwards) for i, f := range forwards { what := f.ServiceName if f.Description != "" { what = fmt.Sprintf("%s (%s)", f.ServiceName, f.Description) } fmt.Fprintf(&b, " %d. ssh %s\n", i+1, aliases[i]) 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() }