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")