From 0a82dd3a02bcfe8cb19a75f91d4c6fbc1c0c9ca2 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 19 Sep 2026 23:27:26 +0300 Subject: [PATCH] =?UTF-8?q?feat(api,install):=20=D0=B8=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20fail2ban=20=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BC=D0=B5=D0=BD=D0=B0=20=D1=81=D0=B2=D0=BE=D0=B5=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=BD=D0=B8=D0=BA=D0=BD=D0=B5=D0=B9=D0=BC=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - установщик: `[DEFAULT] ignoreip` в jail.local — локальные сети и WireGuard исключены всегда, свой адрес добавляется флагом `--ignore-ip `; админ, часто заходящий в панель, больше не банит сам себя; - участник может менять свой никнейм на сервере без MANAGE_NICKNAMES и без проверки иерархии (тайм-аут на себя по-прежнему запрещён); - тест на смену своего ника и запрет self-тайм-аута. --- deploy/fail2ban/jail.local.tpl | 6 ++++++ deploy/install.sh | 9 ++++++++- internal/server/api_guilds.go | 14 ++++++++++--- internal/server/api_test.go | 37 ++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/deploy/fail2ban/jail.local.tpl b/deploy/fail2ban/jail.local.tpl index a209439..c40f20f 100644 --- a/deploy/fail2ban/jail.local.tpl +++ b/deploy/fail2ban/jail.local.tpl @@ -1,4 +1,10 @@ # Сгенерировано deploy/install.sh — не редактируйте вручную. +# Исключения: локальные и доверенные адреса не банятся никогда. Админ, +# который часто перезаходит в панель, не должен блокировать сам себя +# (AGENT.md 9.6: баны за атаки, а не за работу). +[DEFAULT] +ignoreip = 127.0.0.1/8 ::1 ${FAIL2BAN_IGNOREIP} + # Jail для неудачных входов в приложение (AGENT.md 9.6). [glchat-auth] enabled = true diff --git a/deploy/install.sh b/deploy/install.sh index 77ec7cc..017c983 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -81,6 +81,9 @@ ADMIN_EMAIL="${ADMIN_EMAIL:-}" ENABLE_ADMIN_2FA="${ENABLE_ADMIN_2FA:-0}" # Явная ротация секретов: по умолчанию секреты существующего .env сохраняются. ROTATE_SECRETS="${ROTATE_SECRETS:-0}" +# Список адресов, которые fail2ban не банит. Локальные сети и WireGuard-туннель +# добавляются всегда: иначе администратор блокирует сам себя при работе в панели. +FAIL2BAN_IGNOREIP="${FAIL2BAN_IGNOREIP:-192.168.0.0/16 10.0.0.0/8 172.16.0.0/12}" ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" REGISTRATION_ENABLED="${REGISTRATION_ENABLED:-true}" TURN_ENABLED="${TURN_ENABLED:-true}" @@ -121,6 +124,9 @@ install.sh — установка glchat (AGENT.md 10). --email email для Let's Encrypt (обязателен, если TLS включён) --admin логин инстанс-администратора --password пароль инстанс-администратора (иначе — интерактивный ввод) + --ignore-ip адрес, который fail2ban никогда не банит + (можно указывать несколько раз; локальные сети + и WireGuard уже включены по умолчанию) --rotate-secrets сменить секреты (MASTER_KEY, SESSION_PEPPER и др.): ломает существующие данные, нужен чистый инстанс --admin-2fa включить 2FA инстанс-админу и напечатать секрет и @@ -193,6 +199,7 @@ parse_args() { --password) ADMIN_PASSWORD="${2:?}"; shift 2 ;; --admin-2fa) ENABLE_ADMIN_2FA=1; shift ;; --rotate-secrets) ROTATE_SECRETS=1; shift ;; + --ignore-ip) FAIL2BAN_IGNOREIP="${FAIL2BAN_IGNOREIP} ${2:?}"; shift 2 ;; --instance-name) INSTANCE_NAME="${2:?}"; shift 2 ;; --main-guild) MAIN_GUILD_NAME="${2:?}"; shift 2 ;; --ssh-port) SSH_PORT="${2:?}"; shift 2 ;; @@ -1091,7 +1098,7 @@ render_all_templates() { if [ "$ENABLE_FAIL2BAN" = "true" ]; then render_template "${DEPLOY_DIR}/fail2ban/jail.local.tpl" "${TARGET_DIR}/fail2ban/jail.local" \ - "SSH_PORT=${SSH_PORT}" + "SSH_PORT=${SSH_PORT}" "FAIL2BAN_IGNOREIP=${FAIL2BAN_IGNOREIP}" fi if [ "$ENABLE_NFTABLES" = "true" ]; then diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index 96647d4..5b3ad88 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -729,21 +729,29 @@ func (s *Server) registerMemberRoutes(api huma.API) { if err != nil { return nil, err } - if err := s.checkMemberHierarchy(ctx, guildID, user, resolved, targetID); err != nil { - return nil, err + // Свой никнейм участник меняет сам: иерархия и MANAGE_NICKNAMES для + // этого не нужны (AGENT.md 7.2, 7.17). Тайм-аут на себя — запрещён. + selfEdit := targetID == user.ID + if !selfEdit { + if err := s.checkMemberHierarchy(ctx, guildID, user, resolved, targetID); err != nil { + return nil, err + } } var ( nickname *string timeoutUntil *time.Time ) if input.Body.Nickname != nil { - if !resolved.Has(permissions.ManageNicknames) { + if !selfEdit && !resolved.Has(permissions.ManageNicknames) { return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "MANAGE_NICKNAMES is required") } trimmed := strings.TrimSpace(*input.Body.Nickname) nickname = &trimmed } if input.Body.TimeoutUntil != nil { + if selfEdit { + return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you cannot time yourself out") + } if !resolved.Has(permissions.TimeoutMembers) { return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "TIMEOUT_MEMBERS is required") } diff --git a/internal/server/api_test.go b/internal/server/api_test.go index 8345f9b..6862331 100644 --- a/internal/server/api_test.go +++ b/internal/server/api_test.go @@ -452,6 +452,43 @@ func TestInstanceAdminEndpointsAndLimits(t *testing.T) { } } +func TestMemberCanChangeOwnNickname(t *testing.T) { + srv, _ := newTestServer(t) + ownerCookie := registerAndLogin(t, srv, "nick_owner", "nick-owner@example.com") + memberCookie := registerAndLogin(t, srv, "nick_member", "nick-member@example.com") + + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Ники"}`, ownerCookie) + guild := decodeResponse[struct { + Guild struct { + ID string `json:"id"` + } `json:"guild"` + }](t, created) + doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie) + + member, err := srv.auth.UserByEmail(t.Context(), "nick-member@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + self := doJSON(t, srv, http.MethodPatch, + "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID), + `{"nickname":"себе ник"}`, memberCookie) + if self.Code != http.StatusOK { + t.Fatalf("self nickname = %d, body = %s", self.Code, self.Body.String()) + } + + // Тайм-аут на себя запрещён даже владельцу. + owner, err := srv.auth.UserByEmail(t.Context(), "nick-owner@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + timeout := doJSON(t, srv, http.MethodPatch, + "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(owner.ID), + `{"timeout_until":"2030-01-01T00:00:00Z"}`, ownerCookie) + if timeout.Code != http.StatusForbidden { + t.Fatalf("self timeout = %d, want 403", timeout.Code) + } +} + func TestMemberTimeoutRequiresPermission(t *testing.T) { srv, _ := newTestServer(t) ownerCookie := registerAndLogin(t, srv, "timeout_owner", "timeout-owner@example.com")