HTTPS: одна кнопка «Сохранить» на раздел (перенаправление, доменное имя, адрес без порта)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
This commit is contained in:
fontvielle
2026-10-22 15:39:30 +03:00
parent 005c5e8368
commit 7fd4bd0af9
3 changed files with 137 additions and 123 deletions
+68 -68
View File
@@ -376,73 +376,73 @@ func registerHostname(mux *http.ServeMux, rc *RuntimeConfig) {
http.Error(w, "method", http.StatusMethodNotAllowed) http.Error(w, "method", http.StatusMethodNotAllowed)
return return
} }
raw := strings.NewReplacer(",", " ", ";", " ", "\n", " ", "\r", " ").Replace(r.FormValue("hostnames")) msg, _ := saveHostnames(r.Context(), rc, r.FormValue("hostnames"))
var names []string setupFlash(w, r, msg)
for _, f := range strings.Fields(raw) {
f = strings.ToLower(strings.TrimSpace(f))
f = strings.TrimPrefix(f, "https://")
f = strings.TrimPrefix(f, "http://")
if i := strings.IndexByte(f, '/'); i >= 0 {
f = f[:i]
}
if h, _, err := net.SplitHostPort(f); err == nil {
f = h
}
if f == "" {
continue
}
if !validHostname(f) {
setupFlash(w, r, "Доменное имя «"+f+"» задано неверно. Пример: m2m.corp.example.ru")
return
}
names = append(names, f)
}
names = uniqStrings(names)
// имя должно быть в DNS и указывать на этот сервер — иначе браузер всё равно не откроет bj по нему
mine := map[string]bool{}
for _, ip := range serverIPs() {
mine[ip] = true
}
var resolved []string
for _, n := range names {
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
addrs, err := net.DefaultResolver.LookupHost(ctx, n)
cancel()
if err != nil || len(addrs) == 0 {
setupFlash(w, r, "Имя «"+n+"» не найдено в DNS. Попросите администратора сети добавить на DNS-сервере (контроллере домена) запись A: "+
n+" → "+strings.Join(serverIPs(), " или ")+" — и нажмите «Сохранить» ещё раз.")
return
}
ok := false
for _, a := range addrs {
ok = ok || mine[a]
}
if !ok && len(mine) > 0 {
setupFlash(w, r, "Имя «"+n+"» в DNS указывает на "+strings.Join(addrs, ", ")+", а у сервера bj адрес "+strings.Join(serverIPs(), ", ")+
". Исправьте запись A на DNS-сервере и нажмите «Сохранить» ещё раз.")
return
}
resolved = append(resolved, n+" → "+strings.Join(addrs, ", "))
}
h := rc.Snapshot().HTTPS
h.Hostnames = names
if err := rc.UpdateHTTPS(h); err != nil {
setupFlash(w, r, "Имена сервера: "+err.Error())
return
}
if h.Custom {
setupFlash(w, r, "Имена сохранены. Сейчас используется загруженный сертификат организации — проверьте, что эти имена есть в нём.")
return
}
if err := ensureSelfSigned(true); err != nil {
setupFlash(w, r, "Имена сохранены, но сертификат не перевыпущен: "+err.Error())
return
}
if len(names) == 0 {
setupFlash(w, r, "Доменные имена убраны. Сертификат перевыпущен на имя хоста и IP.")
return
}
setupFlash(w, r, "Готово: "+strings.Join(resolved, "; ")+" — имя есть в DNS и вписано в сертификат. Адрес bj: https://"+httpsHostPort(names[0], h.publicPort())+"/ (http:// перебросит на https:// сам). Сертификат самоподписанный — браузер один раз попросит подтвердить исключение; "+
"без предупреждения — после загрузки сертификата организации («Сертификат организации» ниже).")
}) })
} }
// saveHostnames — доменные имена сервера: проверка DNS (имя указывает на этот сервер), сохранение,
// перевыпуск самоподписанного сертификата. Возвращает текст для пользователя и успех.
func saveHostnames(ctx0 context.Context, rc *RuntimeConfig, raw string) (string, bool) {
raw = strings.NewReplacer(",", " ", ";", " ", "\n", " ", "\r", " ").Replace(raw)
var names []string
for _, f := range strings.Fields(raw) {
f = strings.ToLower(strings.TrimSpace(f))
f = strings.TrimPrefix(f, "https://")
f = strings.TrimPrefix(f, "http://")
if i := strings.IndexByte(f, '/'); i >= 0 {
f = f[:i]
}
if h, _, err := net.SplitHostPort(f); err == nil {
f = h
}
if f == "" {
continue
}
if !validHostname(f) {
return "Доменное имя «" + f + "» задано неверно. Пример: m2m.corp.example.ru", false
}
names = append(names, f)
}
names = uniqStrings(names)
// имя должно быть в DNS и указывать на этот сервер — иначе браузер всё равно не откроет bj по нему
mine := map[string]bool{}
for _, ip := range serverIPs() {
mine[ip] = true
}
var resolved []string
for _, n := range names {
ctx, cancel := context.WithTimeout(ctx0, 4*time.Second)
addrs, err := net.DefaultResolver.LookupHost(ctx, n)
cancel()
if err != nil || len(addrs) == 0 {
return "Имя «" + n + "» не найдено в DNS. Попросите администратора сети добавить на DNS-сервере (контроллере домена) запись A: " +
n + " → " + strings.Join(serverIPs(), " или ") + " — и нажмите «Сохранить» ещё раз.", false
}
ok := false
for _, a := range addrs {
ok = ok || mine[a]
}
if !ok && len(mine) > 0 {
return "Имя «" + n + "» в DNS указывает на " + strings.Join(addrs, ", ") + ", а у сервера bj адрес " + strings.Join(serverIPs(), ", ") +
". Исправьте запись A на DNS-сервере и нажмите «Сохранить» ещё раз.", false
}
resolved = append(resolved, n+" → "+strings.Join(addrs, ", "))
}
h := rc.Snapshot().HTTPS
h.Hostnames = names
if err := rc.UpdateHTTPS(h); err != nil {
return "Имена сервера: " + err.Error(), false
}
if h.Custom {
return "Имена сохранены. Сейчас используется загруженный сертификат организации — проверьте, что эти имена есть в нём.", true
}
if err := ensureSelfSigned(true); err != nil {
return "Имена сохранены, но сертификат не перевыпущен: " + err.Error(), false
}
if len(names) == 0 {
return "Доменные имена убраны. Сертификат перевыпущен на имя хоста и IP.", true
}
return "Готово: " + strings.Join(resolved, "; ") + " — имя есть в DNS и вписано в сертификат. Адрес bj: https://" + httpsHostPort(names[0], h.publicPort()) + "/ (http:// перебросит на https:// сам). Сертификат самоподписанный — браузер один раз попросит подтвердить исключение; " +
"без предупреждения — после загрузки сертификата организации («Сертификат организации» ниже).", true
}
+63 -40
View File
@@ -294,17 +294,38 @@ func registerHTTPS(mux *http.ServeMux, rc *RuntimeConfig) {
http.Error(w, "method", http.StatusMethodNotAllowed) http.Error(w, "method", http.StatusMethodNotAllowed)
return return
} }
// одна форма раздела: перенаправление, доменное имя, адрес без порта. Применяется только изменённое;
// порты — последними: bj после них перезапускается.
h := rc.Snapshot().HTTPS h := rc.Snapshot().HTTPS
h.RedirectOff = r.FormValue("redirect") == "" var msgs []string
if err := rc.UpdateHTTPS(h); err != nil { if off := r.FormValue("redirect") == ""; off != h.RedirectOff {
setupFlash(w, r, "HTTPS: "+err.Error()) h.RedirectOff = off
return if err := rc.UpdateHTTPS(h); err != nil {
setupFlash(w, r, "HTTPS: "+err.Error())
return
}
if h.RedirectOn() {
msgs = append(msgs, "Браузеры перенаправляются с http:// на https://.")
} else {
msgs = append(msgs, "Перенаправление с http:// выключено.")
}
} }
msg := "HTTPS: перенаправление с HTTP выключено" if _, sent := r.Form["hostnames"]; sent && strings.Join(strings.Fields(strings.ToLower(r.FormValue("hostnames"))), " ") != strings.Join(h.Hostnames, " ") {
if h.RedirectOn() { msg, ok := saveHostnames(r.Context(), rc, r.FormValue("hostnames"))
msg = "HTTPS: браузеры теперь перенаправляются на https://" + httpsHostPort("…", h.publicPort()) msgs = append(msgs, msg)
if !ok {
setupFlash(w, r, strings.Join(msgs, " "))
return
}
} }
setupFlash(w, r, msg) // включено, но порт 443 не поднялся — «Сохранить» повторяет выдачу права
if on := r.FormValue("std_ports") != ""; on != rc.Snapshot().HTTPS.StdPorts || (on && stdPortsState() != "ok") {
msgs = append(msgs, applyStdPorts(r.Context(), rc, on))
}
if len(msgs) == 0 {
msgs = append(msgs, "HTTPS: изменений нет.")
}
setupFlash(w, r, strings.Join(msgs, " "))
}) })
// адрес без номера порта: 443 и 80 (право на порты — через root-помощника, bj перезапускается) // адрес без номера порта: 443 и 80 (право на порты — через root-помощника, bj перезапускается)
mux.HandleFunc("/admin/setup/https/stdports", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/admin/setup/https/stdports", func(w http.ResponseWriter, r *http.Request) {
@@ -312,38 +333,7 @@ func registerHTTPS(mux *http.ServeMux, rc *RuntimeConfig) {
http.Error(w, "method", http.StatusMethodNotAllowed) http.Error(w, "method", http.StatusMethodNotAllowed)
return return
} }
on := r.FormValue("std_ports") != "" setupFlash(w, r, applyStdPorts(r.Context(), rc, r.FormValue("std_ports") != ""))
h := rc.Snapshot().HTTPS
h.StdPorts = on
if err := rc.UpdateHTTPS(h); err != nil {
setupFlash(w, r, "HTTPS: "+err.Error())
return
}
mode := "off"
if on {
mode = "on"
}
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
res, err := runKeymediaOp(ctx, "system-ports", map[string]string{"extra": mode})
cancel()
if err == nil {
if ok, _ := res["ok"].(bool); !ok {
err = fmt.Errorf("%v", res["error"])
}
}
if err != nil {
setupFlash(w, r, "Адрес без номера порта: настройка сохранена, но право на порты не выдано — "+err.Error())
return
}
name := "имя-сервера"
if len(h.Hostnames) > 0 {
name = h.Hostnames[0]
}
if on {
setupFlash(w, r, "Готово: bj перезапускается (несколько секунд) и будет открываться по https://"+name+"/ — без :"+strconv.Itoa(h.port())+"; http://"+name+"/ перейдёт на https:// сам. Обновите страницу через 10 секунд.")
} else {
setupFlash(w, r, "Адрес без номера порта выключен: bj перезапускается и открывается по https://"+name+":"+strconv.Itoa(h.port())+"/.")
}
}) })
// свой сертификат организации (PEM: сертификат с цепочкой и закрытый ключ) // свой сертификат организации (PEM: сертификат с цепочкой и закрытый ключ)
mux.HandleFunc("/admin/setup/https/cert", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/admin/setup/https/cert", func(w http.ResponseWriter, r *http.Request) {
@@ -541,3 +531,36 @@ func stdPortsState() string {
} }
return "ожидает перезапуска bj" return "ожидает перезапуска bj"
} }
// applyStdPorts — адрес без номера порта: сохранить настройку и выдать/снять службе право на 443/80
// (через root-помощника; bj после этого перезапускается). Возвращает текст для пользователя.
func applyStdPorts(ctx0 context.Context, rc *RuntimeConfig, on bool) string {
h := rc.Snapshot().HTTPS
h.StdPorts = on
if err := rc.UpdateHTTPS(h); err != nil {
return "HTTPS: " + err.Error()
}
mode := "off"
if on {
mode = "on"
}
ctx, cancel := context.WithTimeout(ctx0, 40*time.Second)
res, err := runKeymediaOp(ctx, "system-ports", map[string]string{"extra": mode})
cancel()
if err == nil {
if ok, _ := res["ok"].(bool); !ok {
err = fmt.Errorf("%v", res["error"])
}
}
if err != nil {
return "Адрес без номера порта: настройка сохранена, но право на порты не выдано — " + err.Error()
}
name := "имя-сервера"
if len(h.Hostnames) > 0 {
name = h.Hostnames[0]
}
if on {
return "Готово: bj перезапускается (несколько секунд) и будет открываться по https://" + name + "/ — без :" + strconv.Itoa(h.port()) + "; http://" + name + "/ перейдёт на https:// сам. Обновите страницу через 10 секунд."
}
return "Адрес без номера порта выключен: bj перезапускается и открывается по https://" + name + ":" + strconv.Itoa(h.port()) + "/."
}
@@ -929,24 +929,15 @@
<h2><span class="dot {{if and .Settings.HTTPS.RedirectOn (not $h.Err)}}ok{{else}}warn{{end}}"></span>HTTPS — шифрование входа в bj</h2> <h2><span class="dot {{if and .Settings.HTTPS.RedirectOn (not $h.Err)}}ok{{else}}warn{{end}}"></span>HTTPS — шифрование входа в bj</h2>
<p class="muted">Пароли администратора, баз данных, хранилища и ключ продукта не должны идти по сети открытым текстом. bj всегда слушает HTTPS: <a href="https://{{serverIP}}:{{if .Settings.HTTPS.Port}}{{.Settings.HTTPS.Port}}{{else}}8443{{end}}/admin/">https://{{serverIP}}:{{if .Settings.HTTPS.Port}}{{.Settings.HTTPS.Port}}{{else}}8443{{end}}/</a>. Модули bj и API на этом сервере работают по HTTP, как раньше.</p> <p class="muted">Пароли администратора, баз данных, хранилища и ключ продукта не должны идти по сети открытым текстом. bj всегда слушает HTTPS: <a href="https://{{serverIP}}:{{if .Settings.HTTPS.Port}}{{.Settings.HTTPS.Port}}{{else}}8443{{end}}/admin/">https://{{serverIP}}:{{if .Settings.HTTPS.Port}}{{.Settings.HTTPS.Port}}{{else}}8443{{end}}/</a>. Модули bj и API на этом сервере работают по HTTP, как раньше.</p>
<p style="margin:8px 0">{{if $h.Err}}<span style="color:var(--err)">✗ сертификат: {{$h.Err}}</span>{{else}}Сертификат: <b>{{$h.CN}}</b>, до {{$h.Until.Format "02.01.2006"}} — {{if .Settings.HTTPS.Custom}}сертификат организации{{else}}самоподписанный: браузер один раз попросит подтвердить исключение; чтобы не просил — загрузите сертификат организации{{end}}.{{end}}</p> <p style="margin:8px 0">{{if $h.Err}}<span style="color:var(--err)">✗ сертификат: {{$h.Err}}</span>{{else}}Сертификат: <b>{{$h.CN}}</b>, до {{$h.Until.Format "02.01.2006"}} — {{if .Settings.HTTPS.Custom}}сертификат организации{{else}}самоподписанный: браузер один раз попросит подтвердить исключение; чтобы не просил — загрузите сертификат организации{{end}}.{{end}}</p>
<form method="post" action="/admin/setup/https" style="margin:8px 0"> <form method="post" action="/admin/setup/https" style="margin:8px 0" onsubmit="var p=this.std_ports;return p.checked==p.defaultChecked||confirm('Сохранить? bj перезапустится на несколько секунд.')">
<input type="hidden" name="section" value="sec-https">
<label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="redirect" style="width:auto"{{if .Settings.HTTPS.RedirectOn}} checked{{end}}> <b>Открывать bj только по HTTPS</b> — браузер с http:// перенаправляется на https://</label> <label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="redirect" style="width:auto"{{if .Settings.HTTPS.RedirectOn}} checked{{end}}> <b>Открывать bj только по HTTPS</b> — браузер с http:// перенаправляется на https://</label>
<button type="submit" class="btn" style="margin-top:8px">Сохранить</button> <label for="set-hostnames" style="display:block;margin:12px 0 4px">Доменное имя сервера bj</label>
</form> <input id="set-hostnames" name="hostnames" value="{{range $i, $n := .Settings.HTTPS.Hostnames}}{{if $i}} {{end}}{{$n}}{{end}}" placeholder="например, m2m.corp.example.ru (несколько — через пробел)" style="min-width:320px;width:100%">
<form method="post" action="/admin/setup/hostname" style="margin:12px 0 8px">
<input type="hidden" name="section" value="sec-https">
<label for="set-hostnames" style="display:block;margin-bottom:4px">Доменное имя сервера bj</label>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<input id="set-hostnames" name="hostnames" value="{{range $i, $n := .Settings.HTTPS.Hostnames}}{{if $i}} {{end}}{{$n}}{{end}}" placeholder="например, m2m.corp.example.ru (несколько — через пробел)" style="min-width:320px;flex:1">
<button type="submit" class="btn">Сохранить</button>
</div>
<p class="muted" style="font-size:12.5px;margin:4px 0 0">Сначала администратор сети заводит в DNS запись A на этот сервер ({{range $i, $ip := serverIPs}}{{if $i}}, {{end}}{{$ip}}{{end}}); bj проверит, что имя находится и указывает сюда, и впишет его в сертификат. Адрес bj — <code>https://имя{{httpsPortSuffix}}/</code>.</p> <p class="muted" style="font-size:12.5px;margin:4px 0 0">Сначала администратор сети заводит в DNS запись A на этот сервер ({{range $i, $ip := serverIPs}}{{if $i}}, {{end}}{{$ip}}{{end}}); bj проверит, что имя находится и указывает сюда, и впишет его в сертификат. Адрес bj — <code>https://имя{{httpsPortSuffix}}/</code>.</p>
</form> <label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-top:10px"><input type="checkbox" name="std_ports" style="width:auto"{{if .Settings.HTTPS.StdPorts}} checked{{end}}> <b>Адрес без номера порта</b> — https://имя/ вместо https://имя:{{httpsOwnPort}}/ (bj слушает 443, а 80 переводит на https://)</label>
<form method="post" action="/admin/setup/https/stdports" style="margin:4px 0 8px">
<input type="hidden" name="section" value="sec-https">
<label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="std_ports" style="width:auto"{{if .Settings.HTTPS.StdPorts}} checked{{end}}> <b>Адрес без номера порта</b> — https://имя/ вместо https://имя:{{httpsOwnPort}}/ (bj слушает 443, а 80 переводит на https://)</label>
{{with stdPortsState}}<p style="margin:4px 0 0;font-size:12.5px">{{if eq . "ok"}}<span style="color:var(--ok)">✓ работает: порты 443 и 80</span>{{else}}<span style="color:var(--warn)">⚠ {{.}} — нажмите «Сохранить» ещё раз</span>{{end}}</p>{{end}} {{with stdPortsState}}<p style="margin:4px 0 0;font-size:12.5px">{{if eq . "ok"}}<span style="color:var(--ok)">✓ работает: порты 443 и 80</span>{{else}}<span style="color:var(--warn)">⚠ {{.}} — нажмите «Сохранить» ещё раз</span>{{end}}</p>{{end}}
<button type="submit" class="btn" style="margin-top:8px" onclick="return confirm('Сохранить? bj перезапустится на несколько секунд.')">Сохранить</button> <button type="submit" class="btn" style="margin-top:12px">Сохранить</button>
</form> </form>
<details style="margin-top:8px"><summary style="cursor:pointer;color:var(--accent)">Сертификат организации</summary> <details style="margin-top:8px"><summary style="cursor:pointer;color:var(--accent)">Сертификат организации</summary>
<form method="post" action="/admin/setup/https/cert" enctype="multipart/form-data" style="display:grid;gap:8px;max-width:520px;margin-top:8px"> <form method="post" action="/admin/setup/https/cert" enctype="multipart/form-data" style="display:grid;gap:8px;max-width:520px;margin-top:8px">