diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..0d08e261
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
+
+version: 2
+updates:
+ - package-ecosystem: "github-actions" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 39ddf2e0..0dd4847d 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -15,13 +15,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
with:
submodules: true
- name: Docker meta
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: |
hsanaeii/3x-ui
@@ -32,28 +32,28 @@ jobs:
type=semver,pattern={{version}}
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
with:
install: true
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@v7
with:
context: .
push: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8b8d6902..ed9417c9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,11 +2,9 @@ name: Release 3X-UI
on:
workflow_dispatch:
- release:
- types: [published]
push:
branches:
- - main
+ - '**'
tags:
- "v*.*.*"
paths:
@@ -20,9 +18,48 @@ on:
- 'x-ui.service.debian'
- 'x-ui.service.arch'
- 'x-ui.service.rhel'
+ pull_request:
jobs:
+ analyze:
+ name: Analyze Go code
+ permissions:
+ contents: read
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Check formatting
+ run: |
+ unformatted=$(gofmt -l .)
+ if [ -n "$unformatted" ]; then
+ echo "These files are not gofmt-formatted:"
+ echo "$unformatted"
+ exit 1
+ fi
+
+ - name: Run go vet
+ run: go vet ./...
+
+ - name: Run staticcheck
+ uses: dominikh/staticcheck-action@v1
+ with:
+ version: "latest"
+ install-go: false
+
+ - name: Run tests
+ run: go test -race -shuffle=on ./...
+
build:
+ needs: analyze
permissions:
contents: write
strategy:
@@ -38,7 +75,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
@@ -133,19 +170,17 @@ jobs:
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
- name: Upload files to Artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: x-ui-linux-${{ matrix.platform }}
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
- name: Upload files to GH release
uses: svenstaro/upload-release-action@v2
- if: |
- (github.event_name == 'release' && github.event.action == 'published') ||
- (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
- tag: ${{ github.ref }}
+ tag: ${{ github.ref_name }}
file: x-ui-linux-${{ matrix.platform }}.tar.gz
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
overwrite: true
@@ -156,6 +191,7 @@ jobs:
# =================================
build-windows:
name: Build for Windows
+ needs: analyze
permissions:
contents: write
strategy:
@@ -165,7 +201,7 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
@@ -230,19 +266,17 @@ jobs:
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
- name: Upload files to Artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: x-ui-windows-amd64
path: ./x-ui-windows-amd64.zip
- name: Upload files to GH release
uses: svenstaro/upload-release-action@v2
- if: |
- (github.event_name == 'release' && github.event.action == 'published') ||
- (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
- tag: ${{ github.ref }}
+ tag: ${{ github.ref_name }}
file: x-ui-windows-amd64.zip
asset_name: x-ui-windows-amd64.zip
overwrite: true
diff --git a/web/controller/index.go b/web/controller/index.go
index 605f874f..dd58e5e5 100644
--- a/web/controller/index.go
+++ b/web/controller/index.go
@@ -1,10 +1,10 @@
package controller
import (
+ "fmt"
"net/http"
"text/template"
"time"
- "fmt"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/service"
@@ -79,12 +79,12 @@ func (a *IndexController) login(c *gin.Context) {
if user == nil {
logger.Warningf("wrong username: \"%s\", password: \"%s\", IP: \"%s\"", safeUser, safePass, getRemoteIp(c))
-
- notifyPass := safePass
-
+
+ notifyPass := safePass
+
if checkErr != nil && checkErr.Error() == "invalid 2fa code" {
translatedError := a.tgbot.I18nBot("tgbot.messages.2faFailed")
- notifyPass = fmt.Sprintf("*** (%s)", translatedError)
+ notifyPass = fmt.Sprintf("*** (%s)", translatedError)
}
a.tgbot.UserLoginNotify(safeUser, notifyPass, getRemoteIp(c), timeStr, 0)
diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go
index d3c1a1d1..cbc352dc 100644
--- a/web/job/check_client_ip_job.go
+++ b/web/job/check_client_ip_job.go
@@ -10,7 +10,6 @@ import (
"regexp"
"runtime"
"sort"
- "strconv"
"time"
"github.com/mhsanaei/3x-ui/v2/database"
@@ -319,13 +318,14 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
}
}
- // Convert back to slice and sort by timestamp (newest first)
+ // Convert back to slice and sort by timestamp (oldest first)
+ // This ensures we always protect the original/current connections and ban new excess ones.
allIps := make([]IPWithTimestamp, 0, len(ipMap))
for ip, timestamp := range ipMap {
allIps = append(allIps, IPWithTimestamp{IP: ip, Timestamp: timestamp})
}
sort.Slice(allIps, func(i, j int) bool {
- return allIps[i].Timestamp > allIps[j].Timestamp // Descending order (newest first)
+ return allIps[i].Timestamp < allIps[j].Timestamp // Ascending order (oldest first)
})
shouldCleanLog := false
@@ -345,23 +345,17 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
if len(allIps) > limitIp {
shouldCleanLog = true
- // Keep only the newest IPs (up to limitIp)
+ // Keep the oldest IPs (currently active connections) and ban the new excess ones.
keptIps := allIps[:limitIp]
- disconnectedIps := allIps[limitIp:]
+ bannedIps := allIps[limitIp:]
- // Log the disconnected IPs (old ones)
- for _, ipTime := range disconnectedIps {
+ // Log banned IPs in the format fail2ban filters expect: [LIMIT_IP] Email = X || Disconnecting OLD IP = Y || Timestamp = Z
+ for _, ipTime := range bannedIps {
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
}
- // Actually disconnect old IPs by temporarily removing and re-adding user
- // This forces Xray to drop existing connections from old IPs
- if len(disconnectedIps) > 0 {
- j.disconnectClientTemporarily(inbound, clientEmail, clients)
- }
-
- // Update database with only the newest IPs
+ // Update database with only the currently active (kept) IPs
jsonIps, _ := json.Marshal(keptIps)
inboundClientIps.Ips = string(jsonIps)
} else {
@@ -378,67 +372,12 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
}
if len(j.disAllowedIps) > 0 {
- logger.Infof("[LIMIT_IP] Client %s: Kept %d newest IPs, disconnected %d old IPs", clientEmail, limitIp, len(j.disAllowedIps))
+ logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, queued %d new IPs for fail2ban", clientEmail, limitIp, len(j.disAllowedIps))
}
return shouldCleanLog
}
-// disconnectClientTemporarily removes and re-adds a client to force disconnect old connections
-func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
- var xrayAPI xray.XrayAPI
-
- // Get panel settings for API port
- db := database.GetDB()
- var apiPort int
- var apiPortSetting model.Setting
- if err := db.Where("key = ?", "xrayApiPort").First(&apiPortSetting).Error; err == nil {
- apiPort, _ = strconv.Atoi(apiPortSetting.Value)
- }
-
- if apiPort == 0 {
- apiPort = 10085 // Default API port
- }
-
- err := xrayAPI.Init(apiPort)
- if err != nil {
- logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err)
- return
- }
- defer xrayAPI.Close()
-
- // Find the client config
- var clientConfig map[string]any
- for _, client := range clients {
- if client.Email == clientEmail {
- // Convert client to map for API
- clientBytes, _ := json.Marshal(client)
- json.Unmarshal(clientBytes, &clientConfig)
- break
- }
- }
-
- if clientConfig == nil {
- return
- }
-
- // Remove user to disconnect all connections
- err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
- if err != nil {
- logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
- return
- }
-
- // Wait a moment for disconnection to take effect
- time.Sleep(100 * time.Millisecond)
-
- // Re-add user to allow new connections
- err = xrayAPI.AddUser(string(inbound.Protocol), inbound.Tag, clientConfig)
- if err != nil {
- logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
- }
-}
-
func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
db := database.GetDB()
inbound := &model.Inbound{}
diff --git a/web/service/inbound.go b/web/service/inbound.go
index 101c79d9..8a3a4ae2 100644
--- a/web/service/inbound.go
+++ b/web/service/inbound.go
@@ -2032,7 +2032,6 @@ func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.Cl
return nil, err
}
if t != nil && client != nil {
- t.Enable = client.Enable
t.UUID = client.ID
t.SubId = client.SubID
return t, nil
diff --git a/web/service/tgbot.go b/web/service/tgbot.go
index 6a49f1d3..1649f2ed 100644
--- a/web/service/tgbot.go
+++ b/web/service/tgbot.go
@@ -1926,6 +1926,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
} else {
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
+ t.sendClientIndividualLinks(chatId, client_Email)
+ t.sendClientQRLinks(chatId, client_Email)
}
case "add_client_submit_enable":
client_Enable = true
@@ -1936,6 +1938,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
} else {
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
+ t.sendClientIndividualLinks(chatId, client_Email)
+ t.sendClientQRLinks(chatId, client_Email)
}
case "reset_all_traffics_cancel":
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
@@ -3302,6 +3306,27 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
}
}
+// getCommonClientButtons returns the shared inline keyboard rows for client configuration
+func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
+ return [][]telego.InlineKeyboardButton{
+ tu.InlineKeyboardRow(
+ tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
+ tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
+ ),
+ tu.InlineKeyboardRow(
+ tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
+ tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"),
+ ),
+ tu.InlineKeyboardRow(
+ tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
+ tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
+ ),
+ tu.InlineKeyboardRow(
+ tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
+ ),
+ }
+}
+
// addClient handles the process of adding a new client to an inbound.
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
@@ -3312,91 +3337,40 @@ func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
protocol := inbound.Protocol
+ var protocolRows [][]telego.InlineKeyboardButton
switch protocol {
case model.VMESS, model.VLESS:
- inlineKeyboard := tu.InlineKeyboard(
+ protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
),
- tu.InlineKeyboardRow(
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
- ),
- tu.InlineKeyboardRow(
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"),
- ),
- tu.InlineKeyboardRow(
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
- ),
- tu.InlineKeyboardRow(
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
- ),
- )
- if len(messageID) > 0 {
- t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
- } else {
- t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
}
case model.Trojan:
- inlineKeyboard := tu.InlineKeyboard(
+ protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_tr"),
),
- tu.InlineKeyboardRow(
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
- ),
- tu.InlineKeyboardRow(
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
- tu.InlineKeyboardButton("ip limit").WithCallbackData("add_client_ch_default_ip_limit"),
- ),
- tu.InlineKeyboardRow(
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
- ),
- tu.InlineKeyboardRow(
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
- ),
- )
- if len(messageID) > 0 {
- t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
- } else {
- t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
}
case model.Shadowsocks:
- inlineKeyboard := tu.InlineKeyboard(
+ protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_sh"),
),
- tu.InlineKeyboardRow(
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
- ),
- tu.InlineKeyboardRow(
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
- tu.InlineKeyboardButton("ip limit").WithCallbackData("add_client_ch_default_ip_limit"),
- ),
- tu.InlineKeyboardRow(
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
- ),
- tu.InlineKeyboardRow(
- tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
- ),
- )
-
- if len(messageID) > 0 {
- t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
- } else {
- t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
}
}
+ commonRows := t.getCommonClientButtons()
+ inlineKeyboard := tu.InlineKeyboard(append(protocolRows, commonRows...)...)
+
+ if len(messageID) > 0 {
+ t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
+ } else {
+ t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
+ }
+
}
// searchInbound searches for inbounds by remark and sends the results.
diff --git a/web/service/user.go b/web/service/user.go
index 0a2a3f3e..6fcf17e7 100644
--- a/web/service/user.go
+++ b/web/service/user.go
@@ -95,7 +95,7 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
}
if gotp.NewDefaultTOTP(twoFactorToken).Now() != twoFactorCode {
- return nil, errors.New("invalid 2fa code")
+ return nil, errors.New("invalid 2fa code")
}
}
diff --git a/web/translation/translate.ru_RU.toml b/web/translation/translate.ru_RU.toml
index 8a403a1c..0425db96 100644
--- a/web/translation/translate.ru_RU.toml
+++ b/web/translation/translate.ru_RU.toml
@@ -149,7 +149,7 @@
"geofileUpdateDialogDesc" = "Это обновит файл #filename#."
"geofilesUpdateDialogDesc" = "Это обновит все геофайлы."
"geofilesUpdateAll" = "Обновить все"
-"geofileUpdatePopover" = "Геофайл успешно обновлён"
+"geofileUpdatePopover" = "Геофайлы успешно обновлены"
"dontRefresh" = "Установка в процессе. Не обновляйте страницу"
"logs" = "Журнал"
"config" = "Конфигурация"
diff --git a/x-ui.sh b/x-ui.sh
index e0b5ae65..e26dcce2 100644
--- a/x-ui.sh
+++ b/x-ui.sh
@@ -318,12 +318,12 @@ check_config() {
start >/dev/null 2>&1
else
LOGE "IP certificate setup failed."
- echo -e "${yellow}You can try again via option 18 (SSL Certificate Management).${plain}"
+ echo -e "${yellow}You can try again via option 19 (SSL Certificate Management).${plain}"
start >/dev/null 2>&1
fi
else
echo -e "${yellow}Access URL: http://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
- echo -e "${yellow}For security, please configure SSL certificate using option 18 (SSL Certificate Management)${plain}"
+ echo -e "${yellow}For security, please configure SSL certificate using option 19 (SSL Certificate Management)${plain}"
fi
fi
}
@@ -2013,7 +2013,7 @@ EOF
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
[Definition]
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
-failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*SRC\s*=\s*
+failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnecting OLD IP\s*=\s*\s*\|\|\s*Timestamp\s*=\s*\d+
ignoreregex =
EOF