mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2025-05-11 02:15:48 +02:00
feat: login 2fa
This commit is contained in:
parent
8d8ba150ef
commit
5abd9b75bb
33 changed files with 1063 additions and 122 deletions
1
.idea/vcs.xml
generated
1
.idea/vcs.xml
generated
|
@ -2,6 +2,5 @@
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="" vcs="Git" />
|
<mapping directory="" vcs="Git" />
|
||||||
<mapping directory="$PROJECT_DIR$/docs" vcs="Git" />
|
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
|
@ -3,6 +3,7 @@ package api
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/0xJacky/Nginx-UI/internal/logger"
|
"github.com/0xJacky/Nginx-UI/internal/logger"
|
||||||
|
"github.com/0xJacky/Nginx-UI/model"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -11,6 +12,10 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func CurrentUser(c *gin.Context) *model.Auth {
|
||||||
|
return c.MustGet("user").(*model.Auth)
|
||||||
|
}
|
||||||
|
|
||||||
func ErrHandler(c *gin.Context, err error) {
|
func ErrHandler(c *gin.Context, err error) {
|
||||||
logger.GetLogger().Errorln(err)
|
logger.GetLogger().Errorln(err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
|
|
@ -18,12 +18,17 @@ var mutex = &sync.Mutex{}
|
||||||
type LoginUser struct {
|
type LoginUser struct {
|
||||||
Name string `json:"name" binding:"required,max=255"`
|
Name string `json:"name" binding:"required,max=255"`
|
||||||
Password string `json:"password" binding:"required,max=255"`
|
Password string `json:"password" binding:"required,max=255"`
|
||||||
|
OTP string `json:"otp"`
|
||||||
|
RecoveryCode string `json:"recovery_code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ErrPasswordIncorrect = 4031
|
ErrPasswordIncorrect = 4031
|
||||||
ErrMaxAttempts = 4291
|
ErrMaxAttempts = 4291
|
||||||
ErrUserBanned = 4033
|
ErrUserBanned = 4033
|
||||||
|
Enabled2FA = 199
|
||||||
|
Error2FACode = 4034
|
||||||
|
LoginSuccess = 200
|
||||||
)
|
)
|
||||||
|
|
||||||
type LoginResponse struct {
|
type LoginResponse struct {
|
||||||
|
@ -80,11 +85,32 @@ func Login(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the user enables 2FA
|
||||||
|
if len(u.OTPSecret) > 0 {
|
||||||
|
if json.OTP == "" && json.RecoveryCode == "" {
|
||||||
|
c.JSON(http.StatusOK, LoginResponse{
|
||||||
|
Message: "The user has enabled 2FA",
|
||||||
|
Code: Enabled2FA,
|
||||||
|
})
|
||||||
|
user.BanIP(clientIP)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
|
||||||
|
c.JSON(http.StatusForbidden, LoginResponse{
|
||||||
|
Message: "Invalid 2FA or recovery code",
|
||||||
|
Code: Error2FACode,
|
||||||
|
})
|
||||||
|
user.BanIP(clientIP)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// login success, clear banned record
|
// login success, clear banned record
|
||||||
_, _ = b.Where(b.IP.Eq(clientIP)).Delete()
|
_, _ = b.Where(b.IP.Eq(clientIP)).Delete()
|
||||||
|
|
||||||
logger.Info("[User Login]", u.Name)
|
logger.Info("[User Login]", u.Name)
|
||||||
token, err := user.GenerateJWT(u.Name)
|
token, err := user.GenerateJWT(u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, LoginResponse{
|
c.JSON(http.StatusInternalServerError, LoginResponse{
|
||||||
Message: err.Error(),
|
Message: err.Error(),
|
||||||
|
@ -93,6 +119,7 @@ func Login(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, LoginResponse{
|
c.JSON(http.StatusOK, LoginResponse{
|
||||||
|
Code: LoginSuccess,
|
||||||
Message: "ok",
|
Message: "ok",
|
||||||
Token: token,
|
Token: token,
|
||||||
})
|
})
|
||||||
|
@ -101,13 +128,7 @@ func Login(c *gin.Context) {
|
||||||
func Logout(c *gin.Context) {
|
func Logout(c *gin.Context) {
|
||||||
token := c.GetHeader("Authorization")
|
token := c.GetHeader("Authorization")
|
||||||
if token != "" {
|
if token != "" {
|
||||||
err := user.DeleteToken(token)
|
user.DeleteToken(token)
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusNoContent, nil)
|
c.JSON(http.StatusNoContent, nil)
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,7 +65,7 @@ func CasdoorCallback(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userToken, err := user.GenerateJWT(u.Name)
|
userToken, err := user.GenerateJWT(u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
api.ErrHandler(c, err)
|
api.ErrHandler(c, err)
|
||||||
return
|
return
|
||||||
|
|
163
api/user/otp.go
Normal file
163
api/user/otp.go
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"github.com/0xJacky/Nginx-UI/api"
|
||||||
|
"github.com/0xJacky/Nginx-UI/internal/crypto"
|
||||||
|
"github.com/0xJacky/Nginx-UI/query"
|
||||||
|
"github.com/0xJacky/Nginx-UI/settings"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pquerna/otp"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
"image/jpeg"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateTOTP(c *gin.Context) {
|
||||||
|
user := api.CurrentUser(c)
|
||||||
|
|
||||||
|
issuer := fmt.Sprintf("Nginx UI %s", settings.ServerSettings.Name)
|
||||||
|
issuer = strings.TrimSpace(issuer)
|
||||||
|
|
||||||
|
otpOpts := totp.GenerateOpts{
|
||||||
|
Issuer: issuer,
|
||||||
|
AccountName: user.Name,
|
||||||
|
Period: 30, // seconds
|
||||||
|
Digits: otp.DigitsSix,
|
||||||
|
Algorithm: otp.AlgorithmSHA1,
|
||||||
|
}
|
||||||
|
otpKey, err := totp.Generate(otpOpts)
|
||||||
|
if err != nil {
|
||||||
|
api.ErrHandler(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ciphertext, err := crypto.AesEncrypt([]byte(otpKey.Secret()))
|
||||||
|
if err != nil {
|
||||||
|
api.ErrHandler(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
qrCode, err := otpKey.Image(512, 512)
|
||||||
|
if err != nil {
|
||||||
|
api.ErrHandler(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode the image to a buffer
|
||||||
|
var buf []byte
|
||||||
|
buffer := bytes.NewBuffer(buf)
|
||||||
|
err = jpeg.Encode(buffer, qrCode, nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error encoding image:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the buffer to a base64 string
|
||||||
|
base64Str := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes())
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"secret": base64.StdEncoding.EncodeToString(ciphertext),
|
||||||
|
"qr_code": base64Str,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnrollTOTP(c *gin.Context) {
|
||||||
|
user := api.CurrentUser(c)
|
||||||
|
if len(user.OTPSecret) > 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"message": "User already enrolled",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var json struct {
|
||||||
|
Secret string `json:"secret" binding:"required"`
|
||||||
|
Passcode string `json:"passcode" binding:"required"`
|
||||||
|
}
|
||||||
|
if !api.BindAndValid(c, &json) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
secret, err := base64.StdEncoding.DecodeString(json.Secret)
|
||||||
|
if err != nil {
|
||||||
|
api.ErrHandler(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := crypto.AesDecrypt(secret)
|
||||||
|
if err != nil {
|
||||||
|
api.ErrHandler(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := totp.Validate(json.Passcode, string(decrypted)); !ok {
|
||||||
|
c.JSON(http.StatusNotAcceptable, gin.H{
|
||||||
|
"message": "Invalid passcode",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext, err := crypto.AesEncrypt(decrypted)
|
||||||
|
if err != nil {
|
||||||
|
api.ErrHandler(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u := query.Auth
|
||||||
|
_, err = u.Where(u.ID.Eq(user.ID)).Update(u.OTPSecret, ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
api.ErrHandler(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recoveryCode := sha1.Sum(ciphertext)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "ok",
|
||||||
|
"recovery_code": hex.EncodeToString(recoveryCode[:]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResetOTP(c *gin.Context) {
|
||||||
|
var json struct {
|
||||||
|
RecoveryCode string `json:"recovery_code"`
|
||||||
|
}
|
||||||
|
if !api.BindAndValid(c, &json) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recoverCode, err := hex.DecodeString(json.RecoveryCode)
|
||||||
|
if err != nil {
|
||||||
|
api.ErrHandler(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := api.CurrentUser(c)
|
||||||
|
k := sha1.Sum(user.OTPSecret)
|
||||||
|
if !bytes.Equal(k[:], recoverCode) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"message": "Invalid recovery code",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u := query.Auth
|
||||||
|
_, err = u.Where(u.ID.Eq(user.ID)).UpdateSimple(u.OTPSecret.Null())
|
||||||
|
if err != nil {
|
||||||
|
api.ErrHandler(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func OTPStatus(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": len(api.CurrentUser(c).OTPSecret) > 0,
|
||||||
|
})
|
||||||
|
}
|
|
@ -17,3 +17,10 @@ func InitManageUserRouter(r *gin.RouterGroup) {
|
||||||
r.POST("user/:id", EditUser)
|
r.POST("user/:id", EditUser)
|
||||||
r.DELETE("user/:id", DeleteUser)
|
r.DELETE("user/:id", DeleteUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func InitUserRouter(r *gin.RouterGroup) {
|
||||||
|
r.GET("/otp_status", OTPStatus)
|
||||||
|
r.GET("/otp_secret", GenerateTOTP)
|
||||||
|
r.POST("/otp_enroll", EnrollTOTP)
|
||||||
|
r.POST("/otp_reset", ResetOTP)
|
||||||
|
}
|
||||||
|
|
|
@ -51,3 +51,11 @@ Interval = 1440
|
||||||
Node = http://10.0.0.1:9000?name=node1&node_secret=my-node-secret&enabled=true
|
Node = http://10.0.0.1:9000?name=node1&node_secret=my-node-secret&enabled=true
|
||||||
Node = http://10.0.0.2:9000?name=node2&node_secret=my-node-secret&enabled=true
|
Node = http://10.0.0.2:9000?name=node2&node_secret=my-node-secret&enabled=true
|
||||||
Node = http://10.0.0.3?name=node3&node_secret=my-node-secret&enabled=true
|
Node = http://10.0.0.3?name=node3&node_secret=my-node-secret&enabled=true
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
IPWhiteList =
|
||||||
|
BanThresholdMinutes = 10
|
||||||
|
MaxAttempts = 10
|
||||||
|
|
||||||
|
[crypto]
|
||||||
|
Secret = secret2
|
||||||
|
|
2
app/components.d.ts
vendored
2
app/components.d.ts
vendored
|
@ -78,6 +78,8 @@ declare module 'vue' {
|
||||||
NginxControlNginxControl: typeof import('./src/components/NginxControl/NginxControl.vue')['default']
|
NginxControlNginxControl: typeof import('./src/components/NginxControl/NginxControl.vue')['default']
|
||||||
NodeSelectorNodeSelector: typeof import('./src/components/NodeSelector/NodeSelector.vue')['default']
|
NodeSelectorNodeSelector: typeof import('./src/components/NodeSelector/NodeSelector.vue')['default']
|
||||||
NotificationNotification: typeof import('./src/components/Notification/Notification.vue')['default']
|
NotificationNotification: typeof import('./src/components/Notification/Notification.vue')['default']
|
||||||
|
OTPInput: typeof import('./src/components/OTPInput.vue')['default']
|
||||||
|
OTPInputOTPInput: typeof import('./src/components/OTPInput/OTPInput.vue')['default']
|
||||||
PageHeaderPageHeader: typeof import('./src/components/PageHeader/PageHeader.vue')['default']
|
PageHeaderPageHeader: typeof import('./src/components/PageHeader/PageHeader.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|
|
@ -38,6 +38,7 @@
|
||||||
"vue3-ace-editor": "2.2.4",
|
"vue3-ace-editor": "2.2.4",
|
||||||
"vue3-apexcharts": "1.4.4",
|
"vue3-apexcharts": "1.4.4",
|
||||||
"vue3-gettext": "3.0.0-beta.4",
|
"vue3-gettext": "3.0.0-beta.4",
|
||||||
|
"vue3-otp-input": "^0.5.21",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
12
app/pnpm-lock.yaml
generated
12
app/pnpm-lock.yaml
generated
|
@ -89,6 +89,9 @@ importers:
|
||||||
vue3-gettext:
|
vue3-gettext:
|
||||||
specifier: 3.0.0-beta.4
|
specifier: 3.0.0-beta.4
|
||||||
version: 3.0.0-beta.4(@vue/compiler-sfc@3.4.33)(typescript@5.3.3)(vue@3.4.33(typescript@5.3.3))
|
version: 3.0.0-beta.4(@vue/compiler-sfc@3.4.33)(typescript@5.3.3)(vue@3.4.33(typescript@5.3.3))
|
||||||
|
vue3-otp-input:
|
||||||
|
specifier: ^0.5.21
|
||||||
|
version: 0.5.21(vue@3.4.33(typescript@5.3.3))
|
||||||
vuedraggable:
|
vuedraggable:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0(vue@3.4.33(typescript@5.3.3))
|
version: 4.1.0(vue@3.4.33(typescript@5.3.3))
|
||||||
|
@ -2969,6 +2972,11 @@ packages:
|
||||||
'@vue/compiler-sfc': '>=3.0.0'
|
'@vue/compiler-sfc': '>=3.0.0'
|
||||||
vue: '>=3.0.0'
|
vue: '>=3.0.0'
|
||||||
|
|
||||||
|
vue3-otp-input@0.5.21:
|
||||||
|
resolution: {integrity: sha512-dRxmGJqXlU+U5dCijNCyY7ird49+pyfeQspSTqvIp2Xs+VByIluNlTOjgHrftzSdeVZggtx+Ojb8uKiRLaob4Q==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.0.*
|
||||||
|
|
||||||
vue@3.4.33:
|
vue@3.4.33:
|
||||||
resolution: {integrity: sha512-VdMCWQOummbhctl4QFMcW6eNtXHsFyDlX60O/tsSQuCcuDOnJ1qPOhhVla65Niece7xq/P2zyZReIO5mP+LGTQ==}
|
resolution: {integrity: sha512-VdMCWQOummbhctl4QFMcW6eNtXHsFyDlX60O/tsSQuCcuDOnJ1qPOhhVla65Niece7xq/P2zyZReIO5mP+LGTQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -6175,6 +6183,10 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
|
vue3-otp-input@0.5.21(vue@3.4.33(typescript@5.3.3)):
|
||||||
|
dependencies:
|
||||||
|
vue: 3.4.33(typescript@5.3.3)
|
||||||
|
|
||||||
vue@3.4.33(typescript@5.3.3):
|
vue@3.4.33(typescript@5.3.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/compiler-dom': 3.4.33
|
'@vue/compiler-dom': 3.4.33
|
||||||
|
|
|
@ -6,15 +6,16 @@ const { login, logout } = useUserStore()
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
message: string
|
message: string
|
||||||
token: string
|
token: string
|
||||||
|
code: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = {
|
const auth = {
|
||||||
async login(name: string, password: string) {
|
async login(name: string, password: string, otp: string, recoveryCode: string): Promise<AuthResponse> {
|
||||||
return http.post('/login', {
|
return http.post('/login', {
|
||||||
name,
|
name,
|
||||||
password,
|
password,
|
||||||
}).then((r: AuthResponse) => {
|
otp,
|
||||||
login(r.token)
|
recovery_code: recoveryCode,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async casdoor_login(code?: string, state?: string) {
|
async casdoor_login(code?: string, state?: string) {
|
||||||
|
|
23
app/src/api/otp.ts
Normal file
23
app/src/api/otp.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import http from '@/lib/http'
|
||||||
|
|
||||||
|
export interface OTPGenerateSecretResponse {
|
||||||
|
secret: string
|
||||||
|
qr_code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const otp = {
|
||||||
|
status(): Promise<{ status: boolean }> {
|
||||||
|
return http.get('/otp_status')
|
||||||
|
},
|
||||||
|
generate_secret(): Promise<OTPGenerateSecretResponse> {
|
||||||
|
return http.get('/otp_secret')
|
||||||
|
},
|
||||||
|
enroll_otp(secret: string, passcode: string): Promise<{ recovery_code: string }> {
|
||||||
|
return http.post('/otp_enroll', { secret, passcode })
|
||||||
|
},
|
||||||
|
reset(recovery_code: string) {
|
||||||
|
return http.post('/otp_reset', { recovery_code })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default otp
|
72
app/src/components/OTPInput/OTPInput.vue
Normal file
72
app/src/components/OTPInput/OTPInput.vue
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import VOtpInput from 'vue3-otp-input'
|
||||||
|
|
||||||
|
const emit = defineEmits(['onComplete'])
|
||||||
|
|
||||||
|
const data = defineModel<string>()
|
||||||
|
const refOtp = ref()
|
||||||
|
|
||||||
|
function onComplete(value: string) {
|
||||||
|
emit('onComplete', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearInput() {
|
||||||
|
refOtp.value?.clearInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
clearInput,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VOtpInput
|
||||||
|
ref="refOtp"
|
||||||
|
v-model:value="data"
|
||||||
|
input-classes="otp-input"
|
||||||
|
:num-inputs="6"
|
||||||
|
input-type="number"
|
||||||
|
should-auto-focus
|
||||||
|
should-focus-order
|
||||||
|
@on-complete="onComplete"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less">
|
||||||
|
.dark {
|
||||||
|
.otp-input {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border: 2px solid #1677ff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
:deep(.otp-input) {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 5px;
|
||||||
|
margin: 0 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border: 2px solid #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-inner-spin-button,
|
||||||
|
&::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,13 +1,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LockOutlined, UserOutlined } from '@ant-design/icons-vue'
|
import { LockOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||||
import { Form, message } from 'ant-design-vue'
|
import { Form, message } from 'ant-design-vue'
|
||||||
|
import OTPInput from '@/components/OTPInput/OTPInput.vue'
|
||||||
|
|
||||||
import { useUserStore } from '@/pinia'
|
import { useUserStore } from '@/pinia'
|
||||||
import auth from '@/api/auth'
|
import auth from '@/api/auth'
|
||||||
import install from '@/api/install'
|
import install from '@/api/install'
|
||||||
import SetLanguage from '@/components/SetLanguage/SetLanguage.vue'
|
import SetLanguage from '@/components/SetLanguage/SetLanguage.vue'
|
||||||
import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue'
|
import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue'
|
||||||
import gettext from '@/gettext'
|
import gettext, { $gettext } from '@/gettext'
|
||||||
|
|
||||||
const thisYear = new Date().getFullYear()
|
const thisYear = new Date().getFullYear()
|
||||||
|
|
||||||
|
@ -20,6 +21,11 @@ install.get_lock().then(async (r: { lock: boolean }) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const enabled2FA = ref(false)
|
||||||
|
const refOTP = ref()
|
||||||
|
const passcode = ref('')
|
||||||
|
const useRecoveryCode = ref(false)
|
||||||
|
const recoveryCode = ref('')
|
||||||
|
|
||||||
const modelRef = reactive({
|
const modelRef = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
|
@ -42,17 +48,25 @@ const rulesRef = reactive({
|
||||||
})
|
})
|
||||||
|
|
||||||
const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef)
|
const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef)
|
||||||
|
const { login } = useUserStore()
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
validate().then(async () => {
|
validate().then(async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
await auth.login(modelRef.username, modelRef.password).then(async () => {
|
await auth.login(modelRef.username, modelRef.password, passcode.value, recoveryCode.value).then(async r => {
|
||||||
message.success($gettext('Login successful'), 1)
|
|
||||||
|
|
||||||
const next = (route.query?.next || '').toString() || '/'
|
const next = (route.query?.next || '').toString() || '/'
|
||||||
|
|
||||||
|
switch (r.code) {
|
||||||
|
case 200:
|
||||||
|
message.success($gettext('Login successful'), 1)
|
||||||
|
login(r.token)
|
||||||
await router.push(next)
|
await router.push(next)
|
||||||
|
break
|
||||||
|
case 199:
|
||||||
|
enabled2FA.value = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
switch (e.code) {
|
switch (e.code) {
|
||||||
case 4031:
|
case 4031:
|
||||||
|
@ -64,6 +78,10 @@ const onSubmit = () => {
|
||||||
case 4033:
|
case 4033:
|
||||||
message.error($gettext('User is banned'))
|
message.error($gettext('User is banned'))
|
||||||
break
|
break
|
||||||
|
case 4034:
|
||||||
|
refOTP.value?.clearInput()
|
||||||
|
message.error($gettext('Invalid 2FA or recovery code'))
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
message.error($gettext(e.message ?? 'Server error'))
|
message.error($gettext(e.message ?? 'Server error'))
|
||||||
break
|
break
|
||||||
|
@ -117,6 +135,10 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clickUseRecoveryCode() {
|
||||||
|
passcode.value = ''
|
||||||
|
useRecoveryCode.value = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -128,6 +150,7 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
|
||||||
<h1>Nginx UI</h1>
|
<h1>Nginx UI</h1>
|
||||||
</div>
|
</div>
|
||||||
<AForm id="components-form-demo-normal-login">
|
<AForm id="components-form-demo-normal-login">
|
||||||
|
<template v-if="!enabled2FA">
|
||||||
<AFormItem v-bind="validateInfos.username">
|
<AFormItem v-bind="validateInfos.username">
|
||||||
<AInput
|
<AInput
|
||||||
v-model:value="modelRef.username"
|
v-model:value="modelRef.username"
|
||||||
|
@ -148,7 +171,43 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
|
||||||
</template>
|
</template>
|
||||||
</AInputPassword>
|
</AInputPassword>
|
||||||
</AFormItem>
|
</AFormItem>
|
||||||
<AFormItem>
|
</template>
|
||||||
|
<div v-else>
|
||||||
|
<div v-if="!useRecoveryCode">
|
||||||
|
<p>{{ $gettext('Please enter the 2FA code:') }}</p>
|
||||||
|
<OTPInput
|
||||||
|
ref="refOTP"
|
||||||
|
v-model="passcode"
|
||||||
|
class="justify-center mb-6"
|
||||||
|
@on-complete="onSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<a @click="clickUseRecoveryCode">{{ $gettext('Use recovery code') }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<p>{{ $gettext('Input the recovery code:') }}</p>
|
||||||
|
<AInputGroup compact>
|
||||||
|
<AInput
|
||||||
|
v-model:value="recoveryCode"
|
||||||
|
style="width: calc(100% - 92px)"
|
||||||
|
/>
|
||||||
|
<AButton
|
||||||
|
type="primary"
|
||||||
|
@click="onSubmit"
|
||||||
|
>
|
||||||
|
{{ $gettext('Recovery') }}
|
||||||
|
</AButton>
|
||||||
|
</AInputGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AFormItem v-if="!enabled2FA">
|
||||||
<AButton
|
<AButton
|
||||||
type="primary"
|
type="primary"
|
||||||
block
|
block
|
||||||
|
|
|
@ -7,6 +7,7 @@ import type { BannedIP } from '@/api/settings'
|
||||||
import setting from '@/api/settings'
|
import setting from '@/api/settings'
|
||||||
import type { customRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
|
import type { customRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
|
||||||
import type { Settings } from '@/views/preference/typedef'
|
import type { Settings } from '@/views/preference/typedef'
|
||||||
|
import TOTP from '@/views/preference/components/TOTP.vue'
|
||||||
|
|
||||||
const data: Settings = inject('data') as Settings
|
const data: Settings = inject('data') as Settings
|
||||||
|
|
||||||
|
@ -105,6 +106,8 @@ function removeBannedIP(ip: string) {
|
||||||
</template>
|
</template>
|
||||||
</ATable>
|
</ATable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TOTP />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
176
app/src/views/preference/components/TOTP.vue
Normal file
176
app/src/views/preference/components/TOTP.vue
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import { CheckCircleOutlined } from '@ant-design/icons-vue'
|
||||||
|
import otp from '@/api/otp'
|
||||||
|
import OTPInput from '@/components/OTPInput/OTPInput.vue'
|
||||||
|
|
||||||
|
const status = ref(false)
|
||||||
|
const enrolling = ref(false)
|
||||||
|
const resetting = ref(false)
|
||||||
|
const qrCode = ref('')
|
||||||
|
const secret = ref('')
|
||||||
|
const passcode = ref('')
|
||||||
|
const interval = ref()
|
||||||
|
const refOtp = ref()
|
||||||
|
const recoveryCode = ref('')
|
||||||
|
const inputRecoveryCode = ref('')
|
||||||
|
|
||||||
|
function clickEnable2FA() {
|
||||||
|
enrolling.value = true
|
||||||
|
generateSecret()
|
||||||
|
interval.value = setInterval(() => {
|
||||||
|
if (enrolling.value)
|
||||||
|
generateSecret()
|
||||||
|
else
|
||||||
|
clearGenerateSecretInterval()
|
||||||
|
}, 30 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearGenerateSecretInterval() {
|
||||||
|
if (interval.value) {
|
||||||
|
clearInterval(interval.value)
|
||||||
|
interval.value = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSecret() {
|
||||||
|
otp.generate_secret().then(r => {
|
||||||
|
secret.value = r.secret
|
||||||
|
qrCode.value = r.qr_code
|
||||||
|
refOtp.value?.clearInput()
|
||||||
|
}).catch((e: { message?: string }) => {
|
||||||
|
message.error(e.message ?? $gettext('Server error'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function enroll(code: string) {
|
||||||
|
otp.enroll_otp(secret.value, code).then(r => {
|
||||||
|
enrolling.value = false
|
||||||
|
recoveryCode.value = r.recovery_code
|
||||||
|
clearGenerateSecretInterval()
|
||||||
|
get2FAStatus()
|
||||||
|
message.success($gettext('Enable 2FA successfully'))
|
||||||
|
}).catch((e: { message?: string }) => {
|
||||||
|
refOtp.value?.clearInput()
|
||||||
|
message.error(e.message ?? $gettext('Server error'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function get2FAStatus() {
|
||||||
|
otp.status().then(r => {
|
||||||
|
status.value = r.status
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get2FAStatus()
|
||||||
|
|
||||||
|
onUnmounted(clearGenerateSecretInterval)
|
||||||
|
|
||||||
|
function clickReset2FA() {
|
||||||
|
resetting.value = true
|
||||||
|
inputRecoveryCode.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset2FA() {
|
||||||
|
otp.reset(inputRecoveryCode.value).then(() => {
|
||||||
|
resetting.value = false
|
||||||
|
recoveryCode.value = ''
|
||||||
|
get2FAStatus()
|
||||||
|
clickEnable2FA()
|
||||||
|
}).catch((e: { message?: string }) => {
|
||||||
|
message.error($gettext(e.message ?? 'Server error'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h3>{{ $gettext('2FA Settings') }}</h3>
|
||||||
|
<p>{{ $gettext('TOTP is a two-factor authentication method that uses a time-based one-time password algorithm.') }}</p>
|
||||||
|
<p>{{ $gettext('To enable it, you need to install the Google or Microsoft Authenticator app on your mobile phone.') }}</p>
|
||||||
|
<p>{{ $gettext('Scan the QR code with your mobile phone to add the account to the app.') }}</p>
|
||||||
|
<p v-if="!status">
|
||||||
|
{{ $gettext('Current account is not enabled 2FA.') }}
|
||||||
|
</p>
|
||||||
|
<div v-else>
|
||||||
|
<p><CheckCircleOutlined class="mr-2 text-green-600" />{{ $gettext('Current account is enabled 2FA.') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AAlert
|
||||||
|
v-if="recoveryCode"
|
||||||
|
:message="$gettext('Recovery Code')"
|
||||||
|
class="mb-4"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<div>
|
||||||
|
<p>{{ $gettext('If you lose your mobile phone, you can use the recovery code to reset your 2FA.') }}</p>
|
||||||
|
<p>{{ $gettext('The recovery code is only displayed once, please save it in a safe place.') }}</p>
|
||||||
|
<p>{{ $gettext('Recovery Code:') }}</p>
|
||||||
|
<span class="ml-2">{{ recoveryCode }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</AAlert>
|
||||||
|
|
||||||
|
<AButton
|
||||||
|
v-if="!status && !enrolling"
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
@click="clickEnable2FA"
|
||||||
|
>
|
||||||
|
{{ $gettext('Enable 2FA') }}
|
||||||
|
</AButton>
|
||||||
|
<AButton
|
||||||
|
v-if="status && !resetting"
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
@click="clickReset2FA"
|
||||||
|
>
|
||||||
|
{{ $gettext('Reset 2FA') }}
|
||||||
|
</AButton>
|
||||||
|
|
||||||
|
<template v-if="enrolling">
|
||||||
|
<div class="w-64 h-64 mt-4 mb-2">
|
||||||
|
<img
|
||||||
|
v-if="qrCode"
|
||||||
|
class="w-full"
|
||||||
|
:src="qrCode"
|
||||||
|
alt="qr code"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p>{{ $gettext('Input the code from the app:') }}</p>
|
||||||
|
<OTPInput
|
||||||
|
ref="refOtp"
|
||||||
|
v-model="passcode"
|
||||||
|
@on-complete="enroll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="resetting"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<p>{{ $gettext('Input the recovery code:') }}</p>
|
||||||
|
<AInputGroup compact>
|
||||||
|
<AInput
|
||||||
|
v-model:value="inputRecoveryCode"
|
||||||
|
style="width: calc(100% - 92px)"
|
||||||
|
/>
|
||||||
|
<AButton
|
||||||
|
type="primary"
|
||||||
|
@click="reset2FA"
|
||||||
|
>
|
||||||
|
{{ $gettext('Recovery') }}
|
||||||
|
</AButton>
|
||||||
|
</AInputGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
|
||||||
|
</style>
|
29
go.mod
29
go.mod
|
@ -8,6 +8,7 @@ require (
|
||||||
github.com/caarlos0/env/v11 v11.1.0
|
github.com/caarlos0/env/v11 v11.1.0
|
||||||
github.com/casdoor/casdoor-go-sdk v0.47.0
|
github.com/casdoor/casdoor-go-sdk v0.47.0
|
||||||
github.com/creack/pty v1.1.21
|
github.com/creack/pty v1.1.21
|
||||||
|
github.com/dgraph-io/ristretto v0.1.1
|
||||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||||
github.com/dustin/go-humanize v1.0.1
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/fatih/color v1.17.0
|
github.com/fatih/color v1.17.0
|
||||||
|
@ -44,7 +45,7 @@ require (
|
||||||
|
|
||||||
require (
|
require (
|
||||||
aead.dev/minisign v0.3.0 // indirect
|
aead.dev/minisign v0.3.0 // indirect
|
||||||
cloud.google.com/go/auth v0.7.1 // indirect
|
cloud.google.com/go/auth v0.7.2 // indirect
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.5.0 // indirect
|
cloud.google.com/go/compute/metadata v0.5.0 // indirect
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
@ -73,7 +74,7 @@ require (
|
||||||
github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 // indirect
|
github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 // indirect
|
||||||
github.com/StackExchange/wmi v1.2.1 // indirect
|
github.com/StackExchange/wmi v1.2.1 // indirect
|
||||||
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
|
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.62.793 // indirect
|
github.com/aliyun/alibaba-cloud-sdk-go v1.62.795 // indirect
|
||||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
|
github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
|
||||||
|
@ -97,6 +98,8 @@ require (
|
||||||
github.com/bytedance/sonic v1.11.9 // indirect
|
github.com/bytedance/sonic v1.11.9 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
|
github.com/cespare/xxhash v1.1.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/civo/civogo v0.3.73 // indirect
|
github.com/civo/civogo v0.3.73 // indirect
|
||||||
github.com/cloudflare/cloudflare-go v0.100.0 // indirect
|
github.com/cloudflare/cloudflare-go v0.100.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
|
@ -125,20 +128,21 @@ require (
|
||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.3 // indirect
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
github.com/gofrs/flock v0.12.0 // indirect
|
github.com/gofrs/flock v0.12.1 // indirect
|
||||||
github.com/gofrs/uuid v4.4.0+incompatible // indirect
|
github.com/gofrs/uuid v4.4.0+incompatible // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
|
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||||
|
github.com/golang/glog v1.2.2 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024 // indirect
|
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/google/gofuzz v1.2.0 // indirect
|
github.com/google/gofuzz v1.2.0 // indirect
|
||||||
github.com/google/s2a-go v0.1.7 // indirect
|
github.com/google/s2a-go v0.1.8 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.12.5 // indirect
|
github.com/googleapis/gax-go/v2 v2.13.0 // indirect
|
||||||
github.com/gophercloud/gophercloud v1.13.0 // indirect
|
github.com/gophercloud/gophercloud v1.13.0 // indirect
|
||||||
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect
|
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
|
@ -228,8 +232,8 @@ require (
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/tdewolff/minify/v2 v2.20.37 // indirect
|
github.com/tdewolff/minify/v2 v2.20.37 // indirect
|
||||||
github.com/tdewolff/parse/v2 v2.7.15 // indirect
|
github.com/tdewolff/parse/v2 v2.7.15 // indirect
|
||||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.968 // indirect
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.969 // indirect
|
||||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.968 // indirect
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.969 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
||||||
github.com/tklauser/numcpus v0.8.0 // indirect
|
github.com/tklauser/numcpus v0.8.0 // indirect
|
||||||
github.com/transip/gotransip/v6 v6.25.0 // indirect
|
github.com/transip/gotransip/v6 v6.25.0 // indirect
|
||||||
|
@ -242,7 +246,7 @@ require (
|
||||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
github.com/vultr/govultr/v2 v2.17.2 // indirect
|
github.com/vultr/govultr/v2 v2.17.2 // indirect
|
||||||
github.com/yandex-cloud/go-genproto v0.0.0-20240715115219-0c1e192fbf5c // indirect
|
github.com/yandex-cloud/go-genproto v0.0.0-20240722173647-40d4f9e8b9fa // indirect
|
||||||
github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169 // indirect
|
github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169 // indirect
|
||||||
github.com/yosssi/ace v0.0.5 // indirect
|
github.com/yosssi/ace v0.0.5 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
@ -264,10 +268,10 @@ require (
|
||||||
golang.org/x/text v0.16.0 // indirect
|
golang.org/x/text v0.16.0 // indirect
|
||||||
golang.org/x/time v0.5.0 // indirect
|
golang.org/x/time v0.5.0 // indirect
|
||||||
golang.org/x/tools v0.23.0 // indirect
|
golang.org/x/tools v0.23.0 // indirect
|
||||||
google.golang.org/api v0.188.0 // indirect
|
google.golang.org/api v0.189.0 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d // indirect
|
google.golang.org/genproto v0.0.0-20240722135656-d784300faade // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade // indirect
|
||||||
google.golang.org/grpc v1.65.0 // indirect
|
google.golang.org/grpc v1.65.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.2 // indirect
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
gopkg.in/fsnotify.v1 v1.4.7 // indirect
|
gopkg.in/fsnotify.v1 v1.4.7 // indirect
|
||||||
|
@ -278,6 +282,7 @@ require (
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gorm.io/datatypes v1.2.1 // indirect
|
gorm.io/datatypes v1.2.1 // indirect
|
||||||
gorm.io/driver/mysql v1.5.7 // indirect
|
gorm.io/driver/mysql v1.5.7 // indirect
|
||||||
|
gorm.io/driver/postgres v1.5.6 // indirect
|
||||||
gorm.io/hints v1.1.2 // indirect
|
gorm.io/hints v1.1.2 // indirect
|
||||||
k8s.io/api v0.30.3 // indirect
|
k8s.io/api v0.30.3 // indirect
|
||||||
k8s.io/apimachinery v0.30.3 // indirect
|
k8s.io/apimachinery v0.30.3 // indirect
|
||||||
|
|
52
go.sum
52
go.sum
|
@ -101,8 +101,8 @@ cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVo
|
||||||
cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo=
|
cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo=
|
||||||
cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0=
|
cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0=
|
||||||
cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E=
|
cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E=
|
||||||
cloud.google.com/go/auth v0.7.1 h1:Iv1bbpzJ2OIg16m94XI9/tlzZZl3cdeR3nGVGj78N7s=
|
cloud.google.com/go/auth v0.7.2 h1:uiha352VrCDMXg+yoBtaD0tUF4Kv9vrtrWPYXwutnDE=
|
||||||
cloud.google.com/go/auth v0.7.1/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs=
|
cloud.google.com/go/auth v0.7.2/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=
|
cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=
|
cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=
|
||||||
cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0=
|
cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0=
|
||||||
|
@ -672,6 +672,7 @@ github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc=
|
||||||
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
|
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
|
||||||
github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk=
|
github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk=
|
||||||
github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM=
|
github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM=
|
||||||
|
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||||
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 h1:xPMsUicZ3iosVPSIP7bW5EcGUzjiiMl1OYTe14y/R24=
|
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 h1:xPMsUicZ3iosVPSIP7bW5EcGUzjiiMl1OYTe14y/R24=
|
||||||
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87/go.mod h1:iGLljf5n9GjT6kc0HBvyI1nOKnGQbNB66VzSNbK5iks=
|
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87/go.mod h1:iGLljf5n9GjT6kc0HBvyI1nOKnGQbNB66VzSNbK5iks=
|
||||||
|
@ -694,8 +695,10 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
|
||||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.62.793 h1:7FmdfF5fZMxM8Y0YtwrnMLkwud+egvoB5X5xczqISNQ=
|
github.com/aliyun/alibaba-cloud-sdk-go v1.62.794 h1:M6YtlJdCobRVlJaILK4Eia5aMtDSpeQtxFRl4hSi+DU=
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.62.793/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
|
github.com/aliyun/alibaba-cloud-sdk-go v1.62.794/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
|
||||||
|
github.com/aliyun/alibaba-cloud-sdk-go v1.62.795 h1:DjIaInK6Ru+fPnOX0Ef4ux5tkp/dCPI3pAZEijEvlvo=
|
||||||
|
github.com/aliyun/alibaba-cloud-sdk-go v1.62.795/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
|
||||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||||
|
@ -772,9 +775,12 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
|
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
|
||||||
|
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
|
@ -821,8 +827,14 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/deepmap/oapi-codegen v1.16.3 h1:GT9G86SbQtT1r8ZB+4Cybi9VGdu1P5ieNvNdEoCSbrA=
|
github.com/deepmap/oapi-codegen v1.16.3 h1:GT9G86SbQtT1r8ZB+4Cybi9VGdu1P5ieNvNdEoCSbrA=
|
||||||
github.com/deepmap/oapi-codegen v1.16.3/go.mod h1:JD6ErqeX0nYnhdciLc61Konj3NBASREMlkHOgHn8WAM=
|
github.com/deepmap/oapi-codegen v1.16.3/go.mod h1:JD6ErqeX0nYnhdciLc61Konj3NBASREMlkHOgHn8WAM=
|
||||||
|
github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de h1:t0UHb5vdojIDUqktM6+xJAfScFBsVpXZmqC9dsgJmeA=
|
||||||
|
github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E=
|
||||||
|
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
|
||||||
|
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
|
||||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||||
|
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
|
||||||
|
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||||
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
|
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
|
||||||
github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
|
github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
|
||||||
github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
|
github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
|
||||||
|
@ -949,6 +961,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
|
||||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||||
github.com/gofrs/flock v0.12.0 h1:xHW8t8GPAiGtqz7KxiSqfOEXwpOaqhpYZrTE2MQBgXY=
|
github.com/gofrs/flock v0.12.0 h1:xHW8t8GPAiGtqz7KxiSqfOEXwpOaqhpYZrTE2MQBgXY=
|
||||||
github.com/gofrs/flock v0.12.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc=
|
github.com/gofrs/flock v0.12.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc=
|
||||||
|
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||||
|
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
|
@ -972,6 +986,8 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGw
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
|
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
|
||||||
github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ=
|
github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ=
|
||||||
|
github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY=
|
||||||
|
github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||||
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
@ -1065,6 +1081,8 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4
|
||||||
github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
|
github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
|
||||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||||
|
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||||
|
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
@ -1092,6 +1110,8 @@ github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38
|
||||||
github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
|
github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
|
||||||
github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA=
|
github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA=
|
||||||
github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E=
|
github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
|
||||||
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
|
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
|
||||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||||
github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM=
|
github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM=
|
||||||
|
@ -1561,6 +1581,7 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k
|
||||||
github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
||||||
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
|
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
|
||||||
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
||||||
|
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
|
||||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||||
github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
|
github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
|
||||||
|
@ -1614,14 +1635,14 @@ github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W
|
||||||
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
|
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
|
||||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
|
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
|
||||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.967 h1:ui73H/2pKk2aDCxaBCLAeMB3JlNgdCkn0nx1x0pqvf0=
|
|
||||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.967/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
|
|
||||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.968 h1:SdgunZB3WU2vNn3H9dJQ1Z2cQK61vN79zCfnHk3Cu3Y=
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.968 h1:SdgunZB3WU2vNn3H9dJQ1Z2cQK61vN79zCfnHk3Cu3Y=
|
||||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.968/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.968/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
|
||||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.967 h1:4w33xHFgyrlFZYoGkPQ3uhld8tqoezpObfmCBrdlFBY=
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.969 h1:rJlV77WbjuJ5uGBi+THOk09Cfp8Kskz9HgExq0enTmY=
|
||||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.967/go.mod h1:T0RlPIT2imBeCxLkWfzoiEVP1r5WwzC6becSq7wvSgU=
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.969/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
|
||||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.968 h1:h7voJALWRkUX6w7obk9CWHppnJwZuQlreQJVDldVRxY=
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.968 h1:h7voJALWRkUX6w7obk9CWHppnJwZuQlreQJVDldVRxY=
|
||||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.968/go.mod h1:3cwvPwyqYaYkzAsR4vbrE6mb3Ju9uY7Pj+wHYSVd3aw=
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.968/go.mod h1:3cwvPwyqYaYkzAsR4vbrE6mb3Ju9uY7Pj+wHYSVd3aw=
|
||||||
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.969 h1:W2DHKBCSLjpHoQjqgAkyUu7lV8deIW+FBZS95iNRf1A=
|
||||||
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.969/go.mod h1:jIxuhjYsAyTTErdwvaX1ay+FHH021fmjdlsbnkaOgfs=
|
||||||
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
||||||
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
||||||
github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
|
github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
|
||||||
|
@ -1664,6 +1685,8 @@ github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmv
|
||||||
github.com/yandex-cloud/go-genproto v0.0.0-20240701142715-6a03f33f8ec8/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
|
github.com/yandex-cloud/go-genproto v0.0.0-20240701142715-6a03f33f8ec8/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
|
||||||
github.com/yandex-cloud/go-genproto v0.0.0-20240715115219-0c1e192fbf5c h1:GzMfpQ/oAP93MOQb5/B+3daDzdcLRRqetZ8radtnJJ4=
|
github.com/yandex-cloud/go-genproto v0.0.0-20240715115219-0c1e192fbf5c h1:GzMfpQ/oAP93MOQb5/B+3daDzdcLRRqetZ8radtnJJ4=
|
||||||
github.com/yandex-cloud/go-genproto v0.0.0-20240715115219-0c1e192fbf5c/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
|
github.com/yandex-cloud/go-genproto v0.0.0-20240715115219-0c1e192fbf5c/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
|
||||||
|
github.com/yandex-cloud/go-genproto v0.0.0-20240722173647-40d4f9e8b9fa h1:MFb4Q81BMqa0vL64v/i3mel9C+XQkVnwgWqWbmqv10U=
|
||||||
|
github.com/yandex-cloud/go-genproto v0.0.0-20240722173647-40d4f9e8b9fa/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
|
||||||
github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169 h1:5LGYQ/0h1uUo3HH8MsG6R40gvSVPj/7r4D1sKVMa370=
|
github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169 h1:5LGYQ/0h1uUo3HH8MsG6R40gvSVPj/7r4D1sKVMa370=
|
||||||
github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169/go.mod h1:kRqpmRyPs8rzXuYEJe57AH546a3VcSjEIzdFa1V66hY=
|
github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169/go.mod h1:kRqpmRyPs8rzXuYEJe57AH546a3VcSjEIzdFa1V66hY=
|
||||||
github.com/yosssi/ace v0.0.5 h1:tUkIP/BLdKqrlrPwcmH0shwEEhTRHoGnc1wFIWmaBUA=
|
github.com/yosssi/ace v0.0.5 h1:tUkIP/BLdKqrlrPwcmH0shwEEhTRHoGnc1wFIWmaBUA=
|
||||||
|
@ -2051,6 +2074,7 @@ golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
@ -2258,6 +2282,8 @@ google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45
|
||||||
google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
|
google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
|
||||||
google.golang.org/api v0.188.0 h1:51y8fJ/b1AaaBRJr4yWm96fPcuxSo0JcegXE3DaHQHw=
|
google.golang.org/api v0.188.0 h1:51y8fJ/b1AaaBRJr4yWm96fPcuxSo0JcegXE3DaHQHw=
|
||||||
google.golang.org/api v0.188.0/go.mod h1:VR0d+2SIiWOYG3r/jdm7adPW9hI2aRv9ETOSCQ9Beag=
|
google.golang.org/api v0.188.0/go.mod h1:VR0d+2SIiWOYG3r/jdm7adPW9hI2aRv9ETOSCQ9Beag=
|
||||||
|
google.golang.org/api v0.189.0 h1:equMo30LypAkdkLMBqfeIqtyAnlyig1JSZArl4XPwdI=
|
||||||
|
google.golang.org/api v0.189.0/go.mod h1:FLWGJKb0hb+pU2j+rJqwbnsF+ym+fQs73rbJ+KAUgy8=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
@ -2400,10 +2426,16 @@ google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOl
|
||||||
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
|
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
|
||||||
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d h1:/hmn0Ku5kWij/kjGsrcJeC1T/MrJi2iNWwgAqrihFwc=
|
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d h1:/hmn0Ku5kWij/kjGsrcJeC1T/MrJi2iNWwgAqrihFwc=
|
||||||
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY=
|
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY=
|
||||||
|
google.golang.org/genproto v0.0.0-20240722135656-d784300faade h1:lKFsS7wpngDgSCeFn7MoLy+wBDQZ1UQIJD4UNM1Qvkg=
|
||||||
|
google.golang.org/genproto v0.0.0-20240722135656-d784300faade/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=
|
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
|
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade h1:WxZOF2yayUHpHSbUE6NMzumUzBxYc3YGwo0YHnbzsJY=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d h1:JU0iKnSg02Gmb5ZdV8nYsKEKsP6o/FGVWTrw4i1DA9A=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d h1:JU0iKnSg02Gmb5ZdV8nYsKEKsP6o/FGVWTrw4i1DA9A=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade h1:oCRSWfwGXQsqlVdErcyTt4A93Y8fo0/9D4b1gnI++qo=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||||
|
@ -2515,8 +2547,8 @@ gorm.io/datatypes v1.2.1/go.mod h1:hYK6OTb/1x+m96PgoZZq10UXJ6RvEBb9kRDQ2yyhzGs=
|
||||||
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||||
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
|
gorm.io/driver/postgres v1.5.6 h1:ydr9xEd5YAM0vxVDY0X139dyzNz10spDiDlC7+ibLeU=
|
||||||
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
|
gorm.io/driver/postgres v1.5.6/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
|
||||||
gorm.io/driver/sqlite v1.5.0/go.mod h1:kDMDfntV9u/vuMmz8APHtHF0b4nyBB7sfCieC6G8k8I=
|
gorm.io/driver/sqlite v1.5.0/go.mod h1:kDMDfntV9u/vuMmz8APHtHF0b4nyBB7sfCieC6G8k8I=
|
||||||
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
||||||
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"github.com/0xJacky/Nginx-UI/internal/cert"
|
"github.com/0xJacky/Nginx-UI/internal/cert"
|
||||||
"github.com/0xJacky/Nginx-UI/internal/logger"
|
"github.com/0xJacky/Nginx-UI/internal/logger"
|
||||||
"github.com/0xJacky/Nginx-UI/internal/logrotate"
|
"github.com/0xJacky/Nginx-UI/internal/logrotate"
|
||||||
|
"github.com/0xJacky/Nginx-UI/query"
|
||||||
"github.com/0xJacky/Nginx-UI/settings"
|
"github.com/0xJacky/Nginx-UI/settings"
|
||||||
"github.com/go-co-op/gocron"
|
"github.com/go-co-op/gocron"
|
||||||
"time"
|
"time"
|
||||||
|
@ -25,6 +26,7 @@ func InitCronJobs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
startLogrotate()
|
startLogrotate()
|
||||||
|
cleanExpiredAuthToken()
|
||||||
|
|
||||||
s.StartAsync()
|
s.StartAsync()
|
||||||
}
|
}
|
||||||
|
@ -43,10 +45,20 @@ func startLogrotate() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
logrotateJob, err = s.Every(settings.LogrotateSettings.Interval).Minute().SingletonMode().Do(logrotate.Exec)
|
logrotateJob, err = s.Every(settings.LogrotateSettings.Interval).Minute().SingletonMode().Do(logrotate.Exec)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("LogRotate Job: %v, Err: %v\n", logrotateJob, err)
|
logger.Fatalf("LogRotate Job: %v, Err: %v\n", logrotateJob, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanExpiredAuthToken() {
|
||||||
|
job, err := s.Every(5).Minute().SingletonMode().Do(func() {
|
||||||
|
logger.Info("clean expired auth tokens")
|
||||||
|
q := query.AuthToken
|
||||||
|
_, _ = q.Where(q.ExpiredAt.Lt(time.Now().Unix())).Delete()
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("CleanExpiredAuthToken Job: %v, Err: %v\n", job, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
59
internal/crypto/aes.go
Normal file
59
internal/crypto/aes.go
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"github.com/0xJacky/Nginx-UI/settings"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AesEncrypt encrypts text and given key with AES.
|
||||||
|
func AesEncrypt(text []byte) ([]byte, error) {
|
||||||
|
if len(text) == 0 {
|
||||||
|
return nil, errors.New("AesEncrypt text is empty")
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(settings.CryptoSettings.GetSecretMd5())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("AesEncrypt invalid key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b := base64.StdEncoding.EncodeToString(text)
|
||||||
|
ciphertext := make([]byte, aes.BlockSize+len(b))
|
||||||
|
iv := ciphertext[:aes.BlockSize]
|
||||||
|
if _, err = io.ReadFull(rand.Reader, iv); err != nil {
|
||||||
|
return nil, fmt.Errorf("AesEncrypt unable to read IV: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfb := cipher.NewCFBEncrypter(block, iv)
|
||||||
|
cfb.XORKeyStream(ciphertext[aes.BlockSize:], []byte(b))
|
||||||
|
|
||||||
|
return ciphertext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AesDecrypt decrypts text and given key with AES.
|
||||||
|
func AesDecrypt(text []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(settings.CryptoSettings.GetSecretMd5())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(text) < aes.BlockSize {
|
||||||
|
return nil, errors.New("AesDecrypt ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
iv := text[:aes.BlockSize]
|
||||||
|
text = text[aes.BlockSize:]
|
||||||
|
cfb := cipher.NewCFBDecrypter(block, iv)
|
||||||
|
cfb.XORKeyStream(text, text)
|
||||||
|
|
||||||
|
data, err := base64.StdEncoding.DecodeString(string(text))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("AesDecrypt invalid decrypted base64 string: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
76
internal/crypto/aes_test.go
Normal file
76
internal/crypto/aes_test.go
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/0xJacky/Nginx-UI/settings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func EncryptDecryptRoundTrip(text string) bool {
|
||||||
|
encrypted, err := AesEncrypt([]byte(text))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := AesDecrypt(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return text == string(decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncryptsNonEmptyStringWithoutError(text string) bool {
|
||||||
|
_, err := AesEncrypt([]byte(text))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecryptsToOriginalTextAfterEncryption(text string) bool {
|
||||||
|
encrypted, _ := AesEncrypt([]byte(text))
|
||||||
|
decrypted, err := AesDecrypt(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return text == string(decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FailsToDecryptWithModifiedCiphertext(text string) bool {
|
||||||
|
encrypted, _ := AesEncrypt([]byte(text))
|
||||||
|
// Modify the ciphertext
|
||||||
|
encrypted[0] ^= 0xff
|
||||||
|
_, err := AesDecrypt(encrypted)
|
||||||
|
return err != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FailsToDecryptShortCiphertext() bool {
|
||||||
|
_, err := AesDecrypt([]byte("short"))
|
||||||
|
return err != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAesEncryptionDecryption(t *testing.T) {
|
||||||
|
settings.CryptoSettings.Secret = "test"
|
||||||
|
assert.True(t, EncryptDecryptRoundTrip("Hello, world!"), "should encrypt and decrypt to the original text")
|
||||||
|
assert.True(t, EncryptsNonEmptyStringWithoutError("Test String"), "should encrypt a non-empty string without error")
|
||||||
|
assert.True(t, DecryptsToOriginalTextAfterEncryption("Another Test String"), "should decrypt to the original text after encryption")
|
||||||
|
assert.True(t, FailsToDecryptWithModifiedCiphertext("Sensitive Data"), "should fail to decrypt with modified ciphertext")
|
||||||
|
assert.True(t, FailsToDecryptShortCiphertext(), "should fail to decrypt short ciphertext")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAesEncrypt_WithEmptyString_ReturnsError(t *testing.T) {
|
||||||
|
settings.CryptoSettings.Secret = "test"
|
||||||
|
_, err := AesEncrypt([]byte(""))
|
||||||
|
require.Error(t, err, "encrypting an empty string should return an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAesDecrypt_WithInvalidBase64_ReturnsError(t *testing.T) {
|
||||||
|
settings.CryptoSettings.Secret = "test"
|
||||||
|
// Assuming the function is modified to handle this case explicitly
|
||||||
|
encrypted, _ := AesEncrypt([]byte("valid text"))
|
||||||
|
// Invalidate the base64 encoding
|
||||||
|
encrypted[len(encrypted)-1] = '!'
|
||||||
|
_, err := AesDecrypt(encrypted)
|
||||||
|
require.Error(t, err, "decrypting an invalid base64 string should return an error")
|
||||||
|
}
|
|
@ -1,20 +1,20 @@
|
||||||
package kernal
|
package kernal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"github.com/0xJacky/Nginx-UI/internal/analytic"
|
"github.com/0xJacky/Nginx-UI/internal/analytic"
|
||||||
"github.com/0xJacky/Nginx-UI/internal/cert"
|
"github.com/0xJacky/Nginx-UI/internal/cert"
|
||||||
"github.com/0xJacky/Nginx-UI/internal/cluster"
|
"github.com/0xJacky/Nginx-UI/internal/cluster"
|
||||||
|
"github.com/0xJacky/Nginx-UI/internal/cron"
|
||||||
"github.com/0xJacky/Nginx-UI/internal/logger"
|
"github.com/0xJacky/Nginx-UI/internal/logger"
|
||||||
"github.com/0xJacky/Nginx-UI/internal/logrotate"
|
|
||||||
"github.com/0xJacky/Nginx-UI/internal/validation"
|
"github.com/0xJacky/Nginx-UI/internal/validation"
|
||||||
"github.com/0xJacky/Nginx-UI/model"
|
"github.com/0xJacky/Nginx-UI/model"
|
||||||
"github.com/0xJacky/Nginx-UI/query"
|
"github.com/0xJacky/Nginx-UI/query"
|
||||||
"github.com/0xJacky/Nginx-UI/settings"
|
"github.com/0xJacky/Nginx-UI/settings"
|
||||||
"github.com/go-co-op/gocron"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"mime"
|
"mime"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Boot() {
|
func Boot() {
|
||||||
|
@ -24,6 +24,7 @@ func Boot() {
|
||||||
InitJsExtensionType,
|
InitJsExtensionType,
|
||||||
InitDatabase,
|
InitDatabase,
|
||||||
InitNodeSecret,
|
InitNodeSecret,
|
||||||
|
InitCryptoSecret,
|
||||||
validation.Init,
|
validation.Init,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,7 +45,7 @@ func InitAfterDatabase() {
|
||||||
syncs := []func(){
|
syncs := []func(){
|
||||||
registerPredefinedUser,
|
registerPredefinedUser,
|
||||||
cert.InitRegister,
|
cert.InitRegister,
|
||||||
InitCronJobs,
|
cron.InitCronJobs,
|
||||||
cluster.RegisterPredefinedNodes,
|
cluster.RegisterPredefinedNodes,
|
||||||
analytic.RetrieveNodesStatus,
|
analytic.RetrieveNodesStatus,
|
||||||
}
|
}
|
||||||
|
@ -83,31 +84,34 @@ func InitNodeSecret() {
|
||||||
|
|
||||||
err := settings.Save()
|
err := settings.Save()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Error save settings")
|
logger.Error("Error save settings", err)
|
||||||
}
|
}
|
||||||
logger.Warn("Generated NodeSecret: ", settings.ServerSettings.NodeSecret)
|
logger.Warn("Generated NodeSecret: ", settings.ServerSettings.NodeSecret)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func InitCryptoSecret() {
|
||||||
|
if "" == settings.CryptoSettings.Secret {
|
||||||
|
logger.Warn("Secret is empty, generating...")
|
||||||
|
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
logger.Error("Generate Secret failed: ", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.CryptoSettings.Secret = hex.EncodeToString(key)
|
||||||
|
|
||||||
|
err := settings.Save()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error save settings", err)
|
||||||
|
}
|
||||||
|
logger.Warn("Secret Generated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func InitJsExtensionType() {
|
func InitJsExtensionType() {
|
||||||
// Hack: fix wrong Content Type of .js file on some OS platforms
|
// Hack: fix wrong Content Type of .js file on some OS platforms
|
||||||
// See https://github.com/golang/go/issues/32350
|
// See https://github.com/golang/go/issues/32350
|
||||||
_ = mime.AddExtensionType(".js", "text/javascript; charset=utf-8")
|
_ = mime.AddExtensionType(".js", "text/javascript; charset=utf-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitCronJobs() {
|
|
||||||
s := gocron.NewScheduler(time.UTC)
|
|
||||||
job, err := s.Every(6).Hours().SingletonMode().Do(cert.AutoCert)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatalf("AutoCert Job: %v, Err: %v\n", job, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
job, err = s.Every(settings.LogrotateSettings.Interval).Minute().SingletonMode().Do(logrotate.Exec)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatalf("LogRotate Job: %v, Err: %v\n", job, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.StartAsync()
|
|
||||||
}
|
|
||||||
|
|
39
internal/user/otp.go
Normal file
39
internal/user/otp.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"github.com/0xJacky/Nginx-UI/internal/crypto"
|
||||||
|
"github.com/0xJacky/Nginx-UI/model"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrOTPCode = errors.New("invalid otp code")
|
||||||
|
ErrRecoveryCode = errors.New("invalid recovery code")
|
||||||
|
)
|
||||||
|
|
||||||
|
func VerifyOTP(user *model.Auth, otp, recoveryCode string) (err error) {
|
||||||
|
if otp != "" {
|
||||||
|
decrypted, err := crypto.AesDecrypt(user.OTPSecret)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := totp.Validate(otp, string(decrypted)); !ok {
|
||||||
|
return ErrOTPCode
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recoverCode, err := hex.DecodeString(recoveryCode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
k := sha1.Sum(user.OTPSecret)
|
||||||
|
if !bytes.Equal(k[:], recoverCode) {
|
||||||
|
return ErrRecoveryCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
|
@ -1,53 +1,84 @@
|
||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/0xJacky/Nginx-UI/internal/logger"
|
||||||
"github.com/0xJacky/Nginx-UI/model"
|
"github.com/0xJacky/Nginx-UI/model"
|
||||||
|
"github.com/0xJacky/Nginx-UI/query"
|
||||||
"github.com/0xJacky/Nginx-UI/settings"
|
"github.com/0xJacky/Nginx-UI/settings"
|
||||||
"github.com/dgrijalva/jwt-go"
|
"github.com/dgrijalva/jwt-go"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ExpiredTime = 24 * time.Hour
|
||||||
|
|
||||||
type JWTClaims struct {
|
type JWTClaims struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
UserID int `json:"user_id"`
|
||||||
jwt.StandardClaims
|
jwt.StandardClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUser(name string) (user model.Auth, err error) {
|
func BuildCacheTokenKey(token string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("token:")
|
||||||
|
sb.WriteString(token)
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUser(name string) (user *model.Auth, err error) {
|
||||||
db := model.UseDB()
|
db := model.UseDB()
|
||||||
err = db.Where("name", name).First(&user).Error
|
user = &model.Auth{}
|
||||||
|
err = db.Where("name", name).First(user).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteToken(token string) error {
|
func DeleteToken(token string) {
|
||||||
db := model.UseDB()
|
q := query.AuthToken
|
||||||
return db.Where("token", token).Delete(&model.AuthToken{}).Error
|
_, _ = q.Where(q.Token.Eq(token)).Delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckToken(token string) int64 {
|
func GetTokenUser(token string) (*model.Auth, bool) {
|
||||||
db := model.UseDB()
|
q := query.AuthToken
|
||||||
return db.Where("token", token).Find(&model.AuthToken{}).RowsAffected
|
authToken, err := q.Where(q.Token.Eq(token)).First()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if authToken.ExpiredAt < time.Now().Unix() {
|
||||||
|
DeleteToken(token)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
u := query.Auth
|
||||||
|
user, err := u.FirstByID(authToken.UserID)
|
||||||
|
return user, err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateJWT(name string) (string, error) {
|
func GenerateJWT(user *model.Auth) (string, error) {
|
||||||
claims := JWTClaims{
|
claims := JWTClaims{
|
||||||
Name: name,
|
Name: user.Name,
|
||||||
|
UserID: user.ID,
|
||||||
StandardClaims: jwt.StandardClaims{
|
StandardClaims: jwt.StandardClaims{
|
||||||
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
|
ExpiresAt: time.Now().Add(ExpiredTime).Unix(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
unsignedToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
unsignedToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
signedToken, err := unsignedToken.SignedString([]byte(settings.ServerSettings.JwtSecret))
|
signedToken, err := unsignedToken.SignedString([]byte(settings.ServerSettings.JwtSecret))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
db := model.UseDB()
|
q := query.AuthToken
|
||||||
err = db.Create(&model.AuthToken{
|
err = q.Create(&model.AuthToken{
|
||||||
|
UserID: user.ID,
|
||||||
Token: signedToken,
|
Token: signedToken,
|
||||||
}).Error
|
ExpiredAt: time.Now().Add(ExpiredTime).Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
@ -55,3 +86,50 @@ func GenerateJWT(name string) (string, error) {
|
||||||
|
|
||||||
return signedToken, err
|
return signedToken, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidateJWT(token string) (claims *JWTClaims, err error) {
|
||||||
|
if token == "" {
|
||||||
|
err = errors.New("token is empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unsignedToken, err := jwt.ParseWithClaims(
|
||||||
|
token,
|
||||||
|
&JWTClaims{},
|
||||||
|
func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(settings.ServerSettings.JwtSecret), nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
err = errors.New("parse with claims error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, ok := unsignedToken.Claims.(*JWTClaims)
|
||||||
|
if !ok {
|
||||||
|
err = errors.New("convert to jwt claims error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if claims.ExpiresAt < time.Now().UTC().Unix() {
|
||||||
|
err = errors.New("jwt is expired")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func CurrentUser(token string) (u *model.Auth, err error) {
|
||||||
|
// validate token
|
||||||
|
var claims *JWTClaims
|
||||||
|
claims, err = ValidateJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// get user by id
|
||||||
|
user := query.Auth
|
||||||
|
u, err = user.FirstByID(claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("[Current User]", u.Name)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
|
@ -6,8 +6,11 @@ type Auth struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Password string `json:"-"`
|
Password string `json:"-"`
|
||||||
Status bool `json:"status" gorm:"default:1"`
|
Status bool `json:"status" gorm:"default:1"`
|
||||||
|
OTPSecret []byte `json:"-" gorm:"type:blob"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthToken struct {
|
type AuthToken struct {
|
||||||
|
UserID int `json:"user_id"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
|
ExpiredAt int64 `json:"expired_at" gorm:"default:0"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,9 @@ func newAuthToken(db *gorm.DB, opts ...gen.DOOption) authToken {
|
||||||
|
|
||||||
tableName := _authToken.authTokenDo.TableName()
|
tableName := _authToken.authTokenDo.TableName()
|
||||||
_authToken.ALL = field.NewAsterisk(tableName)
|
_authToken.ALL = field.NewAsterisk(tableName)
|
||||||
|
_authToken.UserID = field.NewInt(tableName, "user_id")
|
||||||
_authToken.Token = field.NewString(tableName, "token")
|
_authToken.Token = field.NewString(tableName, "token")
|
||||||
|
_authToken.ExpiredAt = field.NewInt64(tableName, "expired_at")
|
||||||
|
|
||||||
_authToken.fillFieldMap()
|
_authToken.fillFieldMap()
|
||||||
|
|
||||||
|
@ -39,7 +41,9 @@ type authToken struct {
|
||||||
authTokenDo
|
authTokenDo
|
||||||
|
|
||||||
ALL field.Asterisk
|
ALL field.Asterisk
|
||||||
|
UserID field.Int
|
||||||
Token field.String
|
Token field.String
|
||||||
|
ExpiredAt field.Int64
|
||||||
|
|
||||||
fieldMap map[string]field.Expr
|
fieldMap map[string]field.Expr
|
||||||
}
|
}
|
||||||
|
@ -56,7 +60,9 @@ func (a authToken) As(alias string) *authToken {
|
||||||
|
|
||||||
func (a *authToken) updateTableName(table string) *authToken {
|
func (a *authToken) updateTableName(table string) *authToken {
|
||||||
a.ALL = field.NewAsterisk(table)
|
a.ALL = field.NewAsterisk(table)
|
||||||
|
a.UserID = field.NewInt(table, "user_id")
|
||||||
a.Token = field.NewString(table, "token")
|
a.Token = field.NewString(table, "token")
|
||||||
|
a.ExpiredAt = field.NewInt64(table, "expired_at")
|
||||||
|
|
||||||
a.fillFieldMap()
|
a.fillFieldMap()
|
||||||
|
|
||||||
|
@ -73,8 +79,10 @@ func (a *authToken) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *authToken) fillFieldMap() {
|
func (a *authToken) fillFieldMap() {
|
||||||
a.fieldMap = make(map[string]field.Expr, 1)
|
a.fieldMap = make(map[string]field.Expr, 3)
|
||||||
|
a.fieldMap["user_id"] = a.UserID
|
||||||
a.fieldMap["token"] = a.Token
|
a.fieldMap["token"] = a.Token
|
||||||
|
a.fieldMap["expired_at"] = a.ExpiredAt
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a authToken) clone(db *gorm.DB) authToken {
|
func (a authToken) clone(db *gorm.DB) authToken {
|
||||||
|
|
|
@ -35,6 +35,7 @@ func newAuth(db *gorm.DB, opts ...gen.DOOption) auth {
|
||||||
_auth.Name = field.NewString(tableName, "name")
|
_auth.Name = field.NewString(tableName, "name")
|
||||||
_auth.Password = field.NewString(tableName, "password")
|
_auth.Password = field.NewString(tableName, "password")
|
||||||
_auth.Status = field.NewBool(tableName, "status")
|
_auth.Status = field.NewBool(tableName, "status")
|
||||||
|
_auth.OTPSecret = field.NewBytes(tableName, "otp_secret")
|
||||||
|
|
||||||
_auth.fillFieldMap()
|
_auth.fillFieldMap()
|
||||||
|
|
||||||
|
@ -52,6 +53,7 @@ type auth struct {
|
||||||
Name field.String
|
Name field.String
|
||||||
Password field.String
|
Password field.String
|
||||||
Status field.Bool
|
Status field.Bool
|
||||||
|
OTPSecret field.Bytes
|
||||||
|
|
||||||
fieldMap map[string]field.Expr
|
fieldMap map[string]field.Expr
|
||||||
}
|
}
|
||||||
|
@ -75,6 +77,7 @@ func (a *auth) updateTableName(table string) *auth {
|
||||||
a.Name = field.NewString(table, "name")
|
a.Name = field.NewString(table, "name")
|
||||||
a.Password = field.NewString(table, "password")
|
a.Password = field.NewString(table, "password")
|
||||||
a.Status = field.NewBool(table, "status")
|
a.Status = field.NewBool(table, "status")
|
||||||
|
a.OTPSecret = field.NewBytes(table, "otp_secret")
|
||||||
|
|
||||||
a.fillFieldMap()
|
a.fillFieldMap()
|
||||||
|
|
||||||
|
@ -91,7 +94,7 @@ func (a *auth) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *auth) fillFieldMap() {
|
func (a *auth) fillFieldMap() {
|
||||||
a.fieldMap = make(map[string]field.Expr, 7)
|
a.fieldMap = make(map[string]field.Expr, 8)
|
||||||
a.fieldMap["id"] = a.ID
|
a.fieldMap["id"] = a.ID
|
||||||
a.fieldMap["created_at"] = a.CreatedAt
|
a.fieldMap["created_at"] = a.CreatedAt
|
||||||
a.fieldMap["updated_at"] = a.UpdatedAt
|
a.fieldMap["updated_at"] = a.UpdatedAt
|
||||||
|
@ -99,6 +102,7 @@ func (a *auth) fillFieldMap() {
|
||||||
a.fieldMap["name"] = a.Name
|
a.fieldMap["name"] = a.Name
|
||||||
a.fieldMap["password"] = a.Password
|
a.fieldMap["password"] = a.Password
|
||||||
a.fieldMap["status"] = a.Status
|
a.fieldMap["status"] = a.Status
|
||||||
|
a.fieldMap["otp_secret"] = a.OTPSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a auth) clone(db *gorm.DB) auth {
|
func (a auth) clone(db *gorm.DB) auth {
|
||||||
|
|
|
@ -58,11 +58,14 @@ func authRequired() gin.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.CheckToken(token) < 1 {
|
u, ok := user.GetTokenUser(token)
|
||||||
|
if !ok {
|
||||||
abortWithAuthFailure()
|
abortWithAuthFailure()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Set("user", u)
|
||||||
|
|
||||||
if nodeID := c.GetHeader("X-Node-ID"); nodeID != "" {
|
if nodeID := c.GetHeader("X-Node-ID"); nodeID != "" {
|
||||||
c.Set("ProxyNodeID", nodeID)
|
c.Set("ProxyNodeID", nodeID)
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,6 +46,7 @@ func InitRouter() *gin.Engine {
|
||||||
// Authorization required not websocket request
|
// Authorization required not websocket request
|
||||||
g := root.Group("/", authRequired(), proxy())
|
g := root.Group("/", authRequired(), proxy())
|
||||||
{
|
{
|
||||||
|
user.InitUserRouter(g)
|
||||||
analytic.InitRouter(g)
|
analytic.InitRouter(g)
|
||||||
user.InitManageUserRouter(g)
|
user.InitManageUserRouter(g)
|
||||||
nginx.InitRouter(g)
|
nginx.InitRouter(g)
|
||||||
|
|
|
@ -11,5 +11,6 @@ func TestCluster(t *testing.T) {
|
||||||
assert.Equal(t, []string{
|
assert.Equal(t, []string{
|
||||||
"http://10.0.0.1:9000?name=node1&node_secret=my-node-secret&enabled=true",
|
"http://10.0.0.1:9000?name=node1&node_secret=my-node-secret&enabled=true",
|
||||||
"http://10.0.0.2:9000?name=node2&node_secret=my-node-secret&enabled=true",
|
"http://10.0.0.2:9000?name=node2&node_secret=my-node-secret&enabled=true",
|
||||||
|
"http://10.0.0.3?name=node3&node_secret=my-node-secret&enabled=true",
|
||||||
}, ClusterSettings.Node)
|
}, ClusterSettings.Node)
|
||||||
}
|
}
|
||||||
|
|
14
settings/crypto.go
Normal file
14
settings/crypto.go
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
package settings
|
||||||
|
|
||||||
|
import "crypto/md5"
|
||||||
|
|
||||||
|
type Crypto struct {
|
||||||
|
Secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
var CryptoSettings = Crypto{}
|
||||||
|
|
||||||
|
func (c *Crypto) GetSecretMd5() []byte {
|
||||||
|
k := md5.Sum([]byte(c.Secret))
|
||||||
|
return k[:]
|
||||||
|
}
|
48
settings/crypto_test.go
Normal file
48
settings/crypto_test.go
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetSecretMd5_WithNonEmptySecret_ReturnsExpectedMd5Hash(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
CryptoSettings.Secret = "testSecret"
|
||||||
|
expectedMd5 := md5.Sum([]byte("testSecret"))
|
||||||
|
expectedMd5String := hex.EncodeToString(expectedMd5[:])
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
resultMd5 := CryptoSettings.GetSecretMd5()
|
||||||
|
resultMd5String := hex.EncodeToString(resultMd5[:])
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
assert.Equal(t, expectedMd5String, resultMd5String, "MD5 hash should match for non-empty secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSecretMd5_WithEmptySecret_ReturnsMd5OfEmptyString(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
CryptoSettings.Secret = ""
|
||||||
|
expectedMd5 := md5.Sum([]byte(""))
|
||||||
|
expectedMd5String := hex.EncodeToString(expectedMd5[:])
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
resultMd5 := CryptoSettings.GetSecretMd5()
|
||||||
|
resultMd5String := hex.EncodeToString(resultMd5[:])
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
assert.Equal(t, expectedMd5String, resultMd5String, "MD5 hash of an empty string should be returned for empty secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSecretMd5_WithDifferentSecrets_ReturnsDifferentMd5Hashes(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
CryptoSettings.Secret = "secret1"
|
||||||
|
firstMd5 := CryptoSettings.GetSecretMd5()
|
||||||
|
CryptoSettings.Secret = "secret2"
|
||||||
|
secondMd5 := CryptoSettings.GetSecretMd5()
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
assert.NotEqual(t, firstMd5, secondMd5, "Different secrets should produce different MD5 hashes")
|
||||||
|
}
|
|
@ -28,6 +28,7 @@ var sections = map[string]interface{}{
|
||||||
"logrotate": &LogrotateSettings,
|
"logrotate": &LogrotateSettings,
|
||||||
"cluster": &ClusterSettings,
|
"cluster": &ClusterSettings,
|
||||||
"auth": &AuthSettings,
|
"auth": &AuthSettings,
|
||||||
|
"crypto": &CryptoSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
@ -64,6 +65,7 @@ func Setup() {
|
||||||
parseEnv(&CasdoorSettings, "CASDOOR_")
|
parseEnv(&CasdoorSettings, "CASDOOR_")
|
||||||
parseEnv(&LogrotateSettings, "LOGROTATE_")
|
parseEnv(&LogrotateSettings, "LOGROTATE_")
|
||||||
parseEnv(&AuthSettings, "AUTH_")
|
parseEnv(&AuthSettings, "AUTH_")
|
||||||
|
parseEnv(&CryptoSettings, "CRYPTO_")
|
||||||
|
|
||||||
// if in official docker, set the restart cmd of nginx to "nginx -s stop",
|
// if in official docker, set the restart cmd of nginx to "nginx -s stop",
|
||||||
// then the supervisor of s6-overlay will start the nginx again.
|
// then the supervisor of s6-overlay will start the nginx again.
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue