feat(admin): авто-загрузка сертификатов УЦ НРД + ежесуточное обновление
Новый раздел /admin/setup → «Сертификаты УЦ» (и в шаге 3 wizard'а): список URL .cer-файлов УЦ (одна ссылка на строку). По кнопке «Скачать сейчас» система качает каждый URL, парсит X.509, и через certmgr -inst импортирует в mroot (если cert == issuer, т.е. корневой) или uRoot (промежуточный). Дедуп по SHA-256: если файл по URL не изменился — повторного импорта нет. В runtime-конфиге сохраняется журнал FetchedCerts (CN/SHA-256/срок/статус) для отображения в UI. Чекбокс «Авто-обновление раз в сутки» включает фоновую горутину StartCACertsAutoUpdater — стартует через 30 сек после bj-server, потом тикает раз в 24 часа. При изменении сертификата он переустанавливается без участия оператора. Mastered tasks: #44.
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultNSDCAURLs — список URL для авто-загрузки сертификатов УЦ НРД.
|
||||
// Эти URL пользователь может скорректировать в /admin/setup → «Сертификаты
|
||||
// УЦ» (раздел появляется после первого сохранения настроек). На сайте НРД
|
||||
// (www.nsd.ru/workflow/system/cryptography/) сертификаты выложены в виде
|
||||
// .cer файлов — нужно скопировать их прямые URL сюда.
|
||||
//
|
||||
// По умолчанию список пустой, потому что прямые URL у НРД меняются от
|
||||
// релиза к релизу и должны быть проверены оператором перед использованием.
|
||||
var defaultNSDCAURLs = []string{
|
||||
// https://www.nsd.ru/workflow/system/cryptography/ — раскомментируйте
|
||||
// нужные ссылки в UI после того, как уточните URL у НРД.
|
||||
}
|
||||
|
||||
// FetchCACertificates скачивает все URL из настроек, парсит .cer, и при
|
||||
// успехе вызывает certmgr -inst -store mroot. Возвращает обновлённую
|
||||
// CACertsSettings (для записи в runtime-конфиг) и сводку как строку.
|
||||
func FetchCACertificates(ctx context.Context, s CACertsSettings) (CACertsSettings, string) {
|
||||
if len(s.URLs) == 0 {
|
||||
return s, "Список URL пуст. Добавьте ссылки на .cer-файлы УЦ НРД в /admin/setup → «Сертификаты УЦ»."
|
||||
}
|
||||
var logBuf strings.Builder
|
||||
now := time.Now()
|
||||
newFetched := make([]FetchedCACert, 0, len(s.URLs))
|
||||
|
||||
for _, u := range s.URLs {
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
fc := FetchedCACert{URL: u, FetchedAt: now}
|
||||
der, err := downloadAndParseCert(ctx, u)
|
||||
if err != nil {
|
||||
fc.Error = err.Error()
|
||||
newFetched = append(newFetched, fc)
|
||||
fmt.Fprintf(&logBuf, "%s — ОШИБКА: %s\n", u, err)
|
||||
continue
|
||||
}
|
||||
cert, perr := x509.ParseCertificate(der)
|
||||
if perr != nil {
|
||||
fc.Error = "не удалось распарсить X.509: " + perr.Error()
|
||||
newFetched = append(newFetched, fc)
|
||||
fmt.Fprintf(&logBuf, "%s — не X.509: %s\n", u, perr)
|
||||
continue
|
||||
}
|
||||
fc.SubjectCN = cert.Subject.CommonName
|
||||
fc.IssuerCN = cert.Issuer.CommonName
|
||||
fc.NotAfter = cert.NotAfter
|
||||
fc.SHA256 = hex.EncodeToString(sha256Bytes(der))
|
||||
// УЦ-сертификаты с самоподписью (Issuer == Subject) идут в mroot,
|
||||
// промежуточные — в uRoot.
|
||||
store := "uRoot"
|
||||
if cert.Subject.CommonName == cert.Issuer.CommonName {
|
||||
store = "mroot"
|
||||
}
|
||||
fc.Store = store
|
||||
|
||||
// Дедуп: если sha256 совпадает с уже импортированным — пропускаем
|
||||
// сам импорт (но фиксируем что проверили).
|
||||
alreadyImported := false
|
||||
for _, old := range s.FetchedCerts {
|
||||
if old.URL == u && old.SHA256 == fc.SHA256 && old.Error == "" {
|
||||
alreadyImported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if alreadyImported {
|
||||
fmt.Fprintf(&logBuf, "%s — не изменился (sha256=%s...)\n", u, fc.SHA256[:12])
|
||||
newFetched = append(newFetched, fc)
|
||||
continue
|
||||
}
|
||||
|
||||
// Импорт через certmgr.
|
||||
if err := importCertToStore(ctx, der, store); err != nil {
|
||||
fc.Error = "certmgr: " + err.Error()
|
||||
fmt.Fprintf(&logBuf, "%s — certmgr упал: %s\n", u, err)
|
||||
} else {
|
||||
fmt.Fprintf(&logBuf, "%s — импортирован в %s (CN=%s, sha256=%s...)\n",
|
||||
u, store, fc.SubjectCN, fc.SHA256[:12])
|
||||
}
|
||||
newFetched = append(newFetched, fc)
|
||||
}
|
||||
|
||||
s.LastFetch = now
|
||||
s.LastFetchLog = logBuf.String()
|
||||
s.FetchedCerts = newFetched
|
||||
return s, logBuf.String()
|
||||
}
|
||||
|
||||
func sha256Bytes(b []byte) []byte {
|
||||
h := sha256.Sum256(b)
|
||||
return h[:]
|
||||
}
|
||||
|
||||
// downloadAndParseCert качает URL и возвращает DER-байты сертификата.
|
||||
// Поддерживает PEM (-----BEGIN CERTIFICATE-----) и сырой DER.
|
||||
func downloadAndParseCert(ctx context.Context, rawURL string) ([]byte, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не URL: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, fmt.Errorf("поддерживаются только http/https, получено %q", u.Scheme)
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "bj-server/1.0 (cacerts auto-fetch)")
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("сеть: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Пробуем PEM.
|
||||
if block, _ := pem.Decode(data); block != nil && block.Type == "CERTIFICATE" {
|
||||
return block.Bytes, nil
|
||||
}
|
||||
// Иначе считаем что DER.
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// importCertToStore вызывает certmgr -inst -store <store> -file <tmp>.
|
||||
func importCertToStore(ctx context.Context, der []byte, store string) error {
|
||||
const certmgr = "/opt/cprocsp/bin/amd64/certmgr"
|
||||
if _, err := os.Stat(certmgr); err != nil {
|
||||
return fmt.Errorf("certmgr не найден (КриптоПро CSP не установлен?): %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "bj-ca-*.cer")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.Write(der); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
cmd := exec.CommandContext(ctx, certmgr, "-inst", "-store", store, "-file", tmp.Name())
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w / %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartCACertsAutoUpdater запускает горутину, которая раз в сутки
|
||||
// перекачивает сертификаты УЦ и переустанавливает изменённые. Возвращает
|
||||
// функцию остановки. Если AutoUpdate=false — фон не запускается.
|
||||
func StartCACertsAutoUpdater(rc *RuntimeConfig) func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
// При старте — небольшой запас, чтобы не лезть в сеть в ту же
|
||||
// секунду запуска bj-server.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
s := rc.Snapshot().CACerts
|
||||
if s.AutoUpdate && len(s.URLs) > 0 {
|
||||
updated, _ := FetchCACertificates(ctx, s)
|
||||
if err := rc.UpdateCACerts(updated); err != nil {
|
||||
log.Printf("ca-certs auto-update: save failed: %v", err)
|
||||
} else {
|
||||
log.Printf("ca-certs auto-update: %d url'ов проверено", len(s.URLs))
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return cancel
|
||||
}
|
||||
|
||||
// saveCACerts — POST /admin/setup/cacerts.
|
||||
// Принимает форму с textarea (одна URL на строку) и чекбоксом auto_update.
|
||||
func (h *setupHandlers) saveCACerts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
raw := r.FormValue("urls")
|
||||
auto := r.FormValue("auto_update") == "on"
|
||||
urls := []string{}
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
urls = append(urls, line)
|
||||
}
|
||||
}
|
||||
cur := h.rc.Snapshot().CACerts
|
||||
cur.URLs = urls
|
||||
cur.AutoUpdate = auto
|
||||
if err := h.rc.UpdateCACerts(cur); err != nil {
|
||||
setupFlash(w, r, "Сертификаты УЦ: не получилось сохранить: "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, fmt.Sprintf("Сертификаты УЦ: сохранено %d URL'ов, авто-обновление: %v", len(urls), auto))
|
||||
}
|
||||
|
||||
// fetchCACertsNow — POST /admin/setup/cacerts/fetch.
|
||||
// Ручной триггер «скачать сейчас», вызывает FetchCACertificates сразу.
|
||||
func (h *setupHandlers) fetchCACertsNow(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||
defer cancel()
|
||||
cur := h.rc.Snapshot().CACerts
|
||||
updated, summary := FetchCACertificates(ctx, cur)
|
||||
if err := h.rc.UpdateCACerts(updated); err != nil {
|
||||
setupFlash(w, r, "Сертификаты УЦ: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
if summary == "" {
|
||||
summary = "готово"
|
||||
}
|
||||
// Обрезаем длинный лог в flash-сообщении.
|
||||
if len(summary) > 800 {
|
||||
summary = summary[:800] + "…"
|
||||
}
|
||||
setupFlash(w, r, "Сертификаты УЦ обновлены: "+strings.TrimSpace(summary))
|
||||
}
|
||||
|
||||
// caCertsTemplateString — компактный URL для отображения в UI.
|
||||
func caCertsTemplateString(s CACertsSettings) string {
|
||||
return strings.Join(s.URLs, "\n")
|
||||
}
|
||||
|
||||
// доп. защита от пустых импортов (linter)
|
||||
var _ = filepath.Join
|
||||
@@ -24,10 +24,35 @@ type Settings struct {
|
||||
Crypto CryptoSettings `json:"crypto"`
|
||||
NSD NSDSettings `json:"nsd"`
|
||||
LK LKSettings `json:"lk"`
|
||||
CACerts CACertsSettings `json:"ca_certs"`
|
||||
LastTest *TestRunResult `json:"last_test,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CACertsSettings — URL'ы для авто-загрузки сертификатов УЦ НРД и нашего
|
||||
// УЦ. Список редактируется пользователем; раз в сутки фоновая горутина
|
||||
// перекачивает каждый URL и переустанавливает сертификат, если он
|
||||
// поменялся. Все сертификаты идут в mroot/uRoot хранилища КриптоПро.
|
||||
type CACertsSettings struct {
|
||||
URLs []string `json:"urls"`
|
||||
AutoUpdate bool `json:"auto_update"`
|
||||
LastFetch time.Time `json:"last_fetch"`
|
||||
LastFetchLog string `json:"last_fetch_log"`
|
||||
FetchedCerts []FetchedCACert `json:"fetched_certs"`
|
||||
}
|
||||
|
||||
// FetchedCACert — информация о последнем удачно скачанном сертификате.
|
||||
type FetchedCACert struct {
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
SubjectCN string `json:"subject_cn"`
|
||||
IssuerCN string `json:"issuer_cn"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
Store string `json:"store"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// PostgresSettings — DSN для подключения к БД (M2-шаг-3).
|
||||
type PostgresSettings struct {
|
||||
DSN string `json:"dsn"`
|
||||
@@ -126,6 +151,15 @@ func (r *RuntimeConfig) UpdateNSD(s NSDSettings) error {
|
||||
}
|
||||
|
||||
// UpdateLK сохраняет LK callback URL.
|
||||
// UpdateCACerts сохраняет настройки авто-загрузки сертификатов УЦ.
|
||||
func (r *RuntimeConfig) UpdateCACerts(s CACertsSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.CACerts = s
|
||||
r.data.UpdatedAt = time.Now()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
func (r *RuntimeConfig) UpdateLK(s LKSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.LK = s
|
||||
|
||||
@@ -159,6 +159,10 @@ func (s *Server) Mux() http.Handler { return s.mux }
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
go s.consumeDecisions(ctx)
|
||||
|
||||
// Авто-обновление сертификатов УЦ раз в сутки (если оператор включил).
|
||||
stopCACerts := StartCACertsAutoUpdater(s.rc)
|
||||
defer stopCACerts()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
log.Printf("lk-gateway: listen %s", s.cfg.Addr)
|
||||
|
||||
@@ -70,6 +70,10 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
|
||||
mux.HandleFunc("/admin/setup/lk", h.saveLK)
|
||||
mux.HandleFunc("/admin/setup/test-run", h.testRun)
|
||||
|
||||
// Авто-загрузка сертификатов УЦ НРД и нашего УЦ.
|
||||
mux.HandleFunc("/admin/setup/cacerts", h.saveCACerts)
|
||||
mux.HandleFunc("/admin/setup/cacerts/fetch", h.fetchCACertsNow)
|
||||
|
||||
// Пошаговый мастер настройки для нетехнических пользователей.
|
||||
mux.HandleFunc("/admin/wizard", h.renderWizard)
|
||||
}
|
||||
|
||||
@@ -147,6 +147,53 @@
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Авто-загрузка сертификатов УЦ НРД -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.CACerts.URLs}}ok{{else}}warn{{end}}"></span>Сертификаты УЦ (НРД и др.) — авто-загрузка</h2>
|
||||
<p class="muted">Прямые URL .cer-файлов УЦ НРД (см. <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и других УЦ. Каждый URL скачивается, парсится X.509, и автоматически импортируется в КриптоПро (<code>mroot</code> для корневых, <code>uRoot</code> для промежуточных). Включите авто-обновление — раз в сутки система перепроверит и переустановит, если сертификат изменился.</p>
|
||||
<form method="post" action="/admin/setup/cacerts" style="margin-top:10px;display:grid;gap:10px">
|
||||
<label>URL'ы .cer-файлов (один на строку)</label>
|
||||
<textarea name="urls" rows="4" placeholder="https://www.nsd.ru/path/to/root-ca.cer https://www.nsd.ru/path/to/sub-ca.cer" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
|
||||
{{end}}</textarea>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" name="auto_update" {{if .Settings.CACerts.AutoUpdate}}checked{{end}}>
|
||||
<span>Авто-обновление раз в сутки</span>
|
||||
</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button type="submit" class="btn">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="/admin/setup/cacerts/fetch" style="margin-top:8px">
|
||||
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;font-weight:600">⬇ Скачать и импортировать сейчас</button>
|
||||
{{if not .Settings.CACerts.LastFetch.IsZero}}
|
||||
<span class="muted" style="margin-left:10px">Последнее обновление: {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04:05"}}</span>
|
||||
{{end}}
|
||||
</form>
|
||||
{{if .Settings.CACerts.FetchedCerts}}
|
||||
<table style="margin-top:14px">
|
||||
<thead><tr><th>URL</th><th>Владелец</th><th>Хранилище</th><th>Действителен до</th><th>SHA-256</th><th>Статус</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Settings.CACerts.FetchedCerts}}
|
||||
<tr>
|
||||
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{.URL}}"><code style="font-size:11px">{{.URL}}</code></td>
|
||||
<td>{{.SubjectCN}}</td>
|
||||
<td><code>{{.Store}}</code></td>
|
||||
<td>{{if not .NotAfter.IsZero}}{{.NotAfter.Format "02.01.2006"}}{{end}}</td>
|
||||
<td><code style="font-size:11px">{{if .SHA256}}{{slice .SHA256 0 12}}…{{end}}</code></td>
|
||||
<td>{{if .Error}}<span style="color:var(--err)" title="{{.Error}}">ошибка</span>{{else}}<span style="color:var(--ok)">ок</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
{{if .Settings.CACerts.LastFetchLog}}
|
||||
<details style="margin-top:10px">
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Лог последнего обновления</summary>
|
||||
<pre style="margin-top:8px">{{.Settings.CACerts.LastFetchLog}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- nsd-adapter / ИШ НРД -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}err{{end}}"></span>Интеграционный шлюз НРД (ИШ)</h2>
|
||||
|
||||
@@ -181,6 +181,24 @@
|
||||
<button type="submit" class="btn">Импортировать</button>
|
||||
</form>
|
||||
|
||||
<h3 style="margin-top:18px">Авто-загрузка сертификатов УЦ НРД</h3>
|
||||
<p class="muted">Самый простой способ — добавить прямые URL <code>.cer</code>-файлов УЦ НРД (с <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и включить авто-обновление. Раз в сутки система перепроверит и переустановит изменённые сертификаты.</p>
|
||||
<form method="post" action="/admin/setup/cacerts" style="margin-top:8px;display:grid;gap:10px">
|
||||
<textarea name="urls" rows="3" placeholder="https://www.nsd.ru/path/to/root-ca.cer https://www.nsd.ru/path/to/sub-ca.cer" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
|
||||
{{end}}</textarea>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" name="auto_update" {{if .Settings.CACerts.AutoUpdate}}checked{{end}}>
|
||||
<span>Авто-обновление раз в сутки</span>
|
||||
</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button type="submit" class="btn">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="/admin/setup/cacerts/fetch" style="margin-top:8px">
|
||||
<button type="submit" class="btn" style="background:var(--ok)">⬇ Скачать и импортировать сейчас</button>
|
||||
{{if not .Settings.CACerts.LastFetch.IsZero}}<span class="muted" style="margin-left:10px">Последнее обновление: {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04:05"}}</span>{{end}}
|
||||
</form>
|
||||
|
||||
{{if .Certs}}
|
||||
<h3 style="margin-top:18px">Установленные сертификаты ({{len .Certs}})</h3>
|
||||
<table>
|
||||
|
||||
Reference in New Issue
Block a user