Папки шлюза по сети — два режима: простой (один логин) или через домен (группы и сотрудники bj)

- «Через домен»: сервер bj подключается к домену AD (winbind, один раз — учётной записью администратора домена, bj её не сохраняет); папки igate_exchange(_prod) открыты группам с ролью в bj и доменным сотрудникам bj — своим входом в Windows; краткое имя домена bj берёт из AD (crossRef nETBIOSName).
- Список доступа bj сверяет сам: после изменения групп, сотрудников и настроек входа (через несколько секунд) и раз в 15 минут.
- «Простой»: как было — один логин и пароль Samba; переход с домена выключает вход через домен (учётная запись компьютера в домене остаётся).
- bj-igate-admin share: режимы simple/domain, join (realm, workgroup, admin), проверка списка доступа на недопустимые символы, состояние — mode/joined/realm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-07 09:01:46 +03:00
parent 6aa5d60797
commit 9553bd6e22
8 changed files with 426 additions and 45 deletions
+46 -6
View File
@@ -324,9 +324,14 @@ SQL
# папки обмена шлюза по сети (SMB): для программ организации, которые кладут/забирают файлы через ИШ. bj это не
# нужно. Блок общих папок в smb.conf — между метками bj; заведённые вручную [igate_exchange] не трогаем.
share)
# Папки обмена шлюза по сети (Samba). Два режима:
# simple — один логин и пароль (пользователь Samba, файлы пишутся от имени шлюза);
# domain — сервер в домене AD (winbind): входят сотрудники и группы домена с доступом к bj (список даёт bj).
CONF=/etc/samba/smb.conf; B='# >>> bj: папки шлюза НРД'; E='# <<< bj: папки шлюза НРД'
on=$(j enabled); su=$(j user); sp=$(j password)
GB='# >>> bj: вход через домен'; GE='# <<< bj: вход через домен'
on=$(j enabled); su=$(j user); sp=$(j password); mode=$(j mode); vu=$(j valid)
manual=false; [ -f "$CONF" ] && sed "/^$B/,/^$E/d" "$CONF" | grep -q '^\[igate_exchange\]' && manual=true
dmode=simple; [ -f "$CONF" ] && grep -q "^$GB" "$CONF" && dmode=domain
if [ "${on:-}" = status ] || [ -z "${on:-}" ]; then
mine=false; [ -f "$CONF" ] && grep -q "^$B" "$CONF" && mine=true
u=$( [ "$mine" = true ] && sed -n "/^$B/,/^$E/p" "$CONF" | sed -n 's/^ *valid users *= *//p' | head -1 )
@@ -335,7 +340,9 @@ SQL
ip=$(hostname -I 2>/dev/null | awk '{print $1}'); hn=$(hostname -s 2>/dev/null)
sh=""; [ "$mine" = true ] && for n in $(sed -n "/^$B/,/^$E/p" "$CONF" | sed -n 's/^\[\(.*\)\]$/\1/p'); do sh="$sh${sh:+,}$(q "$n")"; done
[ "$manual" = true ] && [ -z "$sh" ] && sh=$(q igate_exchange)
echo "{\"ok\":true,\"on\":$( [ "$mine" = true ] || [ "$manual" = true ] && echo true || echo false),\"manual\":$manual,\"user\":$(q "$u"),\"smbd\":$(q "$(systemctl is-active smbd 2>/dev/null)"),\"ip\":$(q "$ip"),\"host\":$(q "$hn"),\"shares\":[$sh]}"; exit 0
realm=$( [ "$dmode" = domain ] && sed -n "/^$GB/,/^$GE/p" "$CONF" | sed -n 's/^ *realm *= *//p' | head -1 )
joined=false; [ "$dmode" = domain ] && command -v net >/dev/null 2>&1 && net ads testjoin >/dev/null 2>&1 && joined=true
echo "{\"ok\":true,\"on\":$( [ "$mine" = true ] || [ "$manual" = true ] && echo true || echo false),\"manual\":$manual,\"mode\":$(q "$dmode"),\"joined\":$joined,\"realm\":$(q "$realm"),\"user\":$(q "$u"),\"smbd\":$(q "$(systemctl is-active smbd 2>/dev/null)"),\"ip\":$(q "$ip"),\"host\":$(q "$hn"),\"shares\":[$sh]}"; exit 0
fi
[ "$manual" = true ] && err "папка igate_exchange уже открыта вручную в $CONF — bj её не меняет; чтобы управлять из bj, уберите её из smb.conf"
if [ "$on" != true ]; then
@@ -343,11 +350,39 @@ SQL
systemctl reload smbd >/dev/null 2>&1 || true
ok "доступ к папкам шлюза по сети закрыт"; exit 0
fi
[[ "$su" =~ ^[a-z][a-z0-9_-]{2,31}$ ]] || err "логин — латинские строчные буквы, цифры, _ и -, от 3 символов, начинается с буквы"
case "$su" in root|igate|bj|postgres) err "логин $su занят системой — выберите другой" ;; esac
[ -n "$mode" ] || mode=$dmode
if ! command -v smbd >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -q samba >/dev/null 2>&1 || err "не удалось установить Samba (apt-get install samba) — нужен доступ к репозиторию"
fi
note=""
if [ "$mode" = domain ]; then
# подключение сервера к домену — один раз: учётная запись администратора домена не сохраняется
if [ "$(j join)" = 1 ]; then
realm=$(j realm | tr '[:lower:]' '[:upper:]'); wg=$(j workgroup | tr '[:lower:]' '[:upper:]'); au=$(j admin); ap=$(j adminpw)
[[ "$realm" =~ ^[A-Z0-9][A-Z0-9.-]+$ ]] || err "домен не задан — подключите домен в bj: Настройка → Пользователи"
[[ "$wg" =~ ^[A-Z0-9][A-Z0-9_-]{0,14}$ ]] || err "не определено краткое имя домена (NetBIOS)"
[ -n "$au" ] && [ -n "$ap" ] || err "нужны логин и пароль администратора домена"
command -v winbindd >/dev/null 2>&1 || DEBIAN_FRONTEND=noninteractive apt-get install -y -q winbind libnss-winbind >/dev/null 2>&1 || err "не удалось установить winbind (apt-get install winbind) — нужен доступ к репозиторию"
cp -p "$CONF" "$CONF.bj-before-join" 2>/dev/null
sed -i "/^$GB/,/^$GE/d" "$CONF"
{ echo "$GB"
printf '[global]\n workgroup = %s\n realm = %s\n security = ads\n kerberos method = secrets and keytab\n winbind refresh tickets = yes\n idmap config * : backend = tdb\n idmap config * : range = 3000-7999\n idmap config %s : backend = rid\n idmap config %s : range = 10000-999999\n map to guest = never\n' "$wg" "$realm" "$wg" "$wg"
echo "$GE"; } >> "$CONF"
# блок домена — раньше папок: папки дописываются после него
if grep -q "^$B" "$CONF"; then blk=$(sed -n "/^$B/,/^$E/p" "$CONF"); sed -i "/^$B/,/^$E/d" "$CONF"; printf '%s\n' "$blk" >> "$CONF"; fi
testparm -s "$CONF" >/dev/null 2>&1 || { cp -p "$CONF.bj-before-join" "$CONF"; err "smb.conf с параметрами домена не проходит проверку (testparm) — правка отменена"; }
out=$(PASSWD="$ap" net ads join -U "$au" 2>&1) || { cp -p "$CONF.bj-before-join" "$CONF"; systemctl restart smbd >/dev/null 2>&1; err "сервер не подключён к домену: $(printf '%s' "$out" | tail -2 | tr '\n' ' ')"; }
systemctl enable winbind >/dev/null 2>&1; systemctl restart winbind >/dev/null 2>&1
net ads testjoin >/dev/null 2>&1 || err "сервер подключён, но проверка подключения к домену не прошла (net ads testjoin)"
note="сервер подключён к домену $realm; "; dmode=domain
fi
[ "$dmode" = domain ] || err "сервер ещё не подключён к домену — нажмите «Подключить сервер к домену»"
[ -n "$vu" ] || err "в bj нет групп и сотрудников домена с доступом — добавьте их в Настройка → Пользователи"
case "$vu" in *$'\n'*|*'['*|*']'*|*';'*|*'#'*|*'%'*) err "недопустимые символы в списке доступа" ;; esac
valid="$vu"
else
[[ "$su" =~ ^[a-z][a-z0-9_-]{2,31}$ ]] || err "логин — латинские строчные буквы, цифры, _ и -, от 3 символов, начинается с буквы"
case "$su" in root|igate|bj|postgres) err "логин $su занят системой — выберите другой" ;; esac
id "$su" >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin "$su" || err "пользователь $su не создан"
if [ -n "$sp" ]; then
[ ${#sp} -ge 8 ] || err "пароль — не короче 8 символов"
@@ -356,11 +391,15 @@ SQL
elif ! pdbedit -L 2>/dev/null | grep -q "^$su:"; then
err "задайте пароль для $su"
fi
# простой режим — без домена: вход учётной записью Samba
if [ "$dmode" = domain ]; then sed -i "/^$GB/,/^$GE/d" "$CONF"; systemctl disable --now winbind >/dev/null 2>&1; note="вход через домен выключен (учётная запись компьютера в домене осталась); "; fi
valid="$su"
fi
[ -f "$CONF" ] && sed -i "/^$B/,/^$E/d" "$CONF"
{ echo "$B"
for c in test3 prod; do d=$(exchange_dir "$c"); [ -d "$d" ] || continue
n=igate_exchange; [ "$c" = prod ] && n=igate_exchange_prod
printf '[%s]\n comment = Шлюз НРД: папки обмена %s (INBOX/OUTBOX/SENT/ERRORS)\n path = %s\n browseable = yes\n read only = no\n valid users = %s\n force user = %s\n force group = %s\n' "$n" "$c" "$d" "$su" "$SVC_USER" "$SVC_USER"
printf '[%s]\n comment = Шлюз НРД: папки обмена %s (INBOX/OUTBOX/SENT/ERRORS)\n path = %s\n browseable = yes\n read only = no\n valid users = %s\n force user = %s\n force group = %s\n' "$n" "$c" "$d" "$valid" "$SVC_USER" "$SVC_USER"
done
echo "$E"; } >> "$CONF"
# Windows сначала пробует войти текущей учётной записью; с «map to guest = bad user» Samba пускает её гостем,
@@ -368,7 +407,8 @@ SQL
sed -i -E 's/^([[:space:]]*)map to guest[[:space:]]*=[[:space:]]*bad user.*$/\1map to guest = never/I' "$CONF"
testparm -s "$CONF" >/dev/null 2>&1 || { sed -i "/^$B/,/^$E/d" "$CONF"; err "smb.conf после правки не проходит проверку (testparm) — правка отменена"; }
systemctl enable smbd >/dev/null 2>&1; systemctl restart smbd >/dev/null 2>&1 || err "служба smbd не перезапустилась"
ok "папки шлюза открыты по сети для $su: \\\\$(hostname -I | awk '{print $1}')\\igate_exchange" ;;
if [ "$dmode" = domain ]; then ok "${note}папки шлюза открыты по сети для сотрудников домена с доступом к bj: \\\\$(hostname -I | awk '{print $1}')\\igate_exchange"
else ok "${note}папки шлюза открыты по сети для $su: \\\\$(hostname -I | awk '{print $1}')\\igate_exchange"; fi ;;
*) err "неизвестная команда: $cmd" ;;
esac
+1
View File
@@ -543,5 +543,6 @@ func (h *setupHandlers) adGroupSave(w http.ResponseWriter, r *http.Request) {
h.usersFlash(w, r, "Группа домена: "+err.Error())
return
}
igateShareSyncSoon(h.rc) // папки шлюза «через домен» — тем же группам
h.usersFlash(w, r, msg)
}
+3
View File
@@ -1366,6 +1366,7 @@ func (h *setupHandlers) saveUser(w http.ResponseWriter, r *http.Request) {
h.usersFlash(w, r, "Создан первый администратор "+u.Login+". Теперь вход обязателен — войдите под ним.")
return
}
igateShareSyncSoon(h.rc) // доменный сотрудник — и к папкам шлюза «через домен»
h.usersFlash(w, r, "Пользователь "+u.Login+" сохранён.")
}
@@ -1390,6 +1391,7 @@ func (h *setupHandlers) deleteUser(w http.ResponseWriter, r *http.Request) {
return
}
_ = globalUsers.Delete(login)
igateShareSyncSoon(h.rc)
globalSessions.DropUser(login)
h.usersFlash(w, r, "Пользователь "+login+" удалён.")
}
@@ -1445,6 +1447,7 @@ func (h *setupHandlers) saveAuth(w http.ResponseWriter, r *http.Request) {
h.usersFlash(w, r, "Вход: "+err.Error())
return
}
igateShareSyncSoon(h.rc)
h.usersFlash(w, r, "Настройки входа сохранены.")
}
+1 -12
View File
@@ -14,7 +14,6 @@ import (
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@@ -259,17 +258,7 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) {
go igateContourRun(rc, contour, crypto, dep)
jsonOut(w, map[string]any{"ok": true, "started": true})
})
// папки обмена шлюза по сети (SMB) — для программ организации; GET — состояние, POST — открыть/закрыть
mux.HandleFunc("/admin/setup/igate/share", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) // первый раз — установка Samba
defer cancel()
p := map[string]string{"enabled": "status"}
if r.Method == http.MethodPost {
p = map[string]string{"enabled": strconv.FormatBool(r.FormValue("enabled") != ""), "user": strings.TrimSpace(r.FormValue("user")), "password": r.FormValue("password")}
}
extra, _ := json.Marshal(p)
jsonOut(w, opResult(runKeymediaOp(ctx, "igate-share", map[string]string{"extra": string(extra)})))
})
registerIgateShare(mux, rc) // папки обмена шлюза по сети (igateshare.go)
// ход записи канала: страница показывает его и после перезагрузки
mux.HandleFunc("/admin/setup/igate/contour-status", func(w http.ResponseWriter, r *http.Request) {
jsonOut(w, igateContourJob.view())
+270
View File
@@ -0,0 +1,270 @@
package lkgateway
// Папки обмена шлюза НРД по сети (07.10.2026): два режима на выбор.
// simple — один логин и пароль Samba (как было);
// domain — сервер bj подключён к домену AD (winbind), папки открыты тем же группам и сотрудникам домена,
// у которых есть доступ к bj: группы с ролью (Auth.ADGroups) и доменные учётные записи bj. Сотрудник
// открывает папку своим входом в Windows. Список доступа bj сверяет сам — при изменении групп и
// сотрудников и раз в 15 минут.
// Подключение к домену — один раз, учётной записью администратора домена; bj её не сохраняет.
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/go-ldap/ldap/v3"
)
func registerIgateShare(mux *http.ServeMux, rc *RuntimeConfig) {
// GET — состояние (+ что даст режим «через домен»), POST — открыть/закрыть, режим
mux.HandleFunc("/admin/setup/igate/share", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) // первый раз — установка Samba
defer cancel()
if r.Method != http.MethodPost {
st := opResult(igateShareStatus(ctx))
a := rc.Snapshot().Auth
st["ad_ready"] = a.ADEnabled && a.ADBindUser != "" && a.ADDomain != ""
st["ad_domain"] = a.ADDomain
st["access"] = igateShareAccessView(a)
jsonOut(w, st)
return
}
p := map[string]string{"enabled": fmt.Sprint(r.FormValue("enabled") != ""), "mode": "simple",
"user": strings.TrimSpace(r.FormValue("user")), "password": r.FormValue("password")}
if r.FormValue("mode") == "domain" {
valid, err := igateShareValid(rc.Snapshot().Auth)
if err != nil {
jsonOut(w, map[string]any{"ok": false, "error": err.Error()})
return
}
p = map[string]string{"enabled": fmt.Sprint(r.FormValue("enabled") != ""), "mode": "domain", "valid": valid}
}
extra, _ := json.Marshal(p)
jsonOut(w, opResult(runKeymediaOp(ctx, "igate-share", map[string]string{"extra": string(extra)})))
})
// подключить сервер к домену (один раз) и открыть папки сотрудникам домена с доступом к bj
mux.HandleFunc("/admin/setup/igate/share-join", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
a := rc.Snapshot().Auth
admin, pw := strings.TrimSpace(r.FormValue("admin")), r.FormValue("adminpw")
if !a.ADEnabled || a.ADDomain == "" || a.ADBindUser == "" {
jsonOut(w, map[string]any{"ok": false, "error": "домен в bj не подключён — Настройка → Пользователи → «Сотрудники из домена»"})
return
}
if admin == "" || pw == "" {
jsonOut(w, map[string]any{"ok": false, "error": "не заполнено: логин и пароль администратора домена"})
return
}
wg, err := adNetbiosName(a)
if err != nil {
jsonOut(w, map[string]any{"ok": false, "error": "краткое имя домена не определено: " + err.Error()})
return
}
valid, err := igateShareValidWG(a, wg)
if err != nil {
jsonOut(w, map[string]any{"ok": false, "error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 6*time.Minute) // установка winbind и подключение к домену
defer cancel()
extra, _ := json.Marshal(map[string]string{"enabled": "true", "mode": "domain", "join": "1", "realm": a.ADDomain,
"workgroup": wg, "admin": admin, "adminpw": pw, "valid": valid})
res := opResult(runKeymediaOp(ctx, "igate-share", map[string]string{"extra": string(extra)}))
if ok, _ := res["ok"].(bool); ok {
log.Printf("igate-share: сервер подключён к домену %s (%s), папки открыты: %s", a.ADDomain, wg, valid)
}
jsonOut(w, res)
})
// сейчас сверить список доступа (после изменения групп и сотрудников)
mux.HandleFunc("/admin/setup/igate/share-sync", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
msg, err := igateShareSync(ctx, rc)
if err != nil {
jsonOut(w, map[string]any{"ok": false, "error": err.Error()})
return
}
jsonOut(w, map[string]any{"ok": true, "output": msg})
})
}
func igateShareStatus(ctx context.Context) (map[string]any, error) {
extra, _ := json.Marshal(map[string]string{"enabled": "status"})
return runKeymediaOp(ctx, "igate-share", map[string]string{"extra": string(extra)})
}
// adNetbiosName — краткое имя домена (CORP) из раздела конфигурации AD; запоминается на время работы bj.
var netbiosCache sync.Map // ADDomain → имя
func adNetbiosName(a AuthSettings) (string, error) {
if v, ok := netbiosCache.Load(strings.ToLower(a.ADDomain)); ok {
return v.(string), nil
}
conn, err := adDial(a)
if err != nil {
return "", err
}
defer conn.Close()
if err := adBindService(conn, a); err != nil {
return "", err
}
base := adDefaultBase(conn)
if base == "" {
return "", fmt.Errorf("контроллер не назвал базу домена")
}
sr, err := conn.Search(ldap.NewSearchRequest("CN=Partitions,CN=Configuration,"+base, ldap.ScopeSingleLevel, ldap.NeverDerefAliases, 5, 10, false,
"(&(objectClass=crossRef)(nETBIOSName=*))", []string{"nETBIOSName", "dnsRoot"}, nil))
if err != nil {
return "", err
}
for _, e := range sr.Entries {
if strings.EqualFold(e.GetAttributeValue("dnsRoot"), a.ADDomain) || len(sr.Entries) == 1 {
n := strings.ToUpper(e.GetAttributeValue("nETBIOSName"))
netbiosCache.Store(strings.ToLower(a.ADDomain), n)
return n, nil
}
}
return "", fmt.Errorf("в домене %s не найдено краткое имя", a.ADDomain)
}
// igateShareMembers — кому открыть папки: группы домена с ролью в bj и доменные учётные записи bj (включённые).
func igateShareMembers(a AuthSettings) (groups, users []string) {
seen := map[string]bool{}
for _, g := range a.ADGroups {
n := strings.TrimSpace(dnCN(g.Group))
if n == "" || !roleAvailable(g.Role) || seen["g:"+strings.ToLower(n)] {
continue
}
seen["g:"+strings.ToLower(n)] = true
groups = append(groups, n)
}
if globalUsers != nil {
for _, u := range globalUsers.List() {
if u.Source != "ad" || !u.Active || seen["u:"+strings.ToLower(u.Login)] {
continue
}
seen["u:"+strings.ToLower(u.Login)] = true
users = append(users, u.Login)
}
}
sort.Strings(groups)
sort.Strings(users)
return groups, users
}
// igateShareAccessView — список доступа словами (для страницы).
func igateShareAccessView(a AuthSettings) map[string]any {
g, u := igateShareMembers(a)
return map[string]any{"groups": g, "users": u}
}
func igateShareValid(a AuthSettings) (string, error) {
wg, err := adNetbiosName(a)
if err != nil {
return "", fmt.Errorf("краткое имя домена не определено: %v", err)
}
return igateShareValidWG(a, wg)
}
// igateShareValidWG — строка «valid users» для Samba: @"CORP\Группа", "CORP\логин".
func igateShareValidWG(a AuthSettings, wg string) (string, error) {
groups, users := igateShareMembers(a)
var parts []string
clean := func(s string) string { return strings.NewReplacer(`"`, "", "\n", "", "\r", "", ",", "").Replace(s) }
for _, g := range groups {
parts = append(parts, `@"`+wg+`\`+clean(g)+`"`)
}
for _, u := range users {
parts = append(parts, `"`+wg+`\`+clean(u)+`"`)
}
if len(parts) == 0 {
return "", fmt.Errorf("в bj нет групп и сотрудников домена с доступом — добавьте их в Настройка → Пользователи («Сотрудники из домена»)")
}
return strings.Join(parts, ", "), nil
}
// igateShareSync — режим «через домен»: если состав групп и сотрудников с доступом к bj изменился, переписать
// доступ к папкам. В простом режиме и при закрытых папках ничего не делает.
func igateShareSync(ctx context.Context, rc *RuntimeConfig) (string, error) {
st, err := igateShareStatus(ctx)
if err != nil {
return "", err
}
if on, _ := st["on"].(bool); !on {
return "папки закрыты — сверять нечего", nil
}
if m, _ := st["mode"].(string); m != "domain" {
return "простой режим — доступ по одному логину", nil
}
valid, err := igateShareValid(rc.Snapshot().Auth)
if err != nil {
return "", err
}
if cur, _ := st["user"].(string); cur == valid {
return "список доступа не изменился", nil
}
extra, _ := json.Marshal(map[string]string{"enabled": "true", "mode": "domain", "valid": valid})
res, err := runKeymediaOp(ctx, "igate-share", map[string]string{"extra": string(extra)})
if err != nil {
return "", err
}
if ok, _ := res["ok"].(bool); !ok {
e, _ := res["error"].(string)
return "", fmt.Errorf("%s", e)
}
log.Printf("igate-share: доступ к папкам шлюза обновлён: %s", valid)
return "доступ обновлён: " + valid, nil
}
// igateShareSyncSoon — после изменения групп или сотрудников: сверить доступ к папкам в фоне (раз в несколько
// секунд, сколько бы изменений ни пришло подряд).
var igateShareSyncTimer struct {
sync.Mutex
t *time.Timer
}
func igateShareSyncSoon(rc *RuntimeConfig) {
if rc == nil {
return
}
igateShareSyncTimer.Lock()
defer igateShareSyncTimer.Unlock()
if igateShareSyncTimer.t != nil {
igateShareSyncTimer.t.Stop()
}
igateShareSyncTimer.t = time.AfterFunc(5*time.Second, func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if _, err := igateShareSync(ctx, rc); err != nil {
log.Printf("igate-share: сверка доступа к папкам: %v", err)
}
})
}
// igateShareSyncLoop — подстраховка: раз в 15 минут (группы могли поменять в настройках, пока bj перезапускался).
func (s *Server) igateShareSyncLoop(ctx context.Context) {
t := time.NewTicker(15 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
c, cancel := context.WithTimeout(ctx, 2*time.Minute)
if _, err := igateShareSync(c, s.rc); err != nil {
log.Printf("igate-share: сверка доступа к папкам: %v", err)
}
cancel()
}
}
+32
View File
@@ -0,0 +1,32 @@
package lkgateway
import (
"strings"
"testing"
)
// Папки шлюза «через домен»: доступ — группам с ролью в bj и доменным сотрудникам bj (включённым).
func TestIgateShareValid(t *testing.T) {
prev := globalUsers
globalUsers = &userStore{users: map[string]*User{}}
defer func() { globalUsers = prev }()
globalUsers.users["azuev"] = &User{Login: "azuev", Source: "ad", Active: true, Role: "operator"}
globalUsers.users["old"] = &User{Login: "old", Source: "ad", Active: false, Role: "viewer"}
globalUsers.users["admin"] = &User{Login: "admin", Source: "local", Active: true, Role: "admin"}
a := AuthSettings{ADGroups: []ADGroupRole{{Group: "CN=BJ-Operators,OU=Groups,DC=corp,DC=local", Role: "operator"}, {Group: "Депозитарий \"Главный\"", Role: "viewer"}}}
got, err := igateShareValidWG(a, "CORP")
if err != nil {
t.Fatal(err)
}
want := `@"CORP\BJ-Operators", @"CORP\Депозитарий Главный", "CORP\azuev"`
if got != want {
t.Fatalf("\n got %s\nwant %s", got, want)
}
if strings.Contains(got, "old") || strings.Contains(got, "admin") {
t.Fatal("в доступ попали отключённый или локальный пользователь")
}
globalUsers = &userStore{users: map[string]*User{}}
if _, err := igateShareValidWG(AuthSettings{}, "CORP"); err == nil {
t.Fatal("пустой список доступа должен быть ошибкой")
}
}
+1
View File
@@ -303,6 +303,7 @@ func (s *Server) Run(ctx context.Context) error {
go superviseLoop(ctx, "consume-decisions", s.rc, s.consumeDecisions)
go superviseLoop(ctx, "igw-watch", s.rc, s.igwWatchLoop) // сторож ИШ НРД: оповещения и автоперезапуск
go superviseLoop(ctx, "units-watch", s.rc, s.unitsWatchLoop) // службы модулей и ключей Валидаты: письмо администратору
go superviseLoop(ctx, "igate-share", s.rc, s.igateShareSyncLoop) // папки шлюза «через домен»: список доступа = группы и сотрудники bj
go handbookLoop(ctx, s.rc) // справочник участников M2M с сервера лицензий (для установок без шлюза НРД)
// Поллер входящих от НРД (только в реальном режиме ИШ): забирает
@@ -24,6 +24,9 @@
.ct-act .btn[disabled]{opacity:.5;cursor:not-allowed}
@media (max-width:760px){.ct-grid{grid-template-columns:minmax(0,1fr)}}
.ig-share{margin-top:18px;padding-top:16px;border-top:1px solid var(--border)}
.ig-sh-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:10px}
.ig-sh-grid label.f input{width:100%;box-sizing:border-box}
@media (max-width:600px){.ig-sh-grid{grid-template-columns:minmax(0,1fr)}}
.ig-addr{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:6px}
.ig-addr code{font-size:14px;padding:4px 10px;border-radius:8px;background:var(--card);border:1px solid var(--border);user-select:all}
.ig-addr .muted{font-size:12.5px}
@@ -81,13 +84,29 @@
</div>
<div class="ig-share" id="ig-share">
<h3>Папки шлюза по сети <span class="muted" style="font-weight:400;font-size:13px">— необязательно</span></h3>
<p class="muted" style="font-size:13px;margin:8px 0 0">Папки обмена шлюза (INBOX, OUTBOX, SENT, ERRORS) можно открыть по сети Windows — если другие программы организации кладут или забирают файлы через этот шлюз. bj для работы это не нужно. Доступ — по своему логину и паролю, файлы пишутся от имени шлюза.</p>
<p class="muted" style="font-size:13px;margin:4px 0 0">Папки обмена шлюза (INBOX, OUTBOX, SENT, ERRORS) можно открыть по сети Windows — если другие программы организации кладут или забирают файлы через этот шлюз. bj для работы это не нужно; файлы пишутся от имени шлюза.</p>
<div id="ig-share-st" class="muted" style="font-size:13px;margin-top:8px">Проверяю…</div>
<form id="ig-share-f" style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;max-width:560px;margin-top:10px">
<label style="grid-column:1/-1;display:flex;gap:8px;align-items:center;font-weight:400;margin:0"><input type="checkbox" name="enabled" style="width:auto"> Открыть папки шлюза по сети</label>
<form id="ig-share-f" class="dq" style="margin-top:12px;max-width:680px">
<div class="dq-tiles">
<label class="dq-tile"><input type="radio" name="mode" value="simple" checked><span><b>Простой</b><small>Один логин и пароль на всех, кому нужны папки.</small></span></label>
<label class="dq-tile" id="ig-sh-dtile"><input type="radio" name="mode" value="domain"><span><b>Через домен</b><small id="ig-sh-dnote">Те же группы и сотрудники домена, что и в bj, — своим входом в Windows, без отдельного пароля. Убрали из группы — доступ пропал.</small></span></label>
</div>
<label style="display:flex;gap:8px;align-items:center;font-weight:500;margin:12px 0 0"><input type="checkbox" name="enabled" style="width:auto"> Открыть папки шлюза по сети</label>
<div id="ig-sh-simple" class="ig-sh-grid">
<label class="f">Логин<input name="user" value="igate-share" autocomplete="off" spellcheck="false"></label>
<label class="f">Пароль (от 8 символов)<input name="password" type="password" autocomplete="new-password" spellcheck="false" placeholder="пусто — прежний"></label>
<div style="grid-column:1/-1;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><button type="submit" class="btn sm">Сохранить</button><span id="ig-share-out" style="font-size:13px"></span></div>
</div>
<div id="ig-sh-domain" hidden>
<div id="ig-sh-access" class="muted" style="font-size:13px;margin-top:10px"></div>
<div id="ig-sh-join" hidden style="margin-top:10px">
<p style="margin:0;font-size:13.5px">Сервер bj ещё не подключён к домену <b id="ig-sh-realm"></b>. Подключить нужно один раз — учётной записью с правом добавлять компьютеры в домен; bj её не сохраняет. В домене появится компьютер <b id="ig-sh-host"></b>.</p>
<div class="ig-sh-grid">
<label class="f">Администратор домена<input name="admin" autocomplete="off" spellcheck="false" placeholder="например admin или admin@corp.local"></label>
<label class="f">Пароль<input name="adminpw" type="password" autocomplete="new-password"></label>
</div>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:12px"><button type="submit" class="btn sm" id="ig-sh-save">Сохранить</button><span id="ig-share-out" style="font-size:13px"></span></div>
</form>
</div>
<div class="ig-job" id="ig-job" hidden aria-live="polite">
@@ -160,28 +179,54 @@
}).catch(function(){});
return {watch:watch};
})();
// доступ к папкам шлюза по сети (Samba): состояние и сохранение
// доступ к папкам шлюза по сети (Samba): простой режим или через домен
(function(){
var f=document.getElementById('ig-share-f'), stl=document.getElementById('ig-share-st'), o=document.getElementById('ig-share-out');
if(!f) return;
var cur={}, sim=document.getElementById('ig-sh-simple'), dom=document.getElementById('ig-sh-domain'), join=document.getElementById('ig-sh-join'), acc=document.getElementById('ig-sh-access');
function mode(){ return f.mode.value; }
function sync(){ var d=mode()==='domain'; sim.hidden=d; dom.hidden=!d; join.hidden=!(d && !cur.joined);
document.getElementById('ig-sh-save').textContent=(d && !cur.joined)?'Подключить сервер к домену и открыть папки':'Сохранить'; }
function addrs(d){ var hosts=[d.ip||location.hostname]; if(d.host && d.host!==hosts[0]) hosts.push(d.host);
var names=(d.shares&&d.shares.length)?d.shares:['igate_exchange'], rows=[];
names.forEach(function(n){ hosts.forEach(function(h){ var p='\\\\'+h+'\\'+n;
rows.push('<div class="ig-addr"><code>'+esc(p)+'</code><button type="button" class="btn sm ghost" data-copy="'+esc(p)+'">Скопировать</button><span class="muted">'+(n.indexOf('prod')>=0?'боевой контур':'тестовый контур TEST3')+(h===d.host?' · по имени сервера':'')+'</span></div>'); }); });
return rows.join(''); }
function show(d){
if(!d.ok){ stl.innerHTML=bad(d.error||'состояние не получено'); return; }
if(d.manual){ stl.innerHTML=ok('папки открыты вручную (smb.conf) для '+(d.user||'—')+' — bj их не меняет'); f.hidden=true; return; }
cur=d;
if(d.manual){ stl.innerHTML=ok('папки открыты вручную (smb.conf) для '+esc(d.user||'—')+' — bj их не меняет'); f.hidden=true; return; }
var a=d.access||{}, g=a.groups||[], u=a.users||[];
acc.innerHTML=(g.length||u.length)?('Доступ получат: '+(g.length?'группы <b>'+g.map(esc).join('</b>, <b>')+'</b>':'')+(g.length&&u.length?'; ':'')+(u.length?'сотрудники <b>'+u.map(esc).join('</b>, <b>')+'</b>':'')+'. Список — из Настройка → Пользователи, обновляется сам.'):'<span style="color:var(--warn)">В bj пока нет групп и сотрудников домена с доступом — добавьте их в Настройка → Пользователи («Сотрудники из домена»).</span>';
var dt=document.getElementById('ig-sh-dtile'), dr=f.querySelector('input[name=mode][value=domain]');
dr.disabled=!d.ad_ready; dt.style.opacity=d.ad_ready?'':'.55';
if(!d.ad_ready) document.getElementById('ig-sh-dnote').textContent='Сначала подключите домен в bj: Настройка → Пользователи → «Сотрудники из домена».';
document.getElementById('ig-sh-realm').textContent=d.ad_domain||d.realm||'';
document.getElementById('ig-sh-host').textContent=d.host||'';
f.querySelector('input[name=mode][value='+(d.mode==='domain'?'domain':'simple')+']').checked=true;
if(d.on){
var hosts=[d.ip||location.hostname]; if(d.host && d.host!==hosts[0]) hosts.push(d.host);
var names=(d.shares&&d.shares.length)?d.shares:['igate_exchange'];
var rows=[]; names.forEach(function(n){ hosts.forEach(function(h){ var p='\\\\'+h+'\\'+n;
rows.push('<div class="ig-addr"><code>'+esc(p)+'</code><button type="button" class="btn sm ghost" data-copy="'+esc(p)+'">Скопировать</button><span class="muted">'+(n.indexOf('prod')>=0?'боевой контур':'тестовый контур TEST3')+(h===d.host?' · по имени сервера':'')+'</span></div>'); }); });
stl.innerHTML='<div class="stc stc-ok" style="margin-top:4px"><span class="stc-ic"></span><div class="stc-b"><b>Папки открыты по сети</b><small>Вставьте адрес в адресную строку проводника Windows; вход — логин <b>'+esc(d.user||'—')+'</b> и пароль, который задали здесь.</small>'+rows.join('')+'</div></div>';
var who=d.mode==='domain'?'Входят сотрудники домена с доступом к bj — своей учётной записью Windows, пароль не спрашивается.':'Вход — логин <b>'+esc(d.user||'—')+'</b> и пароль, который задали здесь.';
stl.innerHTML='<div class="stc stc-ok" style="margin-top:4px"><span class="stc-ic"></span><div class="stc-b"><b>Папки открыты по сети'+(d.mode==='domain'?' — через домен '+esc(d.realm||''):'')+'</b><small>Вставьте адрес в адресную строку проводника Windows. '+who+'</small>'+addrs(d)+'</div></div>';
stl.querySelectorAll('[data-copy]').forEach(function(b){ b.onclick=function(){ (window.bjCopy||function(t){navigator.clipboard&&navigator.clipboard.writeText(t)})(b.dataset.copy); b.textContent='Скопировано ✓'; setTimeout(function(){b.textContent='Скопировать'},1500); }; });
} else stl.innerHTML='<span class="muted">Сейчас закрыты. Отметьте «Открыть папки шлюза по сети», задайте пароль и нажмите «Сохранить» — здесь появится адрес для проводника.</span>';
f.enabled.checked=!!d.on; if(d.user) f.user.value=d.user;
} else stl.innerHTML='<span class="muted">Сейчас закрыты. Выберите, как входить, отметьте «Открыть папки шлюза по сети» и сохраните — здесь появится адрес для проводника.</span>';
f.enabled.checked=!!d.on; if(d.mode!=='domain' && d.user) f.user.value=d.user;
sync();
}
fetch('/admin/setup/igate/share',{cache:'no-store'}).then(function(r){return r.json()}).then(show).catch(function(){ stl.textContent='Состояние не получено.'; });
f.addEventListener('submit',function(e){ e.preventDefault(); o.textContent='Сохраняю… (первый раз — установка Samba, до пары минут)';
var p=new URLSearchParams(); if(f.enabled.checked) p.set('enabled','1'); p.set('user',f.user.value.trim()); p.set('password',f.password.value);
post('/admin/setup/igate/share',p).then(function(d){ o.innerHTML=d.ok?ok(d.output):bad(d.error); f.password.value='';
return fetch('/admin/setup/igate/share',{cache:'no-store'}).then(function(r){return r.json()}).then(show); }).catch(function(){ o.innerHTML=bad('не сохранено'); }); });
function load(){ return fetch('/admin/setup/igate/share',{cache:'no-store'}).then(function(r){return r.json()}).then(show).catch(function(){ stl.textContent='Состояние не получено.'; }); }
f.addEventListener('change',function(e){ if(e.target.name==='mode') sync(); });
f.addEventListener('submit',function(e){ e.preventDefault();
var p=new URLSearchParams(), url='/admin/setup/igate/share', d=mode()==='domain';
if(d && !cur.joined){
if(!f.admin.value.trim()||!f.adminpw.value){ o.innerHTML=bad('не заполнено: администратор домена и пароль'); return; }
url='/admin/setup/igate/share-join'; p.set('admin',f.admin.value.trim()); p.set('adminpw',f.adminpw.value);
o.textContent='Подключаю сервер к домену… (первый раз — установка winbind, до пары минут)';
} else {
p.set('mode',mode()); if(f.enabled.checked) p.set('enabled','1');
if(!d){ p.set('user',f.user.value.trim()); p.set('password',f.password.value); }
o.textContent='Сохраняю… (первый раз — установка Samba, до пары минут)';
}
post(url,p).then(function(r){ o.innerHTML=r.ok?ok(r.output):bad(r.error); f.password.value=''; f.adminpw.value=''; return load(); }).catch(function(){ o.innerHTML=bad('не сохранено'); }); });
load();
})();
var st=null;
function load(){