Compare commits
47 Commits
514e3e30b6
...
debug
| Author | SHA1 | Date | |
|---|---|---|---|
| abc6cb4e46 | |||
| 0660117c07 | |||
| 9ede6257f8 | |||
| f5b9b32a9f | |||
| e721cff3f8 | |||
| 7e54d62170 | |||
| 477dd94227 | |||
| c59d122e04 | |||
| ad92439770 | |||
| f1fc52bd6b | |||
| 24cc11bc8d | |||
| 10d899b50f | |||
| 2a8faaa9fe | |||
| c5e35b4c12 | |||
| f578b6eb51 | |||
| a2c71da3a0 | |||
| 28631865c8 | |||
| edb1458806 | |||
| ce73e915ca | |||
| baaa27005e | |||
| 84807b9ba9 | |||
| b99f60c7e5 | |||
| 6740dbb1b7 | |||
| 065b5492ef | |||
| 5c67c0287e | |||
| 57f12f792c | |||
| 8ab7fbc6b2 | |||
| d917a9e465 | |||
| 82c6e1bb15 | |||
| 68f3174f08 | |||
| 94be9799f4 | |||
| 3541fbdaae | |||
| 81f3ba52cc | |||
| 8dee5ac823 | |||
| 980526c630 | |||
| eb193b1b95 | |||
| 476499515d | |||
| 73ca068128 | |||
| 8cf0837f97 | |||
| 88fb7a1888 | |||
| cc23cc2a1e | |||
| f073756e92 | |||
| add00401de | |||
| a8b6265a40 | |||
| 67505d86e8 | |||
| 81cb296ad4 | |||
| 2e6b03cbf2 |
@@ -0,0 +1 @@
|
||||
go.work.sum
|
||||
+6
-8
@@ -2,16 +2,14 @@ FROM golang:1.26.1 as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
go mod download
|
||||
COPY proto/ proto/
|
||||
COPY agent/ agent/
|
||||
|
||||
COPY . .
|
||||
ENV CGO_ENABLED=0
|
||||
WORKDIR /app/agent
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
go build -ldflags "-s -w" -o agent ./cmd/main.go
|
||||
go mod download && \
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w" -o /agent .
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -21,6 +19,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/agent .
|
||||
COPY --from=builder /agent .
|
||||
|
||||
CMD ["./agent"]
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
module gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403214837-94be9799f47d
|
||||
github.com/hpcloud/tail v1.0.0
|
||||
github.com/samber/lo v1.53.0
|
||||
golang.org/x/sync v0.20.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/fsnotify.v1 v1.4.7 // indirect
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
)
|
||||
|
||||
replace gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto => ../proto
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,161 @@
|
||||
package buffer
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// BufferedLog represents a log entry stored for later delivery
|
||||
type BufferedLog struct {
|
||||
ID int64
|
||||
Service string
|
||||
Message string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// LogBuffer provides SQLite-backed log buffering
|
||||
type LogBuffer struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewLogBuffer creates a new log buffer with the given database path
|
||||
func NewLogBuffer(dbPath string) (*LogBuffer, error) {
|
||||
db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// Create table if not exists
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS buffered_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
service TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("failed to create table: %w", err)
|
||||
}
|
||||
|
||||
// Create index for efficient ordering
|
||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_created_at ON buffered_logs(created_at ASC)`)
|
||||
|
||||
return &LogBuffer{db: db}, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (b *LogBuffer) Close() error {
|
||||
return b.db.Close()
|
||||
}
|
||||
|
||||
// Store stores a log entry in the buffer
|
||||
func (b *LogBuffer) Store(service, message string) error {
|
||||
_, err := b.db.Exec(
|
||||
"INSERT INTO buffered_logs (service, message) VALUES (?, ?)",
|
||||
service, message,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// StoreBatch stores multiple log entries in a single transaction
|
||||
func (b *LogBuffer) StoreBatch(entries []BufferedLog) error {
|
||||
tx, err := b.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare("INSERT INTO buffered_logs (service, message) VALUES (?, ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, entry := range entries {
|
||||
if _, err := stmt.Exec(entry.Service, entry.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetPending retrieves pending logs in order of arrival, limited to batchSize
|
||||
func (b *LogBuffer) GetPending(batchSize int) ([]BufferedLog, error) {
|
||||
rows, err := b.db.Query(
|
||||
"SELECT id, service, message, created_at FROM buffered_logs ORDER BY created_at ASC LIMIT ?",
|
||||
batchSize,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []BufferedLog
|
||||
for rows.Next() {
|
||||
var log BufferedLog
|
||||
var createdAt string
|
||||
if err := rows.Scan(&log.ID, &log.Service, &log.Message, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
logs = append(logs, log)
|
||||
}
|
||||
|
||||
return logs, rows.Err()
|
||||
}
|
||||
|
||||
// Delete removes a log entry from the buffer after successful delivery
|
||||
func (b *LogBuffer) Delete(id int64) error {
|
||||
_, err := b.db.Exec("DELETE FROM buffered_logs WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteBatch removes multiple log entries after successful delivery
|
||||
func (b *LogBuffer) DeleteBatch(ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := b.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, id := range ids {
|
||||
if _, err := tx.Exec("DELETE FROM buffered_logs WHERE id = ?", id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Count returns the number of buffered logs
|
||||
func (b *LogBuffer) Count() (int, error) {
|
||||
var count int
|
||||
err := b.db.QueryRow("SELECT COUNT(*) FROM buffered_logs").Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// Clear removes all buffered logs
|
||||
func (b *LogBuffer) Clear() error {
|
||||
_, err := b.db.Exec("DELETE FROM buffered_logs")
|
||||
return err
|
||||
}
|
||||
|
||||
// FlushToJSON exports buffered logs to JSON format for debugging
|
||||
func (b *LogBuffer) FlushToJSON() ([]byte, error) {
|
||||
logs, err := b.GetPending(1000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.MarshalIndent(logs, "", " ")
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/commander"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type CommanderClient struct {
|
||||
cmder *commander.CommandExecutor
|
||||
wg *sync.WaitGroup
|
||||
id, label string
|
||||
}
|
||||
|
||||
func New(
|
||||
cmder *commander.CommandExecutor,
|
||||
id, label string,
|
||||
) CommanderClient {
|
||||
return CommanderClient{cmder, new(sync.WaitGroup), id, label}
|
||||
}
|
||||
|
||||
func (self *CommanderClient) HandleCommands(ctx context.Context, srvAddr string, tc credentials.TransportCredentials) error {
|
||||
cli, err := grpc.NewClient(srvAddr, grpc.WithTransportCredentials(tc))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to connect to gRPC: %w", err)
|
||||
}
|
||||
ccli := proto.NewCommanderClient(cli)
|
||||
bidi, err := ccli.Stream(metadata.NewOutgoingContext(ctx, metadata.MD{"agentid": []string{self.id}, "label": []string{self.label}}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wg := new(errgroup.Group)
|
||||
wg.Go(self.recv(bidi))
|
||||
// wg.Go(self.send(bidi))
|
||||
err = wg.Wait()
|
||||
self.wg.Wait()
|
||||
return err
|
||||
}
|
||||
|
||||
func (self *CommanderClient) recv(bidi grpc.BidiStreamingClient[proto.FinishedCommand, proto.Command]) func() error {
|
||||
return func() error {
|
||||
for {
|
||||
msg, err := bidi.Recv()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
self.wg.Go(func() {
|
||||
func() error {
|
||||
fc, err := self.cmder.Execute(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bidi.Send(fc)
|
||||
}()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// func (self *God) send(bidi grpc.BidiStreamingClient[proto.FinishedCommand, proto.Command]) func() error {
|
||||
// return func() error {
|
||||
// return nil
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,65 @@
|
||||
package commander
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type CommandExecutor struct {
|
||||
}
|
||||
|
||||
func (*CommandExecutor) Execute(command *proto.Command) (*proto.FinishedCommand, error) {
|
||||
cmd := exec.Command(command.Command[0], command.Command[1:]...)
|
||||
var (
|
||||
stdin io.WriteCloser
|
||||
err error
|
||||
)
|
||||
if command.Stdin != nil {
|
||||
stdin, err = cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
stdout, err1 := cmd.StdoutPipe()
|
||||
stderr, err2 := cmd.StderrPipe()
|
||||
if err := errors.Join(err1, err2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if command.Stdin != nil {
|
||||
io.WriteString(stdin, *command.Stdin)
|
||||
if err := stdin.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
eg := new(errgroup.Group)
|
||||
stdoutbuf := new(bytes.Buffer)
|
||||
stderrbuf := new(bytes.Buffer)
|
||||
eg.Go(func() error {
|
||||
_, err := io.Copy(stdoutbuf, stdout)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
_, err := io.Copy(stderrbuf, stderr)
|
||||
return err
|
||||
})
|
||||
if err := cmd.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := eg.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.FinishedCommand{
|
||||
Id: command.Id,
|
||||
Status: int32(cmd.ProcessState.ExitCode()),
|
||||
Stdout: stdoutbuf.String(),
|
||||
Stderr: stderrbuf.String(),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type ServiceConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Type string `yaml:"type"`
|
||||
Path *string `yaml:"path"`
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
BackendURL string `yaml:"backend_url"`
|
||||
GRPCURL string `yaml:"grpc_url"`
|
||||
RegistrationToken string `yaml:"registration_token"`
|
||||
Label string `yaml:"label"`
|
||||
CertDir string `yaml:"cert_dir"`
|
||||
Services []ServiceConfig `yaml:"services"`
|
||||
}
|
||||
|
||||
func Load(path string) (*AgentConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg AgentConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.CertDir == "" {
|
||||
cfg.CertDir = "/etc/hellreign-agent/certs"
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func LoadFromString(data string) (*AgentConfig, error) {
|
||||
var cfg AgentConfig
|
||||
if err := yaml.Unmarshal([]byte(data), &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func validateConfigPath(path string) error {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("config file not found: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
*slog.Logger
|
||||
}
|
||||
|
||||
func New(debug bool) *Logger {
|
||||
var level slog.Level
|
||||
if debug {
|
||||
level = slog.LevelDebug
|
||||
} else {
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
|
||||
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
})
|
||||
|
||||
return &Logger{
|
||||
Logger: slog.New(handler),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
||||
"github.com/hpcloud/tail"
|
||||
)
|
||||
|
||||
var _ logsource.LogSource = new(FileLogSource)
|
||||
|
||||
type FileLogSource struct {
|
||||
*tail.Tail
|
||||
}
|
||||
|
||||
func New(filepath string) (fls *FileLogSource, err error) {
|
||||
if _, err := os.Stat(filepath); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(filepath, []byte{}, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
t, err := tail.TailFile(filepath, tail.Config{
|
||||
Follow: true,
|
||||
Location: &tail.SeekInfo{
|
||||
Offset: 100,
|
||||
Whence: 2,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return &FileLogSource{t}, nil
|
||||
}
|
||||
func (f *FileLogSource) ReadLine() (string, error) {
|
||||
select {
|
||||
case <-f.Dead():
|
||||
return "", errors.Join(logsource.ErrDead, f.Err())
|
||||
case line := <-f.Lines:
|
||||
return line.Text, line.Err
|
||||
}
|
||||
}
|
||||
func (f *FileLogSource) Close() error {
|
||||
return f.Stop()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package logsource
|
||||
|
||||
import "errors"
|
||||
|
||||
type LogSource interface {
|
||||
ReadLine() (string, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
var ErrDead = errors.New("shouldn't continue to read that")
|
||||
@@ -0,0 +1,55 @@
|
||||
package journald
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
||||
)
|
||||
|
||||
var _ logsource.LogSource = new(JournaldLogSource)
|
||||
|
||||
type JournaldLogSource struct {
|
||||
cmd *exec.Cmd
|
||||
stdout io.ReadCloser
|
||||
stdoutscanner *bufio.Scanner
|
||||
}
|
||||
|
||||
// ReadLine implements logsource.LogSource.
|
||||
func (j *JournaldLogSource) ReadLine() (string, error) {
|
||||
if j.stdoutscanner.Scan() {
|
||||
return j.stdoutscanner.Text(), nil
|
||||
} else {
|
||||
if j.stdoutscanner.Err() == nil {
|
||||
return "", fmt.Errorf("%w: %s", logsource.ErrDead, io.EOF)
|
||||
}
|
||||
return "", j.stdoutscanner.Err()
|
||||
}
|
||||
}
|
||||
func (j *JournaldLogSource) Close() error {
|
||||
_ = j.cmd.Process.Signal(syscall.SIGTERM)
|
||||
return j.cmd.Wait()
|
||||
}
|
||||
|
||||
func New(cfg config.ServiceConfig, logdir string) (*JournaldLogSource, error) {
|
||||
args := make([]string, 0)
|
||||
if cfg.Path != nil {
|
||||
args = append(args, "-u", *cfg.Path)
|
||||
}
|
||||
args = append(args, "-f", "-n", "0", "-o", "short", "--no-pager", "--directory", logdir)
|
||||
cmd := exec.Command("journalctl", args...) //nolint:gosec
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdoutscanner := bufio.NewScanner(stdout)
|
||||
return &JournaldLogSource{cmd, stdout, stdoutscanner}, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// LoadMTLSCredentials loads client certificate and CA certificate for mTLS.
|
||||
func LoadMTLSCredentials(caCertPEM, clientCertPEM, clientKeyPEM []byte) (credentials.TransportCredentials, error) {
|
||||
cert, err := tls.X509KeyPair(clientCertPEM, clientKeyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load client key pair: %w", err)
|
||||
}
|
||||
|
||||
caCertPool := x509.NewCertPool()
|
||||
if !caCertPool.AppendCertsFromPEM(caCertPEM) {
|
||||
return nil, fmt.Errorf("failed to append CA certificate")
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
RootCAs: caCertPool,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
return credentials.NewTLS(tlsConfig), nil
|
||||
}
|
||||
|
||||
// LoadMTLSCredentialsFromFiles loads mTLS credentials from file paths.
|
||||
func LoadMTLSCredentialsFromFiles(caCertPath, clientCertPath, clientKeyPath string) (credentials.TransportCredentials, error) {
|
||||
caCert, err := os.ReadFile(caCertPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CA cert: %w", err)
|
||||
}
|
||||
clientCert, err := os.ReadFile(clientCertPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read client cert: %w", err)
|
||||
}
|
||||
clientKey, err := os.ReadFile(clientKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read client key: %w", err)
|
||||
}
|
||||
|
||||
return LoadMTLSCredentials(caCert, clientCert, clientKey)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package registration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Certs struct {
|
||||
CACertPEM []byte
|
||||
ClientCertPEM []byte
|
||||
ClientKeyPEM []byte
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
CSR string `json:"csr"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type RegisterResponse struct {
|
||||
CACert string `json:"ca_cert"`
|
||||
ClientCert string `json:"client_cert"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// GenerateKeyAndCSR generates a new ECDSA private key and CSR for the agent.
|
||||
func GenerateKeyAndCSR(label string) (*ecdsa.PrivateKey, []byte, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
|
||||
template := x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: label,
|
||||
Organization: []string{"HellreigN Agent"},
|
||||
},
|
||||
SignatureAlgorithm: x509.ECDSAWithSHA256,
|
||||
}
|
||||
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, &template, key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create csr: %w", err)
|
||||
}
|
||||
|
||||
csrPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrDER,
|
||||
})
|
||||
|
||||
return key, csrPEM, nil
|
||||
}
|
||||
|
||||
// Register sends CSR to backend and receives signed certificates.
|
||||
func Register(backendURL, token string, csrPEM []byte) (*Certs, error) {
|
||||
reqBody := RegisterRequest{CSR: string(csrPEM), Token: token}
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/agents/register", backendURL)
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("register request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp ErrorResponse
|
||||
json.NewDecoder(resp.Body).Decode(&errResp)
|
||||
return nil, fmt.Errorf("registration failed (status %d): %s", resp.StatusCode, errResp.Error)
|
||||
}
|
||||
|
||||
var regResp RegisterResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(®Resp); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
return &Certs{
|
||||
CACertPEM: []byte(regResp.CACert),
|
||||
ClientCertPEM: []byte(regResp.ClientCert),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveCerts saves CA cert, client cert, and client key to the given directory.
|
||||
func SaveCerts(certDir string, certs *Certs, key *ecdsa.PrivateKey) error {
|
||||
if err := os.MkdirAll(certDir, 0700); err != nil {
|
||||
return fmt.Errorf("create cert dir: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(certDir, "ca.crt"), certs.CACertPEM, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(certDir, "client.crt"), certs.ClientCertPEM, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "EC PRIVATE KEY",
|
||||
Bytes: keyDER,
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(certDir, "client.key"), keyPEM, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadCerts loads existing certificates and key from disk.
|
||||
func LoadCerts(certDir string) (*Certs, *ecdsa.PrivateKey, error) {
|
||||
caCert, err := os.ReadFile(filepath.Join(certDir, "ca.crt"))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
clientCert, err := os.ReadFile(filepath.Join(certDir, "client.crt"))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
clientKeyPEM, err := os.ReadFile(filepath.Join(certDir, "client.key"))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(clientKeyPEM)
|
||||
if block == nil {
|
||||
return nil, nil, fmt.Errorf("decode client key")
|
||||
}
|
||||
key, err := x509.ParseECPrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse client key: %w", err)
|
||||
}
|
||||
|
||||
return &Certs{
|
||||
CACertPEM: caCert,
|
||||
ClientCertPEM: clientCert,
|
||||
}, key, nil
|
||||
}
|
||||
|
||||
// CertsExist checks if all certificate files exist in the directory.
|
||||
func CertsExist(certDir string) bool {
|
||||
files := []string{"ca.crt", "client.crt", "client.key"}
|
||||
for _, f := range files {
|
||||
if _, err := os.Stat(filepath.Join(certDir, f)); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/buffer"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/client"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/commander"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logger"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/file"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/journald"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/mtls"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/registration"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||
"github.com/samber/lo"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfgPath := os.Getenv("CONFIG_FILE")
|
||||
if cfgPath == "" {
|
||||
cfgPath = "/etc/hellreign-agent/config.yml"
|
||||
}
|
||||
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
lgr := logger.New(os.Getenv("IS_DEBUG") == "1")
|
||||
lgr.Debug("Config parsed", "cfg", cfg)
|
||||
|
||||
// Check if certificates already exist (agent was previously registered)
|
||||
if registration.CertsExist(cfg.CertDir) {
|
||||
lgr.Info("Certificates found, skipping registration")
|
||||
} else {
|
||||
if cfg.RegistrationToken == "" {
|
||||
lgr.Error("No registration token provided")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Generate key and CSR
|
||||
k, csrPEM, err := registration.GenerateKeyAndCSR(cfg.Label)
|
||||
if err != nil {
|
||||
lgr.Error("Failed to generate key and CSR", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
lgr.Info("Generated ECDSA key pair and CSR")
|
||||
|
||||
// Register with backend
|
||||
certs, err := registration.Register(cfg.BackendURL, cfg.RegistrationToken, csrPEM)
|
||||
if err != nil {
|
||||
lgr.Error("Failed to register", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
lgr.Info("Successfully registered, received certificates")
|
||||
|
||||
// Save certificates
|
||||
if err := registration.SaveCerts(cfg.CertDir, certs, k); err != nil {
|
||||
lgr.Error("Failed to save certificates", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
lgr.Info("Certificates saved", "cert_dir", cfg.CertDir)
|
||||
}
|
||||
|
||||
creds, err := mtls.LoadMTLSCredentialsFromFiles(
|
||||
cfg.CertDir+"/ca.crt",
|
||||
cfg.CertDir+"/client.crt",
|
||||
cfg.CertDir+"/client.key",
|
||||
)
|
||||
if err != nil {
|
||||
lgr.Error("Failed to load TLS credentials", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize log buffer for offline storage
|
||||
dbPath := getEnvOrDefault("BUFFER_DB", "/var/lib/hellreign-agent/agent_buffer.db")
|
||||
logBuf, err := buffer.NewLogBuffer(dbPath)
|
||||
if err != nil {
|
||||
lgr.Error("Failed to create log buffer", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = logBuf.Close() }()
|
||||
lgr.Info("Log buffer initialized", "path", dbPath)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
wg := &errgroup.Group{}
|
||||
|
||||
grpcAddr := cfg.GRPCURL
|
||||
if grpcAddr == "" {
|
||||
grpcAddr = cfg.BackendURL
|
||||
}
|
||||
grpcAddr = strings.TrimPrefix(grpcAddr, "http://")
|
||||
grpcAddr = strings.TrimPrefix(grpcAddr, "https://")
|
||||
// Start command executor
|
||||
wg.Go(func() error {
|
||||
cmdexe := new(commander.CommandExecutor)
|
||||
ccli := client.New(cmdexe, cfg.Label, cfg.Label)
|
||||
return ccli.HandleCommands(ctx, grpcAddr, creds)
|
||||
})
|
||||
|
||||
// Start log collectors
|
||||
if len(cfg.Services) > 0 {
|
||||
wg.Go(func() error {
|
||||
conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(creds))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to gRPC: %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
ccli := proto.NewCollectorClient(conn)
|
||||
|
||||
svcWg := new(errgroup.Group)
|
||||
for _, svc := range cfg.Services {
|
||||
svc := svc
|
||||
var src logsource.LogSource
|
||||
switch svc.Type {
|
||||
case "journald":
|
||||
src, err = journald.New(svc, os.Getenv("JOURNALD_LOGDIR"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create journald source %q: %w", svc.Name, err)
|
||||
}
|
||||
case "file":
|
||||
if svc.Path == nil {
|
||||
return fmt.Errorf("path is required for file log source %q", svc.Name)
|
||||
}
|
||||
src, err = file.New(*svc.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file source %q: %w", svc.Name, err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown log source type %q for service %q", svc.Type, svc.Name)
|
||||
}
|
||||
|
||||
svcWg.Go(func() error {
|
||||
lgr.Info("Starting log stream", "service", svc.Name)
|
||||
|
||||
// First, flush any buffered logs from offline period
|
||||
if err := flushBufferedLogs(ctx, ccli, logBuf, svc.Name, cfg.Label, cfg.RegistrationToken, lgr); err != nil {
|
||||
lgr.Error("Failed to flush buffered logs", "service", svc.Name, "err", err)
|
||||
}
|
||||
|
||||
scli, err := ccli.Stream(
|
||||
metadata.NewOutgoingContext(ctx, metadata.MD{
|
||||
"whoami": []string{cfg.Label},
|
||||
"service": []string{svc.Name},
|
||||
"token": []string{cfg.RegistrationToken},
|
||||
"services": lo.Map(cfg.Services, func(item config.ServiceConfig, _ int) string {
|
||||
return item.Name
|
||||
}),
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stream: %w", err)
|
||||
}
|
||||
|
||||
for {
|
||||
line, err := src.ReadLine()
|
||||
if err != nil {
|
||||
lgr.Error("ReadLine error", "service", svc.Name, "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := scli.Send(&proto.CollectorRequest{
|
||||
Message: line,
|
||||
}); err != nil {
|
||||
// Connection failed, buffer the log
|
||||
lgr.Warn("Send failed, buffering log", "service", svc.Name, "err", err)
|
||||
if storeErr := logBuf.Store(svc.Name, line); storeErr != nil {
|
||||
lgr.Error("Failed to buffer log", "service", svc.Name, "err", storeErr)
|
||||
}
|
||||
// Try to reconnect
|
||||
if reconnectErr := reconnectStream(ctx, &scli, ccli, svc.Name, cfg.Label, cfg.RegistrationToken, logBuf, lgr); reconnectErr != nil {
|
||||
return reconnectErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return svcWg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
if err := wg.Wait(); err != nil {
|
||||
lgr.Error("Agent dead", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// flushBufferedLogs sends any buffered logs to the server
|
||||
func flushBufferedLogs(
|
||||
ctx context.Context,
|
||||
ccli proto.CollectorClient,
|
||||
logBuf *buffer.LogBuffer,
|
||||
service, agentName, token string,
|
||||
lgr *logger.Logger,
|
||||
) error {
|
||||
count, err := logBuf.Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
lgr.Info("Flushing buffered logs", "service", service, "count", count)
|
||||
|
||||
scli, err := ccli.Stream(
|
||||
metadata.NewOutgoingContext(ctx, metadata.MD{
|
||||
"whoami": []string{agentName},
|
||||
"service": []string{service},
|
||||
"token": []string{token},
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stream for flush: %w", err)
|
||||
}
|
||||
|
||||
const batchSize = 100
|
||||
var deletedIDs []int64
|
||||
|
||||
for {
|
||||
logs, err := logBuf.GetPending(batchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(logs) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, logEntry := range logs {
|
||||
if err := scli.Send(&proto.CollectorRequest{Message: logEntry.Message}); err != nil {
|
||||
lgr.Error("Failed to send buffered log", "service", service, "err", err)
|
||||
return err
|
||||
}
|
||||
deletedIDs = append(deletedIDs, logEntry.ID)
|
||||
}
|
||||
|
||||
// Delete successfully sent logs
|
||||
if err := logBuf.DeleteBatch(deletedIDs); err != nil {
|
||||
lgr.Error("Failed to delete sent logs from buffer", "service", service, "err", err)
|
||||
}
|
||||
deletedIDs = deletedIDs[:0]
|
||||
}
|
||||
|
||||
_, err = scli.CloseAndRecv()
|
||||
lgr.Info("Buffer flush complete", "service", service)
|
||||
return err
|
||||
}
|
||||
|
||||
// reconnectStream attempts to recreate a gRPC stream connection
|
||||
func reconnectStream(
|
||||
ctx context.Context,
|
||||
scli *grpc.ClientStreamingClient[proto.CollectorRequest, proto.CollectorResponse],
|
||||
ccli proto.CollectorClient,
|
||||
service, agentName, token string,
|
||||
buf *buffer.LogBuffer,
|
||||
lgr *logger.Logger,
|
||||
) error {
|
||||
lgr.Info("Attempting to reconnect stream...", "service", service)
|
||||
|
||||
// Try up to 5 times with exponential backoff
|
||||
for i := 0; i < 5; i++ {
|
||||
time.Sleep(time.Duration(i+1) * time.Second)
|
||||
|
||||
newCli, err := ccli.Stream(
|
||||
metadata.NewOutgoingContext(ctx, metadata.MD{
|
||||
"whoami": []string{agentName},
|
||||
"service": []string{service},
|
||||
"token": []string{token},
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
lgr.Warn("Reconnect attempt failed", "service", service, "attempt", i+1, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
*scli = newCli
|
||||
lgr.Info("Stream reconnected successfully", "service", service)
|
||||
return flushBufferedLogs(ctx, ccli, buf, service, agentName, token, lgr)
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to reconnect after 5 attempts for service %s", service)
|
||||
}
|
||||
+243
-30
@@ -1,18 +1,29 @@
|
||||
package cmd
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/docs"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/config"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/collector"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/handlers"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// @securityDefinitions.apikey Bearer
|
||||
@@ -27,17 +38,96 @@ func main() {
|
||||
}
|
||||
cfg, err := config.ImportSettings(cfg_path)
|
||||
if err != nil {
|
||||
log.Fatalf("Err loading config")
|
||||
log.Fatalf("Err loading config: %v", err)
|
||||
}
|
||||
|
||||
db, err := storage.Open(cfg.Database.Token_db)
|
||||
if err != nil {
|
||||
log.Fatalf("Err opening database")
|
||||
log.Fatalf("Err opening database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
h := handlers.New(db)
|
||||
agents := handlers.AgentsGroup{Handlers: h}
|
||||
|
||||
// Initialize registration tokens table
|
||||
if err := h.Repo.InitRegistrationTokens(); err != nil {
|
||||
log.Printf("Warning: failed to initialize registration tokens table: %v", err)
|
||||
}
|
||||
|
||||
// Initialize jobs table
|
||||
jobRepo := repository.NewJobRepository(db)
|
||||
if err := jobRepo.Init(context.Background()); err != nil {
|
||||
log.Printf("Warning: failed to initialize jobs table: %v", err)
|
||||
}
|
||||
|
||||
// Initialize ClickHouse and log repository
|
||||
logRepo := repository.NewLogRepository()
|
||||
if cfg.Database.Clickhouse_host != "" {
|
||||
go func() {
|
||||
db, err := storage.OpenClickHouseWithRetry(storage.ClickHouseConfig{
|
||||
Host: cfg.Database.Clickhouse_host,
|
||||
User: cfg.Database.Clickhouse_user,
|
||||
Password: cfg.Database.Clickhouse_password,
|
||||
Database: cfg.Database.Clickhouse_database,
|
||||
}, 10, 5*time.Second)
|
||||
if err != nil {
|
||||
log.Printf("Warning: ClickHouse connection failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.Println("ClickHouse connected successfully")
|
||||
logRepo.SetDB(db)
|
||||
if err := logRepo.Init(context.Background()); err != nil {
|
||||
log.Printf("Warning: Failed to initialize logs table: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Initialize Collector gRPC service
|
||||
coll := collector.New(logRepo)
|
||||
|
||||
cmdr := commander.New(jobRepo)
|
||||
|
||||
// Initialize script interpreter repository and service
|
||||
scriptRepo := repository.NewScriptInterpreterRepo(db)
|
||||
if err := scriptRepo.Init(context.Background()); err != nil {
|
||||
log.Printf("Warning: failed to initialize script interpreters table: %v", err)
|
||||
}
|
||||
scriptSvc := service.NewScriptService(scriptRepo)
|
||||
scriptHandlers := handlers.NewScriptHandlers(scriptSvc, cmdr)
|
||||
jobsHandlers := handlers.NewJobsHandlers(cmdr, scriptSvc)
|
||||
|
||||
agents := handlers.NewAgentsGroup(h, coll)
|
||||
auth := handlers.AuthGroup{Handlers: h}
|
||||
agentReg := handlers.NewAgentRegistrationGroup(h)
|
||||
agentDeploy := handlers.NewAgentDeployGroup(h)
|
||||
|
||||
// Create admin user from config if not exists
|
||||
if cfg.Admin.Admin_login != "" && cfg.Admin.Admin_password != "" {
|
||||
if !h.Repo.ExistsByLogin(cfg.Admin.Admin_login) {
|
||||
_, err := h.Repo.CreateToken(repository.TokenCreate{
|
||||
Name: cfg.Admin.Admin_name,
|
||||
LastName: cfg.Admin.Admin_last_name,
|
||||
Login: cfg.Admin.Admin_login,
|
||||
Password: cfg.Admin.Admin_password,
|
||||
PermissionView: true,
|
||||
PermissionManage: true,
|
||||
PermissionAdmin: true,
|
||||
IsActive: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to create admin user: %v", err)
|
||||
} else {
|
||||
log.Println("Admin user created from config")
|
||||
}
|
||||
} else {
|
||||
// Ensure existing admin is activated
|
||||
if err := h.Repo.ActivateUserByLogin(cfg.Admin.Admin_login); err != nil {
|
||||
log.Printf("Warning: failed to activate admin user: %v", err)
|
||||
} else {
|
||||
log.Println("Admin user activated")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
router := gin.Default()
|
||||
docs.SwaggerInfo.BasePath = "/api/v1"
|
||||
@@ -49,41 +139,164 @@ func main() {
|
||||
|
||||
v1 := router.Group("/api/v1")
|
||||
{
|
||||
// Auth routes (public)
|
||||
authGroup := v1.Group("/auth")
|
||||
{
|
||||
authGroup.POST("/login", auth.Login)
|
||||
}
|
||||
|
||||
// Auth token management (requires auth)
|
||||
authTokenGroup := v1.Group("/auth")
|
||||
authTokenGroup.Use(auth.AuthMiddleware())
|
||||
{
|
||||
authTokenGroup.POST("/token", handlers.RequireAdmin(), auth.CreateToken)
|
||||
authTokenGroup.GET("/validate", auth.ValidateToken)
|
||||
authTokenGroup.GET("/tokens", handlers.RequireAdmin(), auth.ListTokens)
|
||||
authTokenGroup.DELETE("/token", auth.DeleteMyToken)
|
||||
authTokenGroup.DELETE("/tokens/:login", handlers.RequireAdmin(), auth.DeleteToken)
|
||||
|
||||
// User management (admin only) - Full CRUD
|
||||
authTokenGroup.GET("/users/:login", handlers.RequireAdmin(), auth.GetUser)
|
||||
authTokenGroup.PUT("/users/:login", handlers.RequireAdmin(), auth.UpdateUser)
|
||||
authTokenGroup.PUT("/users/:login/permissions", handlers.RequireAdmin(), auth.UpdateUserPermissions)
|
||||
authTokenGroup.PUT("/users/:login/password", handlers.RequireAdmin(), auth.ResetUserPassword)
|
||||
|
||||
// User activation management (admin only)
|
||||
authTokenGroup.POST("/users/:login/activate", handlers.RequireAdmin(), auth.ActivateUser)
|
||||
authTokenGroup.POST("/users/:login/deactivate", handlers.RequireAdmin(), auth.DeactivateUser)
|
||||
authTokenGroup.GET("/users/inactive", handlers.RequireAdmin(), auth.ListInactiveUsers)
|
||||
}
|
||||
|
||||
// Agents (requires manage_agent permission)
|
||||
agentsGroup := v1.Group("/agents")
|
||||
agentsGroup.Use(auth.AuthMiddleware(), handlers.RequireManageAgent())
|
||||
{
|
||||
agentsGroup.GET("", agents.List)
|
||||
}
|
||||
|
||||
logsGroup := v1.Group("/logs")
|
||||
// Jobs (requires admin permission)
|
||||
jobsGroup := v1.Group("/jobs")
|
||||
jobsGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin())
|
||||
{
|
||||
if cfg.Database.Clickhouse_host != "" {
|
||||
chConn, err := storage.OpenClickHouse(storage.ClickHouseConfig{
|
||||
Host: cfg.Database.Clickhouse_host,
|
||||
User: cfg.Database.Clickhouse_user,
|
||||
Password: cfg.Database.Clickhouse_password,
|
||||
Database: cfg.Database.Clickhouse_database,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Warning: ClickHouse connection failed: %v", err)
|
||||
} else {
|
||||
defer chConn.Close()
|
||||
jobsGroup.POST("", jobsHandlers.AddJob)
|
||||
}
|
||||
|
||||
logRepo := repository.NewLogRepository(chConn)
|
||||
if err := logRepo.Init(context.Background()); err != nil {
|
||||
log.Printf("Warning: Failed to initialize logs table: %v", err)
|
||||
}
|
||||
// Agent registration
|
||||
agentRegGroup := v1.Group("/agents")
|
||||
{
|
||||
agentRegGroup.POST("/register", agentReg.Register)
|
||||
}
|
||||
agentRegTokenGroup := v1.Group("/agents")
|
||||
agentRegTokenGroup.Use(auth.AuthMiddleware(), handlers.RequireManageAgent())
|
||||
{
|
||||
agentRegTokenGroup.POST("/register-token", agentReg.CreateRegistrationToken)
|
||||
agentRegTokenGroup.POST("/deploy", agentDeploy.DeployAgents)
|
||||
}
|
||||
|
||||
logHandlers := handlers.NewLogHandlers(logRepo)
|
||||
logsGroup.POST("", logHandlers.Insert)
|
||||
logsGroup.POST("/batch", logHandlers.InsertBatch)
|
||||
logsGroup.GET("", logHandlers.Search)
|
||||
logsGroup.GET("/services", logHandlers.GetServices)
|
||||
logsGroup.GET("/agents", logHandlers.GetAgents)
|
||||
logsGroup.GET("/levels", logHandlers.GetLevels)
|
||||
}
|
||||
}
|
||||
// Logs (requires view permission)
|
||||
logsGroup := v1.Group("/logs")
|
||||
logsGroup.Use(auth.AuthMiddleware(), handlers.RequireView())
|
||||
{
|
||||
// Mock logs endpoint (always available, no ClickHouse required)
|
||||
mockLogHandlers := handlers.NewLogHandlers(nil)
|
||||
logsGroup.GET("/mock", mockLogHandlers.GetMockLogs)
|
||||
|
||||
// ClickHouse log handlers (always registered, work when ClickHouse connects)
|
||||
logHandlers := handlers.NewLogHandlers(logRepo)
|
||||
logsGroup.POST("", logHandlers.Insert)
|
||||
logsGroup.POST("/batch", logHandlers.InsertBatch)
|
||||
logsGroup.GET("", logHandlers.Search)
|
||||
logsGroup.GET("/services", logHandlers.GetServices)
|
||||
logsGroup.GET("/agents", logHandlers.GetAgents)
|
||||
logsGroup.GET("/levels", logHandlers.GetLevels)
|
||||
}
|
||||
|
||||
// Scripts (requires admin permission)
|
||||
scriptsGroup := v1.Group("/scripts")
|
||||
scriptsGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin())
|
||||
{
|
||||
scriptsGroup.POST("/run", scriptHandlers.RunScript)
|
||||
scriptsGroup.GET("/interpreters", scriptHandlers.ListInterpreters)
|
||||
scriptsGroup.POST("/interpreters", scriptHandlers.CreateInterpreter)
|
||||
scriptsGroup.GET("/interpreters/:id", scriptHandlers.GetInterpreter)
|
||||
scriptsGroup.PUT("/interpreters/:id", scriptHandlers.UpdateInterpreter)
|
||||
scriptsGroup.DELETE("/interpreters/:id", scriptHandlers.DeleteInterpreter)
|
||||
}
|
||||
}
|
||||
|
||||
log.Fatal(router.Run(":8080"))
|
||||
// Start gRPC server with mTLS in background
|
||||
grpcPort := os.Getenv("GRPC_PORT")
|
||||
if grpcPort == "" {
|
||||
grpcPort = "9001"
|
||||
}
|
||||
|
||||
certDir := os.Getenv("SSL_CERT_DIR")
|
||||
if certDir == "" {
|
||||
certDir = "/var/lib/hellreign/ssl"
|
||||
}
|
||||
|
||||
certFile := certDir + "/server.crt"
|
||||
keyFile := certDir + "/server.key"
|
||||
caFile := certDir + "/ca.crt"
|
||||
|
||||
// Load server cert
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load server cert: %v", err)
|
||||
}
|
||||
|
||||
// Load CA cert for client verification
|
||||
caCert, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load CA cert: %v", err)
|
||||
}
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AppendCertsFromPEM(caCert)
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
ClientCAs: caCertPool,
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig)))
|
||||
proto.RegisterCommanderServer(grpcServer, cmdr)
|
||||
proto.RegisterCollectorServer(grpcServer, coll)
|
||||
|
||||
lis, err := net.Listen("tcp", ":"+grpcPort)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to listen on gRPC port %s: %v", grpcPort, err)
|
||||
}
|
||||
|
||||
g, ctx := errgroup.WithContext(context.Background())
|
||||
|
||||
g.Go(func() error {
|
||||
log.Printf("gRPC server starting on port %s with mTLS", grpcPort)
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- grpcServer.Serve(lis) }()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
grpcServer.GracefulStop()
|
||||
return nil
|
||||
}
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
log.Printf("HTTP server starting on :8080")
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- router.Run(":8080") }()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-9
@@ -2,7 +2,9 @@ FROM golang:1.26.1 as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
COPY backend/ backend/
|
||||
COPY proto/ proto/
|
||||
WORKDIR /app/backend
|
||||
ENV CGO_ENABLED=0
|
||||
ENV GIN_MODE=release
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
@@ -12,13 +14,11 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
|
||||
FROM alpine:3.23.0
|
||||
|
||||
RUN apk add --no-cache curl openssl bash
|
||||
RUN apk add --no-cache curl openssl bash ansible
|
||||
|
||||
COPY --from=builder /app/backend .
|
||||
COPY --from=builder /app/scripts /etc/mnemosyne/scripts
|
||||
RUN chmod +x /etc/mnemosyne/scripts/generate-certs.sh
|
||||
COPY --from=builder /app/backend/backend .
|
||||
COPY --from=builder /app/backend/scripts /etc/hellreign/scripts
|
||||
RUN chmod +x /etc/hellreign/scripts/generate-certs.sh
|
||||
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD [ "curl --fail http://localhost:8080/health" ]
|
||||
|
||||
CMD ["./backend"]
|
||||
# Generate certificates on container start
|
||||
ENTRYPOINT ["/bin/sh", "-c", "/etc/hellreign/scripts/generate-certs.sh ${SSL_CERT_DIR:-/var/lib/hellreign/ssl} && exec ./backend"]
|
||||
|
||||
+1491
-3
File diff suppressed because it is too large
Load Diff
+1491
-3
File diff suppressed because it is too large
Load Diff
+979
-3
File diff suppressed because it is too large
Load Diff
+17
-16
@@ -2,29 +2,37 @@ module gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403210401-a6212c89fc0e
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/sync v0.20.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.48.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.5 // indirect
|
||||
github.com/go-openapi/spec v0.22.4 // indirect
|
||||
github.com/go-openapi/swag v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
|
||||
@@ -38,12 +46,10 @@ require (
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
@@ -56,9 +62,6 @@ require (
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
github.com/swaggo/gin-swagger v1.6.1 // indirect
|
||||
github.com/swaggo/swag v1.16.6 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
@@ -66,18 +69,16 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.48.1 // indirect
|
||||
)
|
||||
|
||||
replace gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto => ../proto
|
||||
|
||||
+72
-26
@@ -4,35 +4,27 @@ github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9N28=
|
||||
github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
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/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
@@ -41,20 +33,25 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
|
||||
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
|
||||
github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU=
|
||||
github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
|
||||
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
|
||||
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
|
||||
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
|
||||
@@ -63,6 +60,12 @@ github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzz
|
||||
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
@@ -75,14 +78,20 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
@@ -90,18 +99,17 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -120,6 +128,7 @@ github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
|
||||
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
@@ -127,6 +136,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
@@ -137,12 +148,12 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||
@@ -157,6 +168,8 @@ github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2W
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -164,13 +177,22 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
|
||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@@ -235,24 +257,48 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA=
|
||||
modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package ansible
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Executor handles running Ansible playbooks
|
||||
type Executor struct {
|
||||
workDir string
|
||||
grpcServerHost string
|
||||
grpcServerPort string
|
||||
backendURL string
|
||||
}
|
||||
|
||||
// ExecutorConfig holds configuration for the Executor
|
||||
type ExecutorConfig struct {
|
||||
WorkDir string
|
||||
GRPCServerHost string
|
||||
GRPCServerPort string
|
||||
BackendURL string
|
||||
}
|
||||
|
||||
// NewExecutor creates a new Ansible executor
|
||||
func NewExecutor(cfg ExecutorConfig) *Executor {
|
||||
return &Executor{
|
||||
workDir: cfg.WorkDir,
|
||||
grpcServerHost: cfg.GRPCServerHost,
|
||||
grpcServerPort: cfg.GRPCServerPort,
|
||||
backendURL: cfg.BackendURL,
|
||||
}
|
||||
}
|
||||
|
||||
// DeployResult holds the result of a deployment
|
||||
type DeployResult struct {
|
||||
Host string
|
||||
Success bool
|
||||
Stdout string
|
||||
Stderr string
|
||||
Err error
|
||||
}
|
||||
|
||||
// WorkDir returns the work directory path
|
||||
func (e *Executor) WorkDir() string {
|
||||
return e.workDir
|
||||
}
|
||||
|
||||
// Deploy runs Ansible playbook for the given inventory
|
||||
func (e *Executor) Deploy(ctx context.Context, inventoryPath string, deployType string) ([]DeployResult, error) {
|
||||
playbookName := "binary_deploy.yml"
|
||||
if deployType == "docker" {
|
||||
playbookName = "docker_deploy.yml"
|
||||
}
|
||||
|
||||
playbookPath := filepath.Join(e.workDir, playbookName)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "ansible-playbook",
|
||||
"-i", inventoryPath,
|
||||
"-e", fmt.Sprintf("backend_url=%s", e.backendURL),
|
||||
playbookPath,
|
||||
)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
runErr := cmd.Run()
|
||||
|
||||
// Parse results per host (simplified - returns single result for all)
|
||||
return []DeployResult{
|
||||
{
|
||||
Host: "all",
|
||||
Success: runErr == nil,
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
Err: runErr,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeployParallel runs Ansible playbook for multiple inventories in parallel
|
||||
func (e *Executor) DeployParallel(ctx context.Context, inventoryPaths []string, deployType string) (map[string][]DeployResult, error) {
|
||||
var wg sync.WaitGroup
|
||||
results := make(map[string][]DeployResult)
|
||||
errCh := make(chan error, len(inventoryPaths))
|
||||
|
||||
for _, path := range inventoryPaths {
|
||||
wg.Add(1)
|
||||
go func(p string) {
|
||||
defer wg.Done()
|
||||
res, err := e.Deploy(ctx, p, deployType)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
results[p] = res
|
||||
}(path)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
|
||||
// Collect errors
|
||||
var errs []error
|
||||
for err := range errCh {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return results, fmt.Errorf("some deployments failed: %v", errs)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// WritePlaybook writes a playbook to the work directory
|
||||
func (e *Executor) WritePlaybook(name string, content string) error {
|
||||
path := filepath.Join(e.workDir, name)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(content), 0644)
|
||||
}
|
||||
|
||||
// WriteAllPlaybooks writes all playbooks to the work directory
|
||||
func (e *Executor) WriteAllPlaybooks() error {
|
||||
if err := e.WritePlaybook("binary_deploy.yml", BinaryDeployPlaybook); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.WritePlaybook("docker_deploy.yml", DockerDeployPlaybook)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package ansible
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// InventoryHost represents a single host in the inventory
|
||||
type InventoryHost struct {
|
||||
Name string
|
||||
IP string
|
||||
Port int
|
||||
User string
|
||||
AuthMethod string
|
||||
SSHKey string
|
||||
Password string
|
||||
DeployType string
|
||||
Token string
|
||||
}
|
||||
|
||||
// Inventory represents an Ansible inventory file
|
||||
type Inventory struct {
|
||||
Hosts []InventoryHost
|
||||
}
|
||||
|
||||
const inventoryTemplateText = `{{ range .Hosts }}
|
||||
{{ .Name }} ansible_host={{ .IP }} ansible_port={{ .Port }} ansible_user={{ .User }} ansible_connection=ssh
|
||||
{{ if eq .AuthMethod "key" }}ansible_ssh_private_key_file={{ .SSHKey }}{{ end }}
|
||||
{{ if eq .AuthMethod "password" }}ansible_ssh_pass={{ .Password }}{{ end }}
|
||||
deploy_type={{ .DeployType }}
|
||||
agent_token={{ .Token }}
|
||||
agent_label={{ .Name }}
|
||||
|
||||
{{ end }}`
|
||||
|
||||
// GenerateInventory generates an Ansible inventory file from the given hosts
|
||||
func GenerateInventory(hosts []InventoryHost, outputPath string) error {
|
||||
tmpl, err := template.New("inventory").Parse(inventoryTemplateText)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse inventory template: %w", err)
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(outputPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create inventory directory: %w", err)
|
||||
}
|
||||
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create inventory file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := tmpl.Execute(file, Inventory{Hosts: hosts}); err != nil {
|
||||
return fmt.Errorf("failed to execute inventory template: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package ansible
|
||||
|
||||
// BinaryDeployPlaybook returns the Ansible playbook for binary deployment
|
||||
const BinaryDeployPlaybook = `---
|
||||
- name: Deploy HellreigN Agent (Binary)
|
||||
hosts: all
|
||||
become: yes
|
||||
vars:
|
||||
agent_label: "{{ agent_label }}"
|
||||
agent_token: "{{ agent_token }}"
|
||||
backend_url: "{{ backend_url }}"
|
||||
install_dir: /opt/hellreign
|
||||
bin_name: hellreign-agent
|
||||
service_name: hellreign-agent
|
||||
cert_dir: "{{ install_dir }}/certs"
|
||||
|
||||
tasks:
|
||||
- name: Create installation directory
|
||||
file:
|
||||
path: "{{ install_dir }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Create certificates directory
|
||||
file:
|
||||
path: "{{ cert_dir }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Download HellreigN Agent binary
|
||||
get_url:
|
||||
url: "https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/latest/download/{{ bin_name }}"
|
||||
dest: "{{ install_dir }}/{{ bin_name }}"
|
||||
mode: '0755'
|
||||
|
||||
- name: Create agent configuration
|
||||
copy:
|
||||
content: |
|
||||
backend_url: "{{ backend_url }}"
|
||||
label: "{{ agent_label }}"
|
||||
registration_token: "{{ agent_token }}"
|
||||
cert_dir: "{{ cert_dir }}"
|
||||
dest: "{{ install_dir }}/config.yml"
|
||||
mode: '0644'
|
||||
|
||||
- name: Create systemd service file
|
||||
copy:
|
||||
content: |
|
||||
[Unit]
|
||||
Description=HellreigN Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={{ install_dir }}/{{ bin_name }}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=CONFIG_FILE={{ install_dir }}/config.yml
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
dest: /etc/systemd/system/{{ service_name }}.service
|
||||
mode: '0644'
|
||||
|
||||
- name: Reload systemd daemon
|
||||
systemd:
|
||||
daemon_reload: yes
|
||||
|
||||
- name: Enable and start HellreigN Agent service
|
||||
systemd:
|
||||
name: "{{ service_name }}"
|
||||
enabled: yes
|
||||
state: started
|
||||
`
|
||||
|
||||
// DockerDeployPlaybook returns the Ansible playbook for Docker deployment
|
||||
const DockerDeployPlaybook = `---
|
||||
- name: Deploy HellreigN Agent (Docker)
|
||||
hosts: all
|
||||
become: yes
|
||||
vars:
|
||||
agent_label: "{{ agent_label }}"
|
||||
agent_token: "{{ agent_token }}"
|
||||
backend_url: "{{ backend_url }}"
|
||||
container_name: hellreign-agent-{{ agent_label }}
|
||||
image: "gitea.d3m0k1d.ru/d3m0k1d/hellreign-agent:latest"
|
||||
cert_dir: /etc/hellreign-agent/certs
|
||||
|
||||
tasks:
|
||||
- name: Install Docker (if not present)
|
||||
block:
|
||||
- name: Check if Docker is installed
|
||||
command: docker --version
|
||||
register: docker_check
|
||||
ignore_errors: yes
|
||||
changed_when: false
|
||||
|
||||
- name: Install Docker
|
||||
shell: |
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
when: docker_check.rc != 0
|
||||
|
||||
- name: Create certificates directory
|
||||
file:
|
||||
path: "{{ cert_dir }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Pull HellreigN Agent image
|
||||
community.docker.docker_image:
|
||||
name: "{{ image }}"
|
||||
source: pull
|
||||
|
||||
- name: Create agent configuration
|
||||
copy:
|
||||
content: |
|
||||
backend_url: "{{ backend_url }}"
|
||||
label: "{{ agent_label }}"
|
||||
registration_token: "{{ agent_token }}"
|
||||
cert_dir: "{{ cert_dir }}"
|
||||
dest: "{{ cert_dir }}/config.yml"
|
||||
mode: '0644'
|
||||
|
||||
- name: Create and run HellreigN Agent container
|
||||
community.docker.docker_container:
|
||||
name: "{{ container_name }}"
|
||||
image: "{{ image }}"
|
||||
state: started
|
||||
restart_policy: always
|
||||
volumes:
|
||||
- "{{ cert_dir }}:/etc/hellreign-agent/certs"
|
||||
env:
|
||||
CONFIG_FILE: /etc/hellreign-agent/certs/config.yml
|
||||
`
|
||||
@@ -0,0 +1,5 @@
|
||||
package ansible
|
||||
|
||||
const BaseInvTemplate = `
|
||||
|
||||
`
|
||||
@@ -0,0 +1,180 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type Collector struct {
|
||||
proto.UnimplementedCollectorServer
|
||||
logRepo *repository.LogRepository
|
||||
agents map[string]*Agent
|
||||
mu sync.RWMutex
|
||||
batchSize int
|
||||
flushInterval time.Duration
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
ID string
|
||||
Label string
|
||||
Services []string
|
||||
ConnectedAt time.Time
|
||||
}
|
||||
|
||||
func New(logRepo *repository.LogRepository) *Collector {
|
||||
return &Collector{
|
||||
logRepo: logRepo,
|
||||
agents: make(map[string]*Agent),
|
||||
batchSize: 100,
|
||||
flushInterval: 2 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
|
||||
md, ok := metadata.FromIncomingContext(stream.Context())
|
||||
if !ok {
|
||||
return fmt.Errorf("no metadata in context")
|
||||
}
|
||||
|
||||
whoamiVals := md["whoami"]
|
||||
if len(whoamiVals) == 0 {
|
||||
return fmt.Errorf("whoami metadata missing")
|
||||
}
|
||||
agentName := whoamiVals[0]
|
||||
|
||||
serviceVals := md["service"]
|
||||
if len(serviceVals) == 0 {
|
||||
return fmt.Errorf("service metadata missing")
|
||||
}
|
||||
service := serviceVals[0]
|
||||
|
||||
servicesVals := md["services"]
|
||||
var services []string
|
||||
if len(servicesVals) > 0 {
|
||||
services = servicesVals
|
||||
}
|
||||
|
||||
// Register agent
|
||||
c.mu.Lock()
|
||||
c.agents[agentName] = &Agent{
|
||||
ID: agentName,
|
||||
Label: agentName,
|
||||
Services: services,
|
||||
ConnectedAt: time.Now(),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
delete(c.agents, agentName)
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
log.Printf("Agent %s connected, streaming logs for service: %s", agentName, service)
|
||||
|
||||
// If no ClickHouse, just consume the stream without storing
|
||||
if !c.logRepo.IsConnected() {
|
||||
log.Printf("Warning: ClickHouse not connected yet, consuming logs without storing for agent %s", agentName)
|
||||
for {
|
||||
_, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to receive: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channels for communication with recv goroutine
|
||||
recvCh := make(chan *proto.CollectorRequest, 1)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
// Goroutine that blocks on Recv
|
||||
go func() {
|
||||
for {
|
||||
req, err := stream.Recv()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
recvCh <- req
|
||||
}
|
||||
}()
|
||||
|
||||
// Buffer for batch inserts
|
||||
var batch []storage.LogEntry
|
||||
ticker := time.NewTicker(c.flushInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
flush := func() error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := c.logRepo.InsertBatch(stream.Context(), batch); err != nil {
|
||||
log.Printf("Failed to insert batch for agent %s, service %s: %v", agentName, service, err)
|
||||
return err
|
||||
}
|
||||
log.Printf("Flushed %d logs for agent %s, service %s", len(batch), agentName, service)
|
||||
batch = batch[:0]
|
||||
return nil
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stream.Context().Done():
|
||||
// Context cancelled, flush remaining
|
||||
_ = flush()
|
||||
return stream.Context().Err()
|
||||
case <-ticker.C:
|
||||
if err := flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
case req := <-recvCh:
|
||||
batch = append(batch, storage.LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Level: "info",
|
||||
Service: service,
|
||||
Agent: agentName,
|
||||
Message: req.Message,
|
||||
})
|
||||
|
||||
if len(batch) >= c.batchSize {
|
||||
if err := flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case err := <-errCh:
|
||||
if err == io.EOF {
|
||||
// Client closed stream
|
||||
return flush()
|
||||
}
|
||||
return fmt.Errorf("failed to receive: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) GetAgent(name string) (*Agent, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
a, ok := c.agents[name]
|
||||
return a, ok
|
||||
}
|
||||
|
||||
func (c *Collector) Agents() []*Agent {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
result := make([]*Agent, 0, len(c.agents))
|
||||
for _, a := range c.agents {
|
||||
result = append(result, a)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package commander
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type Commander struct {
|
||||
proto.UnimplementedCommanderServer
|
||||
agents map[string]Agent
|
||||
mu sync.RWMutex
|
||||
jobber Jobber
|
||||
}
|
||||
|
||||
type Jobber interface {
|
||||
InitJob(ctx context.Context, agentID string, job models.JobForInsert) (int64, error)
|
||||
UpdateJobInDB(ctx context.Context, jid int64, msg models.JobForUpdate) (models.Job, error)
|
||||
}
|
||||
|
||||
func New(jobber Jobber) *Commander {
|
||||
return &Commander{
|
||||
agents: make(map[string]Agent),
|
||||
jobber: jobber,
|
||||
}
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command]
|
||||
in chan *proto.Command
|
||||
jobs map[int64]Job
|
||||
jobber Jobber
|
||||
ctx context.Context
|
||||
aid string
|
||||
|
||||
Token string // agent id
|
||||
Label string
|
||||
Services []string
|
||||
}
|
||||
type JobOut struct {
|
||||
fc models.Job
|
||||
err error
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
out chan JobOut
|
||||
}
|
||||
|
||||
func (self *Commander) GetAgent(aid string) (agent Agent, ok bool) {
|
||||
self.mu.RLock()
|
||||
defer self.mu.RUnlock()
|
||||
agent, ok = self.agents[aid]
|
||||
return
|
||||
}
|
||||
|
||||
func (self *Commander) Agents() []Agent {
|
||||
self.mu.RLock()
|
||||
defer self.mu.RUnlock()
|
||||
result := make([]Agent, 0, len(self.agents))
|
||||
for _, a := range self.agents {
|
||||
result = append(result, a)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (self *Commander) removeAgent(aid string) {
|
||||
self.mu.Lock()
|
||||
defer self.mu.Unlock()
|
||||
delete(self.agents, aid)
|
||||
}
|
||||
|
||||
func (self *Agent) AddJob(job models.JobForInsert) (int64, error) {
|
||||
log.Printf("[DEBUG] AddJob: agent=%s, command=%v", self.aid, job.Command)
|
||||
jid, err := self.jobber.InitJob(self.ctx, self.aid, job)
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] AddJob: InitJob failed: %v", err)
|
||||
return 0, err
|
||||
}
|
||||
log.Printf("[DEBUG] AddJob: InitJob returned jid=%d, sending to self.in channel", jid)
|
||||
self.in <- &proto.Command{
|
||||
Id: jid,
|
||||
Command: job.Command,
|
||||
Stdin: job.Stdin,
|
||||
}
|
||||
log.Printf("[DEBUG] AddJob: sent to self.in channel successfully")
|
||||
return jid, err
|
||||
}
|
||||
|
||||
func (self *Agent) WaitJob(jid int64) (*models.Job, error) {
|
||||
log.Printf("[DEBUG] WaitJob: agent=%s, jid=%d, waiting on self.jobs[%d].out", self.aid, jid, jid)
|
||||
result := <-self.jobs[jid].out
|
||||
log.Printf("[DEBUG] WaitJob: agent=%s, jid=%d, received result", self.aid, jid)
|
||||
return &result.fc, result.err
|
||||
}
|
||||
|
||||
func (self *Commander) Stream(bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command]) error {
|
||||
md, ok := metadata.FromIncomingContext(bidi.Context())
|
||||
if !ok {
|
||||
return fmt.Errorf("no metadata in context")
|
||||
}
|
||||
aidVals := md["agentid"]
|
||||
if len(aidVals) == 0 {
|
||||
return fmt.Errorf("agentid metadata missing")
|
||||
}
|
||||
aid := aidVals[0]
|
||||
|
||||
var label string
|
||||
labelVals := md["label"]
|
||||
if len(labelVals) > 0 {
|
||||
label = labelVals[0]
|
||||
}
|
||||
|
||||
agent := newAgent(bidi, self.jobber, aid, label)
|
||||
self.mu.Lock()
|
||||
self.agents[aid] = agent
|
||||
self.mu.Unlock()
|
||||
|
||||
defer self.removeAgent(aid)
|
||||
return agent.run()
|
||||
}
|
||||
|
||||
func (self *Agent) run() error {
|
||||
wg := new(errgroup.Group)
|
||||
wg.Go(self.recv)
|
||||
wg.Go(self.send)
|
||||
return wg.Wait()
|
||||
}
|
||||
|
||||
func (self *Agent) recv() error {
|
||||
for {
|
||||
job, err := func() (job models.Job, err error) {
|
||||
msg, err := self.bidi.Recv()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("[DEBUG] recv: agent=%s, received finished job id=%d", self.aid, msg.Id)
|
||||
return self.jobber.UpdateJobInDB(self.ctx, msg.Id, models.JobForUpdate{
|
||||
Stdout: msg.Stdout,
|
||||
Stderr: msg.Stderr,
|
||||
Status: msg.Status,
|
||||
})
|
||||
}()
|
||||
if err == io.EOF {
|
||||
log.Printf("[DEBUG] recv: agent=%s, EOF received", self.aid)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] recv: agent=%s, error: %v", self.aid, err)
|
||||
}
|
||||
out := self.jobs[job.ID].out
|
||||
out <- JobOut{
|
||||
fc: job,
|
||||
err: err,
|
||||
}
|
||||
close(out)
|
||||
log.Printf("[DEBUG] recv: agent=%s, sent result for job id=%d", self.aid, job.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Agent) send() error {
|
||||
for job := range self.in {
|
||||
log.Printf("[DEBUG] send: agent=%s, job id=%d, command=%v", self.aid, job.Id, job.Command)
|
||||
self.jobs[job.Id] = newJob()
|
||||
if err := self.bidi.Send(job); err != nil {
|
||||
log.Printf("[DEBUG] send: agent=%s, failed to send job id=%d: %v", self.aid, job.Id, err)
|
||||
return err
|
||||
}
|
||||
log.Printf("[DEBUG] send: agent=%s, sent job id=%d to agent", self.aid, job.Id)
|
||||
}
|
||||
log.Printf("[DEBUG] send: agent=%s, self.in channel closed", self.aid)
|
||||
return io.EOF
|
||||
// self.jobs[]
|
||||
}
|
||||
|
||||
func newAgent(bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command], jobber Jobber, aid string, label string) Agent {
|
||||
return Agent{
|
||||
bidi: bidi,
|
||||
in: make(chan *proto.Command),
|
||||
jobs: make(map[int64]Job),
|
||||
jobber: jobber,
|
||||
ctx: bidi.Context(),
|
||||
aid: aid,
|
||||
Label: label,
|
||||
Token: aid,
|
||||
}
|
||||
}
|
||||
|
||||
func newJob() Job {
|
||||
return Job{make(chan JobOut, 1)}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/ansible"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AgentDeployGroup struct {
|
||||
*Handlers
|
||||
executor *ansible.Executor
|
||||
}
|
||||
|
||||
func NewAgentDeployGroup(h *Handlers) *AgentDeployGroup {
|
||||
workDir := os.Getenv("ANSIBLE_WORK_DIR")
|
||||
if workDir == "" {
|
||||
workDir = "/tmp/hellreign/ansible"
|
||||
}
|
||||
|
||||
grpcPort := os.Getenv("GRPC_PORT")
|
||||
if grpcPort == "" {
|
||||
grpcPort = "9001"
|
||||
}
|
||||
|
||||
backendURL := os.Getenv("BACKEND_URL")
|
||||
if backendURL == "" {
|
||||
backendURL = "http://localhost:8080"
|
||||
}
|
||||
|
||||
exec := ansible.NewExecutor(ansible.ExecutorConfig{
|
||||
WorkDir: workDir,
|
||||
GRPCServerHost: "0.0.0.0", // TODO: make configurable
|
||||
GRPCServerPort: grpcPort,
|
||||
BackendURL: backendURL,
|
||||
})
|
||||
|
||||
// Write playbooks on init
|
||||
if err := exec.WriteAllPlaybooks(); err != nil {
|
||||
// Log but don't fail - playbooks can be written later
|
||||
_ = err
|
||||
}
|
||||
|
||||
return &AgentDeployGroup{
|
||||
Handlers: h,
|
||||
executor: exec,
|
||||
}
|
||||
}
|
||||
|
||||
// DeployAgents deploys agents to multiple servers
|
||||
// @Summary Deploy agents to multiple servers via Ansible
|
||||
// @Description Deploy HellreigN agents to multiple servers using Ansible playbooks. Supports Docker and Binary deployment types.
|
||||
// @Tags agents
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body repository.DeployAgentsRequest true "Deployment configuration for servers"
|
||||
// @Success 200 {object} repository.DeployResponse "Deployment results with tokens for each server"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Security Bearer
|
||||
// @Router /agents/deploy [post]
|
||||
func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
|
||||
var req repository.DeployAgentsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Create work directory
|
||||
workDir := adg.executor.WorkDir()
|
||||
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create work directory"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate registration tokens for each server
|
||||
results := make([]repository.DeployResult, 0, len(req.Servers))
|
||||
timestamp := time.Now().UnixMilli()
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
for i, server := range req.Servers {
|
||||
// Create registration token
|
||||
token, err := adg.Repo.CreateRegistrationToken(server.AgentLabel)
|
||||
if err != nil {
|
||||
results = append(results, repository.DeployResult{
|
||||
IP: server.IP,
|
||||
AgentLabel: server.AgentLabel,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create token: %v", err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Set default port
|
||||
port := server.Port
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
|
||||
// Generate inventory for this single server
|
||||
inventoryHosts := []ansible.InventoryHost{
|
||||
{
|
||||
Name: server.AgentLabel,
|
||||
IP: server.IP,
|
||||
Port: port,
|
||||
User: server.User,
|
||||
AuthMethod: string(server.AuthMethod),
|
||||
SSHKey: server.SSHKey,
|
||||
Password: server.Password,
|
||||
DeployType: string(server.DeployType),
|
||||
Token: token,
|
||||
},
|
||||
}
|
||||
|
||||
inventoryPath := filepath.Join(workDir, fmt.Sprintf("inventory_%d_%d", timestamp, i))
|
||||
if err := ansible.GenerateInventory(inventoryHosts, inventoryPath); err != nil {
|
||||
results = append(results, repository.DeployResult{
|
||||
IP: server.IP,
|
||||
AgentLabel: server.AgentLabel,
|
||||
Token: token,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to generate inventory: %v", err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Run Ansible playbook for this server
|
||||
deployResults, err := adg.executor.Deploy(ctx, inventoryPath, string(server.DeployType))
|
||||
|
||||
// Clean up inventory file
|
||||
os.Remove(inventoryPath)
|
||||
|
||||
if err != nil {
|
||||
results = append(results, repository.DeployResult{
|
||||
IP: server.IP,
|
||||
AgentLabel: server.AgentLabel,
|
||||
Token: token,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("deployment failed: %v", err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
success := true
|
||||
errMsg := ""
|
||||
if len(deployResults) > 0 && !deployResults[0].Success {
|
||||
success = false
|
||||
errMsg = deployResults[0].Stderr
|
||||
}
|
||||
|
||||
results = append(results, repository.DeployResult{
|
||||
IP: server.IP,
|
||||
AgentLabel: server.AgentLabel,
|
||||
Token: token,
|
||||
Success: success,
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, repository.DeployResponse{
|
||||
Message: "Deployment completed",
|
||||
Results: results,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AgentRegistrationGroup struct {
|
||||
*Handlers
|
||||
certBundle *utils.CertBundle
|
||||
}
|
||||
|
||||
func NewAgentRegistrationGroup(h *Handlers) *AgentRegistrationGroup {
|
||||
certDir := getCertDir()
|
||||
bundle, err := utils.LoadCertBundle(certDir)
|
||||
if err != nil {
|
||||
log.Printf("[agent-reg] WARNING: cert bundle load failed: %v", err)
|
||||
}
|
||||
return &AgentRegistrationGroup{
|
||||
Handlers: h,
|
||||
certBundle: bundle,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRegistrationToken — админ создаёт токен для агента
|
||||
// @Summary Create registration token
|
||||
// @Tags agents
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body repository.RegistrationRequest true "Label"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Security Bearer
|
||||
// @Router /agents/register-token [post]
|
||||
func (arg *AgentRegistrationGroup) CreateRegistrationToken(c *gin.Context) {
|
||||
var req repository.RegistrationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := arg.Repo.CreateRegistrationToken(req.Label)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||
}
|
||||
|
||||
// Register — агент шлёт CSR + token, получает сертификаты
|
||||
// @Summary Register agent
|
||||
// @Tags agents
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body RegisterRequest true "CSR + token"
|
||||
// @Success 200 {object} RegisterResponse
|
||||
// @Router /agents/register [post]
|
||||
func (arg *AgentRegistrationGroup) Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if arg.certBundle == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "certificate bundle not available"})
|
||||
return
|
||||
}
|
||||
|
||||
regToken, err := arg.Repo.GetRegistrationToken(req.Token)
|
||||
if err != nil {
|
||||
if err == repository.ErrNotFound {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid registration token"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify token"})
|
||||
return
|
||||
}
|
||||
|
||||
if regToken.Used {
|
||||
c.JSON(http.StatusGone, gin.H{"error": "registration token already used"})
|
||||
return
|
||||
}
|
||||
|
||||
clientCertPEM, err := arg.certBundle.SignCSR([]byte(req.CSR), regToken.Label)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to sign CSR: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := arg.Repo.MarkRegistrationTokenUsed(req.Token); err != nil {
|
||||
log.Printf("[agent-reg] WARNING: failed to mark token used: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, RegisterResponse{
|
||||
CACert: string(arg.certBundle.GetCACertPEM()),
|
||||
ClientCert: string(clientCertPEM),
|
||||
})
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
CSR string `json:"csr" binding:"required"`
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
|
||||
type RegisterResponse struct {
|
||||
CACert string `json:"ca_cert"`
|
||||
ClientCert string `json:"client_cert"`
|
||||
}
|
||||
|
||||
func getCertDir() string {
|
||||
if d := os.Getenv("SSL_CERT_DIR"); d != "" {
|
||||
return d
|
||||
}
|
||||
return "/var/lib/hellreign/ssl"
|
||||
}
|
||||
@@ -1,26 +1,45 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/collector"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type AgentsGroup struct {
|
||||
*Handlers
|
||||
collector *collector.Collector
|
||||
}
|
||||
|
||||
func NewAgentsGroup(h *Handlers, coll *collector.Collector) AgentsGroup {
|
||||
return AgentsGroup{Handlers: h, collector: coll}
|
||||
}
|
||||
|
||||
type AgentInfo struct {
|
||||
Token string `json:"token"`
|
||||
Label string `json:"label"`
|
||||
Services []string `json:"services"`
|
||||
Token string `json:"token"`
|
||||
Label string `json:"label"`
|
||||
Services []string `json:"services"`
|
||||
ConnectedAt string `json:"connected_at"`
|
||||
}
|
||||
|
||||
// @Summary Get connected agents
|
||||
// @Description Returns a list of all agents currently connected via gRPC streaming
|
||||
// @Description Returns a list of all agents currently connected via Collector (log streaming)
|
||||
// @Tags agents
|
||||
// @Security Bearer
|
||||
// @Produce json
|
||||
// @Success 200 {array} AgentInfo
|
||||
// @Router /agents [get]
|
||||
func (ag *AgentsGroup) List(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Agents list"})
|
||||
agents := make([]AgentInfo, 0)
|
||||
|
||||
for _, agent := range ag.collector.Agents() {
|
||||
agents = append(agents, AgentInfo{
|
||||
Token: agent.ID,
|
||||
Label: agent.Label,
|
||||
Services: agent.Services,
|
||||
ConnectedAt: agent.ConnectedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, agents)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthGroup handles authentication routes.
|
||||
type AuthGroup struct {
|
||||
*Handlers
|
||||
}
|
||||
|
||||
// Login authenticates a user by login and password, returns a token.
|
||||
// @Summary Login
|
||||
// @Description Authenticate with login and password, returns a token and permissions
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Param request body repository.LoginRequest true "Login credentials"
|
||||
// @Success 200 {object} repository.LoginResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Router /auth/login [post]
|
||||
func (ag *AuthGroup) Login(c *gin.Context) {
|
||||
var req repository.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := ag.Repo.Login(req.Login, req.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, repository.ErrAccountInactive) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "account is not activated by admin"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to authenticate"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// CreateToken creates a new user.
|
||||
// @Summary Create user
|
||||
// @Description Creates a new user with permissions
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Param request body repository.TokenCreate true "User data"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /auth/token [post]
|
||||
func (ag *AuthGroup) CreateToken(c *gin.Context) {
|
||||
var tc repository.TokenCreate
|
||||
if err := c.ShouldBindJSON(&tc); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := ag.Repo.CreateToken(tc); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "user created"})
|
||||
}
|
||||
|
||||
// ValidateToken validates the current Bearer token and returns user info.
|
||||
// @Summary Validate token
|
||||
// @Description Check if the provided Bearer token is valid and return its permissions
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} repository.Tokens
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /auth/validate [get]
|
||||
func (ag *AuthGroup) ValidateToken(c *gin.Context) {
|
||||
tokenVal, exists := c.Get(string(tokenContextKey))
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
token, ok := tokenVal.(*repository.Tokens)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token context"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, token)
|
||||
}
|
||||
|
||||
// ListTokens returns all users.
|
||||
// @Summary List users
|
||||
// @Description Returns list of all users with their permissions
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {array} repository.Tokens
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /auth/tokens [get]
|
||||
func (ag *AuthGroup) ListTokens(c *gin.Context) {
|
||||
tokens, err := ag.Repo.ListTokens()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
// DeleteToken deletes a user by login from URL path.
|
||||
// @Summary Delete user
|
||||
// @Description Deletes a user by their login
|
||||
// @Tags auth
|
||||
// @Param login path string true "Login of the user to delete"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /auth/tokens/:login [delete]
|
||||
func (ag *AuthGroup) DeleteToken(c *gin.Context) {
|
||||
login := c.Param("login")
|
||||
if login == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := ag.Repo.DeleteTokenByLogin(login); err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
|
||||
}
|
||||
|
||||
// DeleteMyToken deletes the current user's account.
|
||||
// @Summary Delete my account
|
||||
// @Description Deletes the current authenticated user
|
||||
// @Tags auth
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /auth/token [delete]
|
||||
func (ag *AuthGroup) DeleteMyToken(c *gin.Context) {
|
||||
tokenVal, exists := c.Get(string(tokenContextKey))
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
token, ok := tokenVal.(*repository.Tokens)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token context"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := ag.Repo.DeleteToken(token.Token); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete account"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "account deleted"})
|
||||
}
|
||||
|
||||
// ActivateUser activates a user by login.
|
||||
// @Summary Activate user
|
||||
// @Description Activates a user account by login (admin only)
|
||||
// @Tags auth
|
||||
// @Param login path string true "Login of the user to activate"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /auth/users/:login/activate [post]
|
||||
func (ag *AuthGroup) ActivateUser(c *gin.Context) {
|
||||
login := c.Param("login")
|
||||
if login == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := ag.Repo.ActivateUserByLogin(login); err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to activate user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "user activated"})
|
||||
}
|
||||
|
||||
// DeactivateUser deactivates a user by login.
|
||||
// @Summary Deactivate user
|
||||
// @Description Deactivates a user account by login (admin only)
|
||||
// @Tags auth
|
||||
// @Param login path string true "Login of the user to deactivate"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /auth/users/:login/deactivate [post]
|
||||
func (ag *AuthGroup) DeactivateUser(c *gin.Context) {
|
||||
login := c.Param("login")
|
||||
if login == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := ag.Repo.DeactivateUserByLogin(login); err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to deactivate user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "user deactivated"})
|
||||
}
|
||||
|
||||
// ListInactiveUsers returns all users that are not activated.
|
||||
// @Summary List inactive users
|
||||
// @Description Returns list of all users waiting for activation
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {array} repository.Tokens
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /auth/users/inactive [get]
|
||||
func (ag *AuthGroup) ListInactiveUsers(c *gin.Context) {
|
||||
tokens, err := ag.Repo.ListInactiveTokens()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list inactive users"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
// GetUser returns a user by login.
|
||||
// @Summary Get user by login
|
||||
// @Description Returns a user by their login (admin only)
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Param login path string true "Login of the user"
|
||||
// @Success 200 {object} repository.Tokens
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /auth/users/:login [get]
|
||||
func (ag *AuthGroup) GetUser(c *gin.Context) {
|
||||
login := c.Param("login")
|
||||
if login == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := ag.Repo.GetTokenByLogin(login)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
// UpdateUser updates user's name and last name.
|
||||
// @Summary Update user
|
||||
// @Description Updates a user's name and last name (admin only)
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Param login path string true "Login of the user"
|
||||
// @Param request body repository.TokenUpdate true "User data to update"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /auth/users/:login [put]
|
||||
func (ag *AuthGroup) UpdateUser(c *gin.Context) {
|
||||
login := c.Param("login")
|
||||
if login == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||
return
|
||||
}
|
||||
|
||||
var update repository.TokenUpdate
|
||||
if err := c.ShouldBindJSON(&update); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := ag.Repo.UpdateToken(login, update); err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "user updated"})
|
||||
}
|
||||
|
||||
// UpdateUserPermissions updates user's permissions and activation status.
|
||||
// @Summary Update user permissions
|
||||
// @Description Updates a user's permissions and activation status (admin only)
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Param login path string true "Login of the user"
|
||||
// @Param request body repository.TokenUpdatePermissions true "Permissions to update"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /auth/users/:login/permissions [put]
|
||||
func (ag *AuthGroup) UpdateUserPermissions(c *gin.Context) {
|
||||
login := c.Param("login")
|
||||
if login == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||
return
|
||||
}
|
||||
|
||||
var update repository.TokenUpdatePermissions
|
||||
if err := c.ShouldBindJSON(&update); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := ag.Repo.UpdatePermissions(login, update); err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update permissions"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "permissions updated"})
|
||||
}
|
||||
|
||||
// ResetUserPassword resets a user's password.
|
||||
// @Summary Reset user password
|
||||
// @Description Resets a user's password to a new value (admin only)
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Param login path string true "Login of the user"
|
||||
// @Param request body repository.TokenPasswordReset true "New password"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /auth/users/:login/password [put]
|
||||
func (ag *AuthGroup) ResetUserPassword(c *gin.Context) {
|
||||
login := c.Param("login")
|
||||
if login == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req repository.TokenPasswordReset
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := ag.Repo.UpdatePassword(login, req.NewPassword); err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "password reset"})
|
||||
}
|
||||
|
||||
// getTokenFromHeader extracts the Bearer token from the Authorization header.
|
||||
func getTokenFromHeader(c *gin.Context) string {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
||||
return ""
|
||||
}
|
||||
return parts[1]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type JobsHandlers struct {
|
||||
cmder *commander.Commander
|
||||
svc *service.ScriptService
|
||||
}
|
||||
|
||||
func NewJobsHandlers(cmder *commander.Commander, svc *service.ScriptService) JobsHandlers {
|
||||
return JobsHandlers{cmder: cmder, svc: svc}
|
||||
}
|
||||
|
||||
type AddJobIn struct {
|
||||
Command string `json:"command" binding:"required"`
|
||||
InterpreterID int64 `json:"interpreter_id"`
|
||||
Stdin *string `json:"stdin"`
|
||||
AgentID string `json:"agent_id" binding:"required"`
|
||||
}
|
||||
type AddJobOut struct {
|
||||
ID int64 `json:"id"`
|
||||
Command []string `json:"command"`
|
||||
Stdin *string `json:"stdin"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Status int32 `json:"status"`
|
||||
}
|
||||
|
||||
// AddJob creates and executes a job on a target agent.
|
||||
// @Summary Create and run a job on an agent
|
||||
// @Description Sends a command to the specified agent, waits for execution, and returns the result
|
||||
// @Tags jobs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body AddJobIn true "Job request"
|
||||
// @Success 201 {object} AddJobOut
|
||||
// @Router /jobs [post]
|
||||
func (self *JobsHandlers) AddJob(c *gin.Context) {
|
||||
log.Printf("[DEBUG] AddJob handler: request received")
|
||||
err := func() error {
|
||||
var in AddJobIn
|
||||
if err := c.Bind(&in); err != nil {
|
||||
log.Printf("[DEBUG] AddJob handler: bind failed: %v", err)
|
||||
return err
|
||||
}
|
||||
log.Printf("[DEBUG] AddJob handler: agent_id=%s, command=%s, interpreter_id=%d", in.AgentID, in.Command, in.InterpreterID)
|
||||
agent, ok := self.cmder.GetAgent(in.AgentID)
|
||||
if !ok {
|
||||
log.Printf("[DEBUG] AddJob handler: agent %s not found", in.AgentID)
|
||||
c.Status(http.StatusNotFound)
|
||||
return fmt.Errorf("agent not found")
|
||||
}
|
||||
log.Printf("[DEBUG] AddJob handler: agent found, resolving command")
|
||||
|
||||
var command []string
|
||||
if in.InterpreterID == 0 {
|
||||
command = []string{"sh", "-c", in.Command}
|
||||
} else {
|
||||
var err error
|
||||
command, err = self.svc.ResolveCommand(c.Request.Context(), in.InterpreterID, in.Command)
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] AddJob handler: ResolveCommand failed: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] AddJob handler: calling agent.AddJob with command=%v", command)
|
||||
jid, err := agent.AddJob(models.JobForInsert{
|
||||
Command: command,
|
||||
Stdin: in.Stdin,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] AddJob handler: agent.AddJob failed: %v", err)
|
||||
return err
|
||||
}
|
||||
log.Printf("[DEBUG] AddJob handler: agent.AddJob returned jid=%d, calling WaitJob", jid)
|
||||
job, err := agent.WaitJob(jid)
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] AddJob handler: agent.WaitJob failed: %v", err)
|
||||
return err
|
||||
}
|
||||
log.Printf("[DEBUG] AddJob handler: agent.WaitJob returned job id=%d, status=%d", job.ID, job.Status)
|
||||
c.JSON(http.StatusCreated, AddJobOut{
|
||||
ID: job.ID,
|
||||
Command: job.Command,
|
||||
Stdin: job.Stdin,
|
||||
Stdout: job.Stdout,
|
||||
Stderr: job.Stderr,
|
||||
Status: job.Status,
|
||||
})
|
||||
log.Printf("[DEBUG] AddJob handler: response sent")
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ type InsertLogRequest struct {
|
||||
// @Produce json
|
||||
// @Param body body InsertLogRequest true "Log entry"
|
||||
// @Success 201 {object} map[string]string
|
||||
// @Security Bearer
|
||||
// @Router /logs [post]
|
||||
func (lh *LogHandlers) Insert(c *gin.Context) {
|
||||
var req InsertLogRequest
|
||||
@@ -72,6 +73,7 @@ type InsertLogsRequest struct {
|
||||
// @Produce json
|
||||
// @Param body body InsertLogsRequest true "Log entries"
|
||||
// @Success 201 {object} map[string]string
|
||||
// @Security Bearer
|
||||
// @Router /logs/batch [post]
|
||||
func (lh *LogHandlers) InsertBatch(c *gin.Context) {
|
||||
var req InsertLogsRequest
|
||||
@@ -124,6 +126,7 @@ type SearchLogsRequest struct {
|
||||
// @Param limit query int false "Limit results" default(100)
|
||||
// @Param offset query int false "Offset results" default(0)
|
||||
// @Success 200 {array} storage.LogEntry
|
||||
// @Security Bearer
|
||||
// @Router /logs [get]
|
||||
func (lh *LogHandlers) Search(c *gin.Context) {
|
||||
var req SearchLogsRequest
|
||||
@@ -170,6 +173,7 @@ func (lh *LogHandlers) Search(c *gin.Context) {
|
||||
// @Tags logs
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Security Bearer
|
||||
// @Router /logs/services [get]
|
||||
func (lh *LogHandlers) GetServices(c *gin.Context) {
|
||||
services, err := lh.LogRepo.GetDistinctServices(c.Request.Context())
|
||||
@@ -190,6 +194,7 @@ func (lh *LogHandlers) GetServices(c *gin.Context) {
|
||||
// @Tags logs
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Security Bearer
|
||||
// @Router /logs/agents [get]
|
||||
func (lh *LogHandlers) GetAgents(c *gin.Context) {
|
||||
agents, err := lh.LogRepo.GetDistinctAgents(c.Request.Context())
|
||||
@@ -210,6 +215,7 @@ func (lh *LogHandlers) GetAgents(c *gin.Context) {
|
||||
// @Tags logs
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Security Bearer
|
||||
// @Router /logs/levels [get]
|
||||
func (lh *LogHandlers) GetLevels(c *gin.Context) {
|
||||
levels, err := lh.LogRepo.GetDistinctLevels(c.Request.Context())
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetMockLogs returns 100 mock log entries for frontend development
|
||||
// @Summary Get mock logs
|
||||
// @Description Returns 100 mock log entries for frontend development (no ClickHouse required)
|
||||
// @Tags logs
|
||||
// @Produce json
|
||||
// @Param level query string false "Filter by level"
|
||||
// @Param service query string false "Filter by service"
|
||||
// @Param agent query string false "Filter by agent"
|
||||
// @Param limit query int false "Limit results" default(100)
|
||||
// @Param offset query int false "Offset results" default(0)
|
||||
// @Success 200 {array} storage.LogEntry
|
||||
// @Security Bearer
|
||||
// @Router /logs/mock [get]
|
||||
func (lh *LogHandlers) GetMockLogs(c *gin.Context) {
|
||||
levelFilter := c.Query("level")
|
||||
serviceFilter := c.Query("service")
|
||||
agentFilter := c.Query("agent")
|
||||
|
||||
limit := 100
|
||||
offset := 0
|
||||
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
if o := c.Query("offset"); o != "" {
|
||||
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
|
||||
logs := generateMockLogs(100)
|
||||
|
||||
// Apply filters
|
||||
var filtered []storage.LogEntry
|
||||
for _, log := range logs {
|
||||
if levelFilter != "" && log.Level != levelFilter {
|
||||
continue
|
||||
}
|
||||
if serviceFilter != "" && log.Service != serviceFilter {
|
||||
continue
|
||||
}
|
||||
if agentFilter != "" && log.Agent != agentFilter {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, log)
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
end := offset + limit
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
if offset > len(filtered) {
|
||||
filtered = []storage.LogEntry{}
|
||||
} else {
|
||||
filtered = filtered[offset:end]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, filtered)
|
||||
}
|
||||
|
||||
func generateMockLogs(count int) []storage.LogEntry {
|
||||
services := []string{
|
||||
"auth-service",
|
||||
"user-service",
|
||||
"agent-service",
|
||||
"gateway",
|
||||
"scheduler",
|
||||
"notification-service",
|
||||
"metrics-collector",
|
||||
"deployment-service",
|
||||
}
|
||||
|
||||
agents := []string{
|
||||
"agent-prod-01",
|
||||
"agent-prod-02",
|
||||
"agent-staging-01",
|
||||
"agent-dev-01",
|
||||
"agent-dev-02",
|
||||
"agent-monitoring-01",
|
||||
"agent-backup-01",
|
||||
"agent-ci-runner-01",
|
||||
}
|
||||
|
||||
levels := []string{"INFO", "WARNING", "ERROR", "FATAL", "DEBUG"}
|
||||
levelWeights := []int{50, 20, 15, 5, 10} // weighted distribution
|
||||
|
||||
messages := map[string][]string{
|
||||
"INFO": {
|
||||
"Service started successfully",
|
||||
"Health check passed",
|
||||
"Configuration loaded",
|
||||
"Connection established to database",
|
||||
"Cache refreshed successfully",
|
||||
"Request processed in 45ms",
|
||||
"User login successful",
|
||||
"Agent registered successfully",
|
||||
"Deployment completed for 3 servers",
|
||||
"Metrics exported to storage",
|
||||
"Backup completed successfully",
|
||||
"SSL certificate valid for 89 days",
|
||||
"Task scheduled: cleanup-temp-files",
|
||||
"Webhook delivered successfully",
|
||||
"Session created for user admin",
|
||||
},
|
||||
"WARNING": {
|
||||
"High memory usage detected: 85%",
|
||||
"Slow query detected: 2.3s",
|
||||
"Rate limit approaching for client 192.168.1.50",
|
||||
"Certificate expires in 7 days",
|
||||
"Retry attempt 2/3 for request",
|
||||
"Disk usage above threshold: 78%",
|
||||
"Connection pool nearly exhausted: 45/50",
|
||||
"Deprecated API endpoint called: /api/v1/legacy",
|
||||
"Response time exceeded SLA: 1.2s > 1s",
|
||||
"Agent heartbeat delayed by 5s",
|
||||
},
|
||||
"ERROR": {
|
||||
"Failed to connect to database: timeout after 30s",
|
||||
"Authentication failed for user test_user",
|
||||
"Agent deployment failed: SSH connection refused",
|
||||
"Failed to send notification: SMTP server unavailable",
|
||||
"Request failed with status 500",
|
||||
"File not found: /etc/hellreign/config.yml",
|
||||
"Invalid token provided",
|
||||
"Permission denied for user viewer",
|
||||
"Failed to parse configuration: invalid YAML",
|
||||
"Agent unreachable: connection timeout",
|
||||
},
|
||||
"FATAL": {
|
||||
"Out of memory: cannot allocate 512MB",
|
||||
"Database connection lost, all retries exhausted",
|
||||
"Critical: SSL certificate expired",
|
||||
"Unrecoverable error: data corruption detected",
|
||||
"Service crashed: segmentation fault",
|
||||
},
|
||||
"DEBUG": {
|
||||
"Processing request payload: 2.3KB",
|
||||
"Cache hit ratio: 78%",
|
||||
"Executing query: SELECT * FROM logs WHERE...",
|
||||
"HTTP request headers: {Content-Type: application/json}",
|
||||
"Agent status check: 8 agents online",
|
||||
"Memory allocation: 256MB used of 1024MB",
|
||||
"Thread pool size: 12 active, 4 idle",
|
||||
"GC pause: 15ms",
|
||||
},
|
||||
}
|
||||
|
||||
r := rand.New(rand.NewSource(42)) // fixed seed for reproducibility
|
||||
|
||||
var logs []storage.LogEntry
|
||||
now := time.Now()
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
level := weightedRandom(r, levels, levelWeights)
|
||||
service := services[r.Intn(len(services))]
|
||||
agent := agents[r.Intn(len(agents))]
|
||||
msgs := messages[level]
|
||||
message := msgs[r.Intn(len(msgs))]
|
||||
|
||||
// Spread logs over the last 24 hours
|
||||
timestamp := now.Add(-time.Duration(count-i) * time.Minute * 15)
|
||||
|
||||
logs = append(logs, storage.LogEntry{
|
||||
Timestamp: timestamp,
|
||||
Level: level,
|
||||
Service: service,
|
||||
Agent: agent,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
return logs
|
||||
}
|
||||
|
||||
func weightedRandom(r *rand.Rand, items []string, weights []int) string {
|
||||
total := 0
|
||||
for _, w := range weights {
|
||||
total += w
|
||||
}
|
||||
n := r.Intn(total)
|
||||
for i, w := range weights {
|
||||
n -= w
|
||||
if n < 0 {
|
||||
return items[i]
|
||||
}
|
||||
}
|
||||
return items[len(items)-1]
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TokenContextKey is the context key for storing authenticated token info.
|
||||
type TokenContextKey string
|
||||
|
||||
const tokenContextKey TokenContextKey = "token"
|
||||
|
||||
// AuthMiddleware validates that a Bearer token exists and is valid.
|
||||
// It stores the token info in the context for later use.
|
||||
// Returns 401 if token is missing or invalid.
|
||||
func (ag *AuthGroup) AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := getTokenFromHeader(c)
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Look up user by token value
|
||||
tokens, err := ag.Repo.GetToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(string(tokenContextKey), tokens)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequirePermission is a generic permission checker.
|
||||
func RequirePermission(check func(*repository.Tokens) bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenVal, exists := c.Get(string(tokenContextKey))
|
||||
if !exists {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "authentication required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
token, ok := tokenVal.(*repository.Tokens)
|
||||
if !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "invalid token context"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !check(token) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireView requires permission_view.
|
||||
func RequireView() gin.HandlerFunc {
|
||||
return RequirePermission(func(t *repository.Tokens) bool {
|
||||
return t.PermissionView
|
||||
})
|
||||
}
|
||||
|
||||
// RequireManageAgent requires permission_manage_agent.
|
||||
func RequireManageAgent() gin.HandlerFunc {
|
||||
return RequirePermission(func(t *repository.Tokens) bool {
|
||||
return t.PermissionManage
|
||||
})
|
||||
}
|
||||
|
||||
// RequireAdmin requires permission_admin.
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return RequirePermission(func(t *repository.Tokens) bool {
|
||||
return t.PermissionAdmin
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ScriptHandlers struct {
|
||||
svc *service.ScriptService
|
||||
cmder *commander.Commander
|
||||
}
|
||||
|
||||
func NewScriptHandlers(svc *service.ScriptService, cmder *commander.Commander) ScriptHandlers {
|
||||
return ScriptHandlers{svc: svc, cmder: cmder}
|
||||
}
|
||||
|
||||
// RunScript executes a script on a target agent.
|
||||
// @Summary Run a script on an agent
|
||||
// @Description Resolves interpreter argv[] and sends the full command to the agent
|
||||
// @Tags scripts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body RunScriptIn true "Script request"
|
||||
// @Success 201 {object} RunScriptOut
|
||||
// @Router /scripts/run [post]
|
||||
func (self *ScriptHandlers) RunScript(c *gin.Context) {
|
||||
err := func() error {
|
||||
type RunScriptIn struct {
|
||||
AgentID string `json:"agent_id" binding:"required"`
|
||||
InterpreterID int64 `json:"interpreter_id" binding:"required"`
|
||||
ScriptText string `json:"script_text" binding:"required"`
|
||||
Stdin *string `json:"stdin"`
|
||||
}
|
||||
var in RunScriptIn
|
||||
if err := c.Bind(&in); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
command, err := self.svc.ResolveCommand(c.Request.Context(), in.InterpreterID, in.ScriptText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agent, ok := self.cmder.GetAgent(in.AgentID)
|
||||
if !ok {
|
||||
c.Status(http.StatusNotFound)
|
||||
return fmt.Errorf("agent not found")
|
||||
}
|
||||
|
||||
jid, err := agent.AddJob(models.JobForInsert{
|
||||
Command: command,
|
||||
Stdin: in.Stdin,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
job, err := agent.WaitJob(jid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type RunScriptOut struct {
|
||||
ID int64 `json:"id"`
|
||||
Command []string `json:"command"`
|
||||
Stdin *string `json:"stdin"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Status int32 `json:"status"`
|
||||
}
|
||||
c.JSON(http.StatusCreated, RunScriptOut{
|
||||
ID: job.ID,
|
||||
Command: job.Command,
|
||||
Stdin: job.Stdin,
|
||||
Stdout: job.Stdout,
|
||||
Stderr: job.Stderr,
|
||||
Status: job.Status,
|
||||
})
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ListInterpreters returns all registered script interpreters.
|
||||
// @Summary List interpreters
|
||||
// @Description Returns all script interpreters available in the system
|
||||
// @Tags scripts
|
||||
// @Produce json
|
||||
// @Success 200 {array} repository.ScriptInterpreter
|
||||
// @Router /scripts/interpreters [get]
|
||||
func (self *ScriptHandlers) ListInterpreters(c *gin.Context) {
|
||||
interpreters, err := self.svc.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, interpreters)
|
||||
}
|
||||
|
||||
// CreateInterpreter registers a new script interpreter.
|
||||
// @Summary Create interpreter
|
||||
// @Description Registers a new script interpreter with name, label, and argv
|
||||
// @Tags scripts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body repository.ScriptInterpreterCreate true "Interpreter definition"
|
||||
// @Success 201 {object} repository.ScriptInterpreter
|
||||
// @Router /scripts/interpreters [post]
|
||||
func (self *ScriptHandlers) CreateInterpreter(c *gin.Context) {
|
||||
var in repository.ScriptInterpreterCreate
|
||||
if err := c.BindJSON(&in); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
si, err := self.svc.Create(c.Request.Context(), in)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, si)
|
||||
}
|
||||
|
||||
// GetInterpreter returns a single interpreter by ID.
|
||||
// @Summary Get interpreter
|
||||
// @Description Returns a script interpreter by ID
|
||||
// @Tags scripts
|
||||
// @Produce json
|
||||
// @Param id path int true "Interpreter ID"
|
||||
// @Success 200 {object} repository.ScriptInterpreter
|
||||
// @Router /scripts/interpreters/:id [get]
|
||||
func (self *ScriptHandlers) GetInterpreter(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
si, err := self.svc.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, si)
|
||||
}
|
||||
|
||||
// UpdateInterpreter updates an interpreter.
|
||||
// @Summary Update interpreter
|
||||
// @Description Updates fields of a script interpreter
|
||||
// @Tags scripts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Interpreter ID"
|
||||
// @Param body body repository.ScriptInterpreterUpdate true "Interpreter fields"
|
||||
// @Success 200 {object} repository.ScriptInterpreter
|
||||
// @Router /scripts/interpreters/:id [put]
|
||||
func (self *ScriptHandlers) UpdateInterpreter(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var in repository.ScriptInterpreterUpdate
|
||||
if err := c.BindJSON(&in); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
si, err := self.svc.Update(c.Request.Context(), id, in)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, si)
|
||||
}
|
||||
|
||||
// DeleteInterpreter removes an interpreter.
|
||||
// @Summary Delete interpreter
|
||||
// @Description Removes a script interpreter by ID
|
||||
// @Tags scripts
|
||||
// @Param id path int true "Interpreter ID"
|
||||
// @Success 204
|
||||
// @Router /scripts/interpreters/:id [delete]
|
||||
func (self *ScriptHandlers) DeleteInterpreter(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := self.svc.Delete(c.Request.Context(), id); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
type Job struct {
|
||||
ID int64
|
||||
|
||||
JobForInsert
|
||||
JobForUpdate
|
||||
}
|
||||
type JobForInsert struct {
|
||||
Command []string
|
||||
Stdin *string
|
||||
}
|
||||
type JobForUpdate struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Status int32
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||
)
|
||||
|
||||
type JobRepository struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func NewJobRepository(db *sql.DB) *JobRepository {
|
||||
return &JobRepository{DB: db}
|
||||
}
|
||||
|
||||
func (r *JobRepository) Init(ctx context.Context) error {
|
||||
_, err := r.DB.ExecContext(ctx, storage.CreateJobsTable)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *JobRepository) InitJob(ctx context.Context, agentID string, job models.JobForInsert) (int64, error) {
|
||||
commandJSON, err := json.Marshal(job.Command)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal command: %w", err)
|
||||
}
|
||||
|
||||
var stdinVal *string
|
||||
if job.Stdin != nil {
|
||||
stdinVal = job.Stdin
|
||||
}
|
||||
|
||||
result, err := r.DB.ExecContext(ctx,
|
||||
`INSERT INTO jobs (agent_id, command, stdin, stdout, stderr, status) VALUES (?, ?, ?, '', '', 0)`,
|
||||
agentID, string(commandJSON), stdinVal,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func (r *JobRepository) UpdateJobInDB(ctx context.Context, jid int64, msg models.JobForUpdate) (models.Job, error) {
|
||||
result, err := r.DB.ExecContext(ctx,
|
||||
`UPDATE jobs SET stdout = ?, stderr = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
msg.Stdout, msg.Stderr, msg.Status, jid,
|
||||
)
|
||||
if err != nil {
|
||||
return models.Job{}, err
|
||||
}
|
||||
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return models.Job{}, err
|
||||
}
|
||||
if affected == 0 {
|
||||
return models.Job{}, ErrNotFound
|
||||
}
|
||||
|
||||
return r.GetJobByID(ctx, jid)
|
||||
}
|
||||
|
||||
func (r *JobRepository) GetJobByID(ctx context.Context, jid int64) (models.Job, error) {
|
||||
var job models.Job
|
||||
var commandJSON string
|
||||
var stdinVal *string
|
||||
|
||||
err := r.DB.QueryRowContext(ctx,
|
||||
`SELECT id, command, stdin, stdout, stderr, status FROM jobs WHERE id = ?`,
|
||||
jid,
|
||||
).Scan(&job.ID, &commandJSON, &stdinVal, &job.Stdout, &job.Stderr, &job.Status)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return models.Job{}, ErrNotFound
|
||||
}
|
||||
return models.Job{}, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(commandJSON), &job.JobForInsert.Command); err != nil {
|
||||
return models.Job{}, fmt.Errorf("unmarshal command: %w", err)
|
||||
}
|
||||
|
||||
job.JobForInsert.Stdin = stdinVal
|
||||
return job, nil
|
||||
}
|
||||
@@ -2,44 +2,85 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
)
|
||||
|
||||
type LogRepository struct {
|
||||
Conn driver.Conn
|
||||
mu sync.RWMutex
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func NewLogRepository(conn driver.Conn) *LogRepository {
|
||||
return &LogRepository{Conn: conn}
|
||||
func NewLogRepository() *LogRepository {
|
||||
return &LogRepository{}
|
||||
}
|
||||
|
||||
func (r *LogRepository) SetDB(db *sql.DB) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.DB = db
|
||||
}
|
||||
|
||||
func (r *LogRepository) IsConnected() bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.DB != nil
|
||||
}
|
||||
|
||||
func (r *LogRepository) getDB() *sql.DB {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.DB
|
||||
}
|
||||
|
||||
func (r *LogRepository) Init(ctx context.Context) error {
|
||||
return r.Conn.Exec(ctx, storage.CreateLogsTable)
|
||||
db := r.getDB()
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := db.ExecContext(ctx, storage.CreateLogsTable)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *LogRepository) Insert(ctx context.Context, log storage.LogEntry) error {
|
||||
return r.Conn.Exec(ctx, `
|
||||
db := r.getDB()
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO logs (timestamp, level, service, agent, message)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, log.Timestamp, log.Level, log.Service, log.Agent, log.Message)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *LogRepository) InsertBatch(ctx context.Context, logs []storage.LogEntry) error {
|
||||
batch, err := r.Conn.PrepareBatch(ctx, "INSERT INTO logs (timestamp, level, service, agent, message)")
|
||||
if err != nil {
|
||||
return err
|
||||
db := r.getDB()
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
if len(logs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, log := range logs {
|
||||
if err := batch.Append(log.Timestamp, log.Level, log.Service, log.Agent, log.Message); err != nil {
|
||||
return err
|
||||
// Build multi-row INSERT statement
|
||||
query := "INSERT INTO logs (timestamp, level, service, agent, message) VALUES "
|
||||
args := make([]interface{}, 0, len(logs)*5)
|
||||
for i, log := range logs {
|
||||
if i > 0 {
|
||||
query += ", "
|
||||
}
|
||||
query += fmt.Sprintf("($%d, $%d, $%d, $%d, $%d)",
|
||||
i*5+1, i*5+2, i*5+3, i*5+4, i*5+5)
|
||||
args = append(args, log.Timestamp, log.Level, log.Service, log.Agent, log.Message)
|
||||
}
|
||||
|
||||
return batch.Send()
|
||||
_, err := db.ExecContext(ctx, query, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
type LogFilter struct {
|
||||
@@ -53,6 +94,11 @@ type LogFilter struct {
|
||||
}
|
||||
|
||||
func (r *LogRepository) Search(ctx context.Context, filter LogFilter) ([]storage.LogEntry, error) {
|
||||
db := r.getDB()
|
||||
if db == nil {
|
||||
return []storage.LogEntry{}, nil
|
||||
}
|
||||
|
||||
query := "SELECT timestamp, level, service, agent, message FROM logs WHERE 1=1"
|
||||
args := make([]interface{}, 0)
|
||||
argIdx := 1
|
||||
@@ -102,13 +148,13 @@ func (r *LogRepository) Search(ctx context.Context, filter LogFilter) ([]storage
|
||||
args = append(args, filter.Offset)
|
||||
}
|
||||
|
||||
rows, err := r.Conn.Query(ctx, query, args...)
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []storage.LogEntry
|
||||
logs := make([]storage.LogEntry, 0)
|
||||
for rows.Next() {
|
||||
var log storage.LogEntry
|
||||
if err := rows.Scan(&log.Timestamp, &log.Level, &log.Service, &log.Agent, &log.Message); err != nil {
|
||||
@@ -121,13 +167,17 @@ func (r *LogRepository) Search(ctx context.Context, filter LogFilter) ([]storage
|
||||
}
|
||||
|
||||
func (r *LogRepository) GetDistinctServices(ctx context.Context) ([]string, error) {
|
||||
rows, err := r.Conn.Query(ctx, "SELECT DISTINCT service FROM logs ORDER BY service")
|
||||
db := r.getDB()
|
||||
if db == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, "SELECT DISTINCT service FROM logs ORDER BY service")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var services []string
|
||||
services := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var service string
|
||||
if err := rows.Scan(&service); err != nil {
|
||||
@@ -140,13 +190,17 @@ func (r *LogRepository) GetDistinctServices(ctx context.Context) ([]string, erro
|
||||
}
|
||||
|
||||
func (r *LogRepository) GetDistinctAgents(ctx context.Context) ([]string, error) {
|
||||
rows, err := r.Conn.Query(ctx, "SELECT DISTINCT agent FROM logs ORDER BY agent")
|
||||
db := r.getDB()
|
||||
if db == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, "SELECT DISTINCT agent FROM logs ORDER BY agent")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var agents []string
|
||||
agents := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var agent string
|
||||
if err := rows.Scan(&agent); err != nil {
|
||||
@@ -159,13 +213,17 @@ func (r *LogRepository) GetDistinctAgents(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
func (r *LogRepository) GetDistinctLevels(ctx context.Context) ([]string, error) {
|
||||
rows, err := r.Conn.Query(ctx, "SELECT DISTINCT level FROM logs ORDER BY level")
|
||||
db := r.getDB()
|
||||
if db == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, "SELECT DISTINCT level FROM logs ORDER BY level")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var levels []string
|
||||
levels := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var level string
|
||||
if err := rows.Scan(&level); err != nil {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package repository
|
||||
|
||||
// Tokens represents a user record with info and permissions.
|
||||
type Tokens struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
LastName string `json:"last_name"`
|
||||
Login string `json:"login"`
|
||||
Token string `json:"token"`
|
||||
PermissionView bool `json:"permission_view"`
|
||||
PermissionManage bool `json:"permission_manage_agent"`
|
||||
PermissionAdmin bool `json:"permission_admin"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// TokenCreate is the request body for creating a new user.
|
||||
type TokenCreate struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
LastName string `json:"last_name" binding:"required"`
|
||||
Login string `json:"login" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
PermissionView bool `json:"permission_view"`
|
||||
PermissionManage bool `json:"permission_manage_agent"`
|
||||
PermissionAdmin bool `json:"permission_admin"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// TokenUpdate is the request body for updating an existing user.
|
||||
type TokenUpdate struct {
|
||||
Name string `json:"name"`
|
||||
LastName string `json:"last_name"`
|
||||
}
|
||||
|
||||
// TokenUpdatePermissions is the request body for updating user permissions.
|
||||
type TokenUpdatePermissions struct {
|
||||
PermissionView *bool `json:"permission_view"`
|
||||
PermissionManage *bool `json:"permission_manage_agent"`
|
||||
PermissionAdmin *bool `json:"permission_admin"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// TokenPasswordReset is the request body for resetting a user's password.
|
||||
type TokenPasswordReset struct {
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
}
|
||||
|
||||
// BatchActionRequest is the request body for batch activate/deactivate users.
|
||||
type BatchActionRequest struct {
|
||||
Logins []string `json:"logins" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// LoginRequest is the request body for login.
|
||||
type LoginRequest struct {
|
||||
Login string `json:"login" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// LoginResponse is returned after successful login.
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
LastName string `json:"last_name"`
|
||||
Login string `json:"login"`
|
||||
PermissionView bool `json:"permission_view"`
|
||||
PermissionManage bool `json:"permission_manage_agent"`
|
||||
PermissionAdmin bool `json:"permission_admin"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// RegistrationToken represents a one-time agent registration token.
|
||||
type RegistrationToken struct {
|
||||
ID int64 `json:"id"`
|
||||
Token string `json:"token"`
|
||||
Label string `json:"label"`
|
||||
Used bool `json:"used"`
|
||||
CreatedAt *string `json:"created_at"`
|
||||
UsedAt *string `json:"used_at"`
|
||||
}
|
||||
|
||||
// RegistrationRequest is the request body for creating a registration token.
|
||||
type RegistrationRequest struct {
|
||||
Label string `json:"label" binding:"required"`
|
||||
}
|
||||
|
||||
// RegistrationResponse is returned when an agent registers.
|
||||
type RegistrationResponse struct {
|
||||
CACert string `json:"ca_cert"`
|
||||
ClientCert string `json:"client_cert"`
|
||||
}
|
||||
|
||||
// DeployType represents the type of agent deployment
|
||||
// @Description Type of deployment: docker or binary
|
||||
type DeployType string
|
||||
|
||||
const (
|
||||
DeployTypeDocker DeployType = "docker"
|
||||
DeployTypeBinary DeployType = "binary"
|
||||
)
|
||||
|
||||
// AuthMethod represents the SSH authentication method
|
||||
// @Description SSH authentication method: key or password
|
||||
type AuthMethod string
|
||||
|
||||
const (
|
||||
AuthMethodKey AuthMethod = "key"
|
||||
AuthMethodPassword AuthMethod = "password"
|
||||
)
|
||||
|
||||
// AgentDeployConfig represents the configuration for deploying an agent to a server
|
||||
// @Description Configuration for deploying HellreigN agent to a single server
|
||||
type AgentDeployConfig struct {
|
||||
User string `json:"user" binding:"required" example:"admin" description:"SSH username"`
|
||||
IP string `json:"ip" binding:"required" example:"192.168.1.100" description:"Server IP address"`
|
||||
Port int `json:"port" example:"22" description:"SSH port (default: 22)"`
|
||||
AuthMethod AuthMethod `json:"authMethod" binding:"required" example:"key" description:"SSH auth method: key or password"`
|
||||
SSHKey string `json:"sshKey,omitempty" example:"-----BEGIN OPENSSH PRIVATE KEY-----" description:"SSH private key (required if authMethod=key)"`
|
||||
Password string `json:"password,omitempty" example:"secret" description:"SSH password (required if authMethod=password)"`
|
||||
DeployType DeployType `json:"deployType" binding:"required" example:"docker" description:"Deployment type: docker or binary"`
|
||||
AgentLabel string `json:"agentLabel" binding:"required" example:"production-server-1" description:"Unique label for the agent"`
|
||||
}
|
||||
|
||||
// DeployAgentsRequest represents the request body for deploying agents to multiple servers
|
||||
// @Description Request to deploy HellreigN agents to multiple servers
|
||||
type DeployAgentsRequest struct {
|
||||
Servers []AgentDeployConfig `json:"servers" binding:"required,min=1,dive" description:"List of server configurations"`
|
||||
}
|
||||
|
||||
// DeployResponse represents the response after deploying agents
|
||||
// @Description Response containing deployment results and registration tokens
|
||||
type DeployResponse struct {
|
||||
Message string `json:"message" example:"Deployment completed"`
|
||||
Results []DeployResult `json:"results" description:"Deployment results for each server"`
|
||||
}
|
||||
|
||||
// DeployResult represents the result of deploying to a single server
|
||||
// @Description Result of deploying to a single server
|
||||
type DeployResult struct {
|
||||
IP string `json:"ip" example:"192.168.1.100" description:"Server IP address"`
|
||||
AgentLabel string `json:"agent_label" example:"production-server-1" description:"Agent label"`
|
||||
Token string `json:"token" example:"abc123..." description:"Registration token for agent registration"`
|
||||
Success bool `json:"success" example:"true" description:"Whether deployment succeeded"`
|
||||
Error string `json:"error,omitempty" example:"" description:"Error message if deployment failed"`
|
||||
}
|
||||
@@ -1,11 +1,462 @@
|
||||
package repository
|
||||
|
||||
import "database/sql"
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/utils"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Repository wraps a SQLite database connection.
|
||||
type Repository struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
// New creates a new Repository.
|
||||
func New(db *sql.DB) *Repository {
|
||||
return &Repository{DB: db}
|
||||
}
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
var ErrAccountInactive = errors.New("account is not activated")
|
||||
|
||||
// Init creates the tokens table if it does not exist.
|
||||
func (r *Repository) Init() error {
|
||||
_, err := r.DB.Exec(storage.CreateSqlite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Migration: add is_active column if it doesn't exist (SQLite ignores errors for duplicate column)
|
||||
_, _ = r.DB.Exec(storage.AddIsActiveColumn)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateToken inserts a new user record with hashed password and generated token.
|
||||
// New users are created with is_active=false by default.
|
||||
func (r *Repository) CreateToken(tc TokenCreate) (string, error) {
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(tc.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token, err := utils.RandomToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result, err := r.DB.Exec(
|
||||
`INSERT INTO tokens (name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
tc.Name, tc.LastName, tc.Login, string(hashed), token,
|
||||
tc.PermissionView, tc.PermissionManage, tc.PermissionAdmin, tc.IsActive,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strconv.FormatInt(id, 10), nil
|
||||
}
|
||||
|
||||
// Login authenticates by login/password, generates a new token, and returns LoginResponse.
|
||||
func (r *Repository) Login(login, password string) (*LoginResponse, error) {
|
||||
var t Tokens
|
||||
var hashedPassword string
|
||||
|
||||
err := r.DB.QueryRow(
|
||||
`SELECT id, name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin, is_active
|
||||
FROM tokens WHERE login = ?`,
|
||||
login,
|
||||
).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &hashedPassword, &t.Token,
|
||||
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
if !t.IsActive {
|
||||
return nil, ErrAccountInactive
|
||||
}
|
||||
|
||||
// Generate new token on each login
|
||||
newToken, err := utils.RandomToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = r.DB.Exec(`UPDATE tokens SET token = ? WHERE id = ?`, newToken, t.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &LoginResponse{
|
||||
Token: newToken,
|
||||
Name: t.Name,
|
||||
LastName: t.LastName,
|
||||
Login: t.Login,
|
||||
PermissionView: t.PermissionView,
|
||||
PermissionManage: t.PermissionManage,
|
||||
PermissionAdmin: t.PermissionAdmin,
|
||||
IsActive: t.IsActive,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetTokenByToken retrieves a user record by token value.
|
||||
func (r *Repository) GetToken(token string) (*Tokens, error) {
|
||||
var t Tokens
|
||||
err := r.DB.QueryRow(
|
||||
`SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin
|
||||
FROM tokens WHERE token = ?`,
|
||||
token,
|
||||
).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &t.Token,
|
||||
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// ListTokens returns all users without password and token.
|
||||
func (r *Repository) ListTokens() ([]Tokens, error) {
|
||||
rows, err := r.DB.Query(
|
||||
`SELECT id, name, last_name, login, permission_view, permission_manage_agent, permission_admin
|
||||
FROM tokens`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tokens []Tokens
|
||||
for rows.Next() {
|
||||
var t Tokens
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.LastName, &t.Login,
|
||||
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
return tokens, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteToken deletes a user by token value.
|
||||
func (r *Repository) DeleteToken(token string) error {
|
||||
result, err := r.DB.Exec(`DELETE FROM tokens WHERE token = ?`, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTokenByLogin deletes a user by login.
|
||||
func (r *Repository) DeleteTokenByLogin(login string) error {
|
||||
result, err := r.DB.Exec(`DELETE FROM tokens WHERE login = ?`, login)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExistsByLogin checks if a user with given login exists.
|
||||
func (r *Repository) ExistsByLogin(login string) bool {
|
||||
var count int
|
||||
err := r.DB.QueryRow(`SELECT COUNT(*) FROM tokens WHERE login = ?`, login).Scan(&count)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// InitRegistrationTokens creates the registration_tokens table if it does not exist.
|
||||
func (r *Repository) InitRegistrationTokens() error {
|
||||
_, err := r.DB.Exec(storage.CreateRegistrationTokensTable)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateRegistrationToken inserts a new one-time registration token.
|
||||
func (r *Repository) CreateRegistrationToken(label string) (string, error) {
|
||||
token, err := utils.RandomToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err = r.DB.Exec(
|
||||
`INSERT INTO registration_tokens (token, label, used) VALUES (?, ?, 0)`,
|
||||
token, label,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// GetRegistrationToken retrieves a registration token if it exists and is not used.
|
||||
func (r *Repository) GetRegistrationToken(token string) (*RegistrationToken, error) {
|
||||
var rt RegistrationToken
|
||||
err := r.DB.QueryRow(
|
||||
`SELECT id, token, label, used, created_at, used_at FROM registration_tokens WHERE token = ?`,
|
||||
token,
|
||||
).Scan(&rt.ID, &rt.Token, &rt.Label, &rt.Used, &rt.CreatedAt, &rt.UsedAt)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &rt, nil
|
||||
}
|
||||
|
||||
// MarkRegistrationTokenUsed marks a registration token as used.
|
||||
func (r *Repository) MarkRegistrationTokenUsed(token string) error {
|
||||
result, err := r.DB.Exec(
|
||||
`UPDATE registration_tokens SET used = 1, used_at = CURRENT_TIMESTAMP WHERE token = ? AND used = 0`,
|
||||
token,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActivateToken activates a user by token value.
|
||||
func (r *Repository) ActivateToken(token string) error {
|
||||
result, err := r.DB.Exec(
|
||||
`UPDATE tokens SET is_active = 1 WHERE token = ?`,
|
||||
token,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeactivateToken deactivates a user by token value.
|
||||
func (r *Repository) DeactivateToken(token string) error {
|
||||
result, err := r.DB.Exec(
|
||||
`UPDATE tokens SET is_active = 0 WHERE token = ?`,
|
||||
token,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActivateUserByLogin activates a user by login.
|
||||
func (r *Repository) ActivateUserByLogin(login string) error {
|
||||
result, err := r.DB.Exec(
|
||||
`UPDATE tokens SET is_active = 1 WHERE login = ?`,
|
||||
login,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeactivateUserByLogin deactivates a user by login.
|
||||
func (r *Repository) DeactivateUserByLogin(login string) error {
|
||||
result, err := r.DB.Exec(
|
||||
`UPDATE tokens SET is_active = 0 WHERE login = ?`,
|
||||
login,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListInactiveTokens returns all users that are not activated.
|
||||
func (r *Repository) ListInactiveTokens() ([]Tokens, error) {
|
||||
rows, err := r.DB.Query(
|
||||
`SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin, is_active
|
||||
FROM tokens WHERE is_active = 0`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tokens []Tokens
|
||||
for rows.Next() {
|
||||
var t Tokens
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &t.Token,
|
||||
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
return tokens, rows.Err()
|
||||
}
|
||||
|
||||
// GetTokenByLogin retrieves a user by login.
|
||||
func (r *Repository) GetTokenByLogin(login string) (*Tokens, error) {
|
||||
var t Tokens
|
||||
err := r.DB.QueryRow(
|
||||
`SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin, is_active
|
||||
FROM tokens WHERE login = ?`,
|
||||
login,
|
||||
).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &t.Token,
|
||||
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// UpdateToken updates name and last_name for a user by login.
|
||||
func (r *Repository) UpdateToken(login string, update TokenUpdate) error {
|
||||
result, err := r.DB.Exec(
|
||||
`UPDATE tokens SET name = ?, last_name = ? WHERE login = ?`,
|
||||
update.Name, update.LastName, login,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePermissions updates permissions and is_active for a user by login.
|
||||
func (r *Repository) UpdatePermissions(login string, update TokenUpdatePermissions) error {
|
||||
user, err := r.GetTokenByLogin(login)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use existing values if not provided
|
||||
newView := user.PermissionView
|
||||
newManage := user.PermissionManage
|
||||
newAdmin := user.PermissionAdmin
|
||||
newActive := user.IsActive
|
||||
|
||||
if update.PermissionView != nil {
|
||||
newView = *update.PermissionView
|
||||
}
|
||||
if update.PermissionManage != nil {
|
||||
newManage = *update.PermissionManage
|
||||
}
|
||||
if update.PermissionAdmin != nil {
|
||||
newAdmin = *update.PermissionAdmin
|
||||
}
|
||||
if update.IsActive != nil {
|
||||
newActive = *update.IsActive
|
||||
}
|
||||
|
||||
result, err := r.DB.Exec(
|
||||
`UPDATE tokens SET permission_view = ?, permission_manage_agent = ?, permission_admin = ?, is_active = ? WHERE login = ?`,
|
||||
newView, newManage, newAdmin, newActive, login,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePassword updates the password for a user by login.
|
||||
func (r *Repository) UpdatePassword(login string, newPassword string) error {
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := r.DB.Exec(
|
||||
`UPDATE tokens SET password = ? WHERE login = ?`,
|
||||
string(hashed), login,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||
)
|
||||
|
||||
type ScriptInterpreter struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Argv []string `json:"argv"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ScriptInterpreterCreate struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Label string `json:"label" binding:"required"`
|
||||
Argv []string `json:"argv" binding:"required"`
|
||||
}
|
||||
|
||||
type ScriptInterpreterUpdate struct {
|
||||
Name *string `json:"name"`
|
||||
Label *string `json:"label"`
|
||||
Argv []string `json:"argv"`
|
||||
}
|
||||
|
||||
type ScriptInterpreterRepo struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func NewScriptInterpreterRepo(db *sql.DB) *ScriptInterpreterRepo {
|
||||
return &ScriptInterpreterRepo{DB: db}
|
||||
}
|
||||
|
||||
func (r *ScriptInterpreterRepo) Init(ctx context.Context) error {
|
||||
_, err := r.DB.ExecContext(ctx, storage.CreateScriptInterpretersTable)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ScriptInterpreterRepo) Create(ctx context.Context, in ScriptInterpreterCreate) (*ScriptInterpreter, error) {
|
||||
argvJSON, err := json.Marshal(in.Argv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := r.DB.ExecContext(ctx,
|
||||
`INSERT INTO script_interpreters (name, label, argv) VALUES (?, ?, ?)`,
|
||||
in.Name, in.Label, string(argvJSON),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (r *ScriptInterpreterRepo) GetByID(ctx context.Context, id int64) (*ScriptInterpreter, error) {
|
||||
var si ScriptInterpreter
|
||||
var argvJSON string
|
||||
var createdAt, updatedAt string
|
||||
|
||||
err := r.DB.QueryRowContext(ctx,
|
||||
`SELECT id, name, label, argv, created_at, updated_at FROM script_interpreters WHERE id = ?`,
|
||||
id,
|
||||
).Scan(&si.ID, &si.Name, &si.Label, &argvJSON, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(argvJSON), &si.Argv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
si.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
si.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
return &si, nil
|
||||
}
|
||||
|
||||
func (r *ScriptInterpreterRepo) List(ctx context.Context) ([]ScriptInterpreter, error) {
|
||||
rows, err := r.DB.QueryContext(ctx,
|
||||
`SELECT id, name, label, argv, created_at, updated_at FROM script_interpreters`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var interpreters []ScriptInterpreter
|
||||
for rows.Next() {
|
||||
var si ScriptInterpreter
|
||||
var argvJSON, createdAt, updatedAt string
|
||||
if err := rows.Scan(&si.ID, &si.Name, &si.Label, &argvJSON, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argvJSON), &si.Argv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
si.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
si.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
interpreters = append(interpreters, si)
|
||||
}
|
||||
return interpreters, rows.Err()
|
||||
}
|
||||
|
||||
func (r *ScriptInterpreterRepo) Update(ctx context.Context, id int64, in ScriptInterpreterUpdate) (*ScriptInterpreter, error) {
|
||||
si, err := r.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
set := ""
|
||||
args := make([]interface{}, 0)
|
||||
idx := 1
|
||||
|
||||
if in.Name != nil {
|
||||
set += "name = ?"
|
||||
args = append(args, *in.Name)
|
||||
idx++
|
||||
}
|
||||
if in.Label != nil {
|
||||
if idx > 1 {
|
||||
set += ", "
|
||||
}
|
||||
set += "label = ?"
|
||||
args = append(args, *in.Label)
|
||||
idx++
|
||||
}
|
||||
if in.Argv != nil {
|
||||
if idx > 1 {
|
||||
set += ", "
|
||||
}
|
||||
argvJSON, err := json.Marshal(in.Argv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set += "argv = ?"
|
||||
args = append(args, string(argvJSON))
|
||||
idx++
|
||||
}
|
||||
|
||||
if idx == 1 {
|
||||
return si, nil
|
||||
}
|
||||
|
||||
set += ", updated_at = CURRENT_TIMESTAMP"
|
||||
args = append(args, id)
|
||||
|
||||
_, err = r.DB.ExecContext(ctx,
|
||||
`UPDATE script_interpreters SET `+set+` WHERE id = ?`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (r *ScriptInterpreterRepo) Delete(ctx context.Context, id int64) error {
|
||||
result, err := r.DB.ExecContext(ctx,
|
||||
`DELETE FROM script_interpreters WHERE id = ?`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||
)
|
||||
|
||||
type ScriptService struct {
|
||||
repo *repository.ScriptInterpreterRepo
|
||||
}
|
||||
|
||||
func NewScriptService(repo *repository.ScriptInterpreterRepo) *ScriptService {
|
||||
return &ScriptService{repo: repo}
|
||||
}
|
||||
|
||||
// ResolveCommand builds the full argv[] by prepending the interpreter's argv
|
||||
// to the script text (as the last argument).
|
||||
func (self *ScriptService) ResolveCommand(ctx context.Context, interpreterID int64, scriptText string) ([]string, error) {
|
||||
interpreter, err := self.repo.GetByID(ctx, interpreterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(interpreter.Argv) == 0 {
|
||||
return nil, fmt.Errorf("interpreter %q has empty argv", interpreter.Name)
|
||||
}
|
||||
|
||||
argv := make([]string, len(interpreter.Argv)+1)
|
||||
copy(argv, interpreter.Argv)
|
||||
argv[len(argv)-1] = scriptText
|
||||
return argv, nil
|
||||
}
|
||||
|
||||
func (self *ScriptService) Create(ctx context.Context, in repository.ScriptInterpreterCreate) (*repository.ScriptInterpreter, error) {
|
||||
return self.repo.Create(ctx, in)
|
||||
}
|
||||
|
||||
func (self *ScriptService) GetByID(ctx context.Context, id int64) (*repository.ScriptInterpreter, error) {
|
||||
return self.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (self *ScriptService) List(ctx context.Context) ([]repository.ScriptInterpreter, error) {
|
||||
return self.repo.List(ctx)
|
||||
}
|
||||
|
||||
func (self *ScriptService) Update(ctx context.Context, id int64, in repository.ScriptInterpreterUpdate) (*repository.ScriptInterpreter, error) {
|
||||
return self.repo.Update(ctx, id, in)
|
||||
}
|
||||
|
||||
func (self *ScriptService) Delete(ctx context.Context, id int64) error {
|
||||
return self.repo.Delete(ctx, id)
|
||||
}
|
||||
@@ -2,10 +2,12 @@ package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
_ "github.com/ClickHouse/clickhouse-go/v2"
|
||||
)
|
||||
|
||||
type ClickHouseConfig struct {
|
||||
@@ -15,33 +17,46 @@ type ClickHouseConfig struct {
|
||||
Database string
|
||||
}
|
||||
|
||||
func OpenClickHouse(cfg ClickHouseConfig) (driver.Conn, error) {
|
||||
conn, err := clickhouse.Open(&clickhouse.Options{
|
||||
Addr: []string{cfg.Host},
|
||||
Auth: clickhouse.Auth{
|
||||
Database: cfg.Database,
|
||||
Username: cfg.User,
|
||||
Password: cfg.Password,
|
||||
},
|
||||
Settings: clickhouse.Settings{
|
||||
"max_execution_time": 60,
|
||||
},
|
||||
Compression: &clickhouse.Compression{
|
||||
Method: clickhouse.CompressionLZ4,
|
||||
},
|
||||
DialTimeout: 30,
|
||||
MaxOpenConns: 10,
|
||||
MaxIdleConns: 5,
|
||||
ConnMaxLifetime: 3600,
|
||||
ConnOpenStrategy: clickhouse.ConnOpenInOrder,
|
||||
})
|
||||
func OpenClickHouse(cfg ClickHouseConfig) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("clickhouse://%s:%s@%s/%s",
|
||||
cfg.User, cfg.Password, cfg.Host, cfg.Database)
|
||||
|
||||
db, err := sql.Open("clickhouse", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clickhouse connect: %w", err)
|
||||
return nil, fmt.Errorf("clickhouse open: %w", err)
|
||||
}
|
||||
|
||||
if err := conn.Ping(context.Background()); err != nil {
|
||||
db.SetMaxOpenConns(5)
|
||||
db.SetMaxIdleConns(2)
|
||||
db.SetConnMaxLifetime(10 * time.Minute)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("clickhouse ping: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
log.Printf("ClickHouse connected via database/sql: %s", cfg.Host)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// OpenClickHouseWithRetry attempts to connect to ClickHouse with retries and backoff.
|
||||
func OpenClickHouseWithRetry(cfg ClickHouseConfig, maxRetries int, initialDelay time.Duration) (*sql.DB, error) {
|
||||
var lastErr error
|
||||
delay := initialDelay
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
db, err := OpenClickHouse(cfg)
|
||||
if err == nil {
|
||||
return db, nil
|
||||
}
|
||||
lastErr = err
|
||||
log.Printf("ClickHouse connection attempt %d/%d failed: %v, retrying in %v...", i+1, maxRetries, err, delay)
|
||||
time.Sleep(delay)
|
||||
delay *= 2
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("clickhouse connection failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,55 @@ const CreateSqlite = `
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
login TEXT NOT NULL,
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
permission_view BOOL NOT NULL,
|
||||
permission_manage_agent BOOL NOT NULL,
|
||||
permission_tokens BOOL NOT NULL
|
||||
permission_admin BOOL NOT NULL,
|
||||
is_active BOOL NOT NULL DEFAULT 0
|
||||
);
|
||||
`
|
||||
|
||||
// AddIsActiveColumn adds is_active column to tokens table if it doesn't exist.
|
||||
// This is a migration for existing databases that don't have this column.
|
||||
const AddIsActiveColumn = `
|
||||
ALTER TABLE tokens ADD COLUMN is_active BOOL NOT NULL DEFAULT 0
|
||||
`
|
||||
|
||||
const CreateRegistrationTokensTable = `
|
||||
CREATE TABLE IF NOT EXISTS registration_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
used BOOL NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at DATETIME
|
||||
);
|
||||
`
|
||||
|
||||
const CreateJobsTable = `
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
stdin TEXT,
|
||||
stdout TEXT DEFAULT '',
|
||||
stderr TEXT DEFAULT '',
|
||||
status INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`
|
||||
|
||||
const CreateScriptInterpretersTable = `
|
||||
CREATE TABLE IF NOT EXISTS script_interpreters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
argv TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`
|
||||
|
||||
|
||||
@@ -30,5 +30,14 @@ func Open(path string) (*sql.DB, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
if _, err := db.Exec(CreateSqlite); err != nil {
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
|
||||
// Migration: add is_active column if it doesn't exist
|
||||
_, _ = db.Exec(AddIsActiveColumn)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CertBundle holds CA and server certificates loaded from disk.
|
||||
type CertBundle struct {
|
||||
CACert *x509.Certificate
|
||||
CAKey *rsa.PrivateKey
|
||||
ServerCert *x509.Certificate
|
||||
ServerKey *rsa.PrivateKey
|
||||
}
|
||||
|
||||
// LoadCertBundle loads CA and server certificates from the given directory.
|
||||
func LoadCertBundle(certDir string) (*CertBundle, error) {
|
||||
caCertPEM, err := os.ReadFile(filepath.Join(certDir, "ca.crt"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ca.crt: %w", err)
|
||||
}
|
||||
|
||||
caKeyPEM, err := os.ReadFile(filepath.Join(certDir, "ca.key"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ca.key: %w", err)
|
||||
}
|
||||
|
||||
serverCertPEM, err := os.ReadFile(filepath.Join(certDir, "server.crt"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read server.crt: %w", err)
|
||||
}
|
||||
|
||||
serverKeyPEM, err := os.ReadFile(filepath.Join(certDir, "server.key"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read server.key: %w", err)
|
||||
}
|
||||
|
||||
caCert := decodeCert(caCertPEM)
|
||||
caKey, err := decodeRSAPrivateKey(caKeyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ca.key: %w", err)
|
||||
}
|
||||
serverCert := decodeCert(serverCertPEM)
|
||||
serverKey, err := decodeRSAPrivateKey(serverKeyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse server.key: %w", err)
|
||||
}
|
||||
|
||||
return &CertBundle{
|
||||
CACert: caCert,
|
||||
CAKey: caKey,
|
||||
ServerCert: serverCert,
|
||||
ServerKey: serverKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SignCSR signs a client CSR with the CA and returns the client certificate PEM.
|
||||
func (b *CertBundle) SignCSR(csrPEM []byte, label string) ([]byte, error) {
|
||||
csr := decodeCSR(csrPEM)
|
||||
|
||||
// Verify CSR signature
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return nil, fmt.Errorf("invalid CSR signature: %w", err)
|
||||
}
|
||||
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate serial: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
CommonName: label,
|
||||
Organization: csr.Subject.Organization,
|
||||
},
|
||||
NotBefore: now,
|
||||
NotAfter: now.Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, b.CACert, csr.PublicKey, b.CAKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create certificate: %w", err)
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: certDER,
|
||||
})
|
||||
|
||||
return certPEM, nil
|
||||
}
|
||||
|
||||
// GetCACertPEM returns the CA certificate as PEM bytes.
|
||||
func (b *CertBundle) GetCACertPEM() []byte {
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: b.CACert.Raw,
|
||||
})
|
||||
}
|
||||
|
||||
func decodeCert(pemData []byte) *x509.Certificate {
|
||||
block, _ := pem.Decode(pemData)
|
||||
if block == nil {
|
||||
return nil
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return cert
|
||||
}
|
||||
|
||||
func decodeRSAPrivateKey(pemData []byte) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode(pemData)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM block found")
|
||||
}
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
// Try PKCS1 fallback
|
||||
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse PKCS1: %w", err)
|
||||
}
|
||||
return key.(*rsa.PrivateKey), nil
|
||||
}
|
||||
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("key is not RSA, got %T", key)
|
||||
}
|
||||
return rsaKey, nil
|
||||
}
|
||||
|
||||
func decodeCSR(pemData []byte) *x509.CertificateRequest {
|
||||
block, _ := pem.Decode(pemData)
|
||||
if block == nil {
|
||||
return nil
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return csr
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package initial
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
# Скрипт генерации SSL сертификатов для mTLS gRPC
|
||||
|
||||
set -e
|
||||
|
||||
CERT_DIR="${1:-/etc/HellreigN/ssl}"
|
||||
DAYS_VALID=365
|
||||
|
||||
echo "Generating CA and server certificates in ${CERT_DIR}..."
|
||||
|
||||
# Создаём директорию
|
||||
mkdir -p "${CERT_DIR}"
|
||||
|
||||
# Если сертификаты уже есть и не пустые - не перегенерируем
|
||||
if [ -s "${CERT_DIR}/ca.crt" ] && [ -s "${CERT_DIR}/server.crt" ] && [ -s "${CERT_DIR}/server.key" ]; then
|
||||
echo "Certificates already exist, skipping generation."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Если файлы существуют но пустые - удаляем их для перегенерации
|
||||
rm -f "${CERT_DIR}/ca.crt" "${CERT_DIR}/ca.key" "${CERT_DIR}/server.crt" "${CERT_DIR}/server.key" "${CERT_DIR}/server.csr"
|
||||
|
||||
# Генерация CA
|
||||
echo "Generating CA..."
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out "${CERT_DIR}/ca.key"
|
||||
openssl req -x509 -new -nodes -sha256 -days ${DAYS_VALID} \
|
||||
-key "${CERT_DIR}/ca.key" \
|
||||
-out "${CERT_DIR}/ca.crt" \
|
||||
-subj "/CN=HellreigN Root CA"
|
||||
|
||||
# Генерация серверного сертификата
|
||||
echo "Generating server certificate..."
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out "${CERT_DIR}/server.key"
|
||||
openssl req -new -sha256 \
|
||||
-key "${CERT_DIR}/server.key" \
|
||||
-out "${CERT_DIR}/server.csr" \
|
||||
-subj "/CN=${SERVER_CN:-localhost}"
|
||||
|
||||
# Создаём конфиг для server SAN
|
||||
# Поддержка переменных окружения:
|
||||
# SERVER_SAN_DNS - список DNS имен через запятую (например: localhost,backend,myserver.example.com)
|
||||
# SERVER_SAN_IP - список IP адресов через запятую (например: 127.0.0.1,192.168.1.100)
|
||||
cat > "${CERT_DIR}/server.ext" <<EOF
|
||||
authorityKeyIdentifier=keyid,issuer
|
||||
basicConstraints=CA:FALSE
|
||||
keyUsage = digitalSignature, keyEncipherment
|
||||
extendedKeyUsage = serverAuth
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
EOF
|
||||
|
||||
# Добавляем DNS SAN
|
||||
dns_idx=1
|
||||
IFS=',' read -ra DNS_NAMES <<< "${SERVER_SAN_DNS:-localhost,backend}"
|
||||
for dns_name in "${DNS_NAMES[@]}"; do
|
||||
dns_name=$(echo "$dns_name" | xargs) # trim whitespace
|
||||
if [ -n "$dns_name" ]; then
|
||||
echo "DNS.${dns_idx} = ${dns_name}" >> "${CERT_DIR}/server.ext"
|
||||
((dns_idx++))
|
||||
fi
|
||||
done
|
||||
|
||||
# Добавляем wildcard для localhost если есть
|
||||
echo "DNS.${dns_idx} = *.localhost" >> "${CERT_DIR}/server.ext"
|
||||
((dns_idx++))
|
||||
|
||||
# Добавляем IP SAN
|
||||
ip_idx=1
|
||||
IFS=',' read -ra IP_ADDRS <<< "${SERVER_SAN_IP:-127.0.0.1}"
|
||||
for ip_addr in "${IP_ADDRS[@]}"; do
|
||||
ip_addr=$(echo "$ip_addr" | xargs) # trim whitespace
|
||||
if [ -n "$ip_addr" ]; then
|
||||
echo "IP.${ip_idx} = ${ip_addr}" >> "${CERT_DIR}/server.ext"
|
||||
((ip_idx++))
|
||||
fi
|
||||
done
|
||||
|
||||
openssl x509 -req -sha256 -days ${DAYS_VALID} \
|
||||
-in "${CERT_DIR}/server.csr" \
|
||||
-CA "${CERT_DIR}/ca.crt" \
|
||||
-CAkey "${CERT_DIR}/ca.key" \
|
||||
-CAcreateserial \
|
||||
-out "${CERT_DIR}/server.crt" \
|
||||
-extfile "${CERT_DIR}/server.ext"
|
||||
|
||||
# Очистка лишних файлов
|
||||
rm -f "${CERT_DIR}/server.ext"
|
||||
|
||||
# Установка прав
|
||||
chmod 600 "${CERT_DIR}"/*.key
|
||||
chmod 644 "${CERT_DIR}"/*.crt
|
||||
|
||||
echo "Certificates generated successfully!"
|
||||
echo " CA: ${CERT_DIR}/ca.crt"
|
||||
echo " Server: ${CERT_DIR}/server.crt + ${CERT_DIR}/server.key"
|
||||
echo " SAN DNS: ${SERVER_SAN_DNS:-localhost,backend}"
|
||||
echo " SAN IP: ${SERVER_SAN_IP:-127.0.0.1}"
|
||||
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.qwen
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(yarn *)",
|
||||
"Bash(npx *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(type *)",
|
||||
"Bash(dir)",
|
||||
"Bash(move *)",
|
||||
"Bash(findstr *)"
|
||||
]
|
||||
},
|
||||
"$version": 3
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "HellreigN",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@uiw/react-codemirror": "^4.25.8",
|
||||
"axios": "^1.13.6",
|
||||
"framer-motion": "^12.38.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.7",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^3.8.0",
|
||||
"tailwind": "^4.0.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,16 @@
|
||||
import "@/shared/styles/index.css";
|
||||
import "primereact/resources/themes/lara-light-cyan/theme.css";
|
||||
import "primereact/resources/primereact.min.css";
|
||||
import "primeicons/primeicons.css";
|
||||
import { PrimeReactProvider } from "primereact/api";
|
||||
import { Routing } from "./providers/routing/routing";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<PrimeReactProvider>
|
||||
<Routing />
|
||||
</PrimeReactProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useAuthStore } from "@/store/auth/auth.store";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/auth" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Suspense } from "react";
|
||||
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
|
||||
import { HomePage } from "@/pages/home.page";
|
||||
import { ThemesPage } from "@/pages/themes.page";
|
||||
import { AuthPage } from "@/pages/auth.page";
|
||||
import { RegisterPage } from "@/pages/register.page";
|
||||
import { AddAgentsPage } from "@/pages/add-agents.page";
|
||||
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
|
||||
|
||||
export const Routing = () => {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
Загрузка...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ReactRoutes>
|
||||
<Route element={<DefaultLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/themes" element={<ThemesPage />} />
|
||||
<Route path="/add-agents" element={<AddAgentsPage />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</ReactRoutes>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import { ThemeInitialProvider } from "./modules/theme-changer";
|
||||
import App from "./app/App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<BrowserRouter>
|
||||
<ThemeInitialProvider>
|
||||
<App />
|
||||
</ThemeInitialProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const DeployType = {
|
||||
Docker: "docker",
|
||||
Binary: "binary",
|
||||
Deploy: "deploy",
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const AuthMethod = {
|
||||
Key: "key",
|
||||
Password: "password",
|
||||
} as const;
|
||||
|
||||
export interface ExtraField {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SSHAgentConfig {
|
||||
user: string;
|
||||
ip: string;
|
||||
authMethod: string;
|
||||
sshKey?: string;
|
||||
password?: string;
|
||||
extraFields: ExtraField[];
|
||||
deployType: string;
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
import React from "react";
|
||||
import {
|
||||
FiServer,
|
||||
FiGlobe,
|
||||
FiKey,
|
||||
FiLock,
|
||||
FiPlus,
|
||||
FiTrash2,
|
||||
FiSettings,
|
||||
} from "react-icons/fi";
|
||||
import { SiDocker } from "react-icons/si";
|
||||
import { FiPackage, FiUploadCloud } from "react-icons/fi";
|
||||
|
||||
type DeployType = "docker" | "binary" | "deploy";
|
||||
type AuthMethod = "key" | "password";
|
||||
|
||||
interface ExtraField {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SSHAgentConfig {
|
||||
user: string;
|
||||
ip: string;
|
||||
authMethod: AuthMethod;
|
||||
sshKey?: string;
|
||||
password?: string;
|
||||
extraFields: ExtraField[];
|
||||
deployType: DeployType;
|
||||
}
|
||||
|
||||
interface SSHAgentFormProps {
|
||||
index: number;
|
||||
config: SSHAgentConfig;
|
||||
onChange: (index: number, config: SSHAgentConfig) => void;
|
||||
onRemove: (index: number) => void;
|
||||
canRemove: boolean;
|
||||
}
|
||||
|
||||
const DEPLOY_OPTIONS: {
|
||||
value: DeployType;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{ value: "docker", label: "Docker", icon: <SiDocker /> },
|
||||
{ value: "binary", label: "Binary", icon: <FiPackage /> },
|
||||
];
|
||||
|
||||
const inputBaseStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "14px",
|
||||
transition: "border-color 0.2s, box-shadow 0.2s",
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "14px",
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
||||
index,
|
||||
config,
|
||||
onChange,
|
||||
onRemove,
|
||||
canRemove,
|
||||
}) => {
|
||||
const handleChange = (field: keyof SSHAgentConfig, value: unknown) => {
|
||||
onChange(index, { ...config, [field]: value });
|
||||
};
|
||||
|
||||
const handleExtraFieldChange = (
|
||||
fieldIndex: number,
|
||||
field: keyof ExtraField,
|
||||
value: string,
|
||||
) => {
|
||||
const newExtraFields = [...config.extraFields];
|
||||
newExtraFields[fieldIndex] = {
|
||||
...newExtraFields[fieldIndex],
|
||||
[field]: value,
|
||||
};
|
||||
handleChange("extraFields", newExtraFields);
|
||||
};
|
||||
|
||||
const addExtraField = () => {
|
||||
handleChange("extraFields", [
|
||||
...config.extraFields,
|
||||
{ key: "", value: "" },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeExtraField = (fieldIndex: number) => {
|
||||
const newExtraFields = config.extraFields.filter(
|
||||
(_, i) => i !== fieldIndex,
|
||||
);
|
||||
handleChange("extraFields", newExtraFields);
|
||||
};
|
||||
|
||||
const handleFocus = (
|
||||
e: React.FocusEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>,
|
||||
) => {
|
||||
e.currentTarget.style.borderColor = "var(--border-focus)";
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px var(--border-focus)30`;
|
||||
};
|
||||
|
||||
const handleBlur = (
|
||||
e: React.FocusEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>,
|
||||
) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl shadow-lg border"
|
||||
style={{
|
||||
backgroundColor: "var(--card-bg)",
|
||||
borderColor: "var(--border)",
|
||||
padding: "24px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "24px",
|
||||
paddingBottom: "16px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: "var(--bg-secondary)" }}
|
||||
>
|
||||
<FiServer style={{ color: "var(--accent)", fontSize: "20px" }} />
|
||||
</div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "18px",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
SSH сервер #{index + 1}
|
||||
</h3>
|
||||
</div>
|
||||
{canRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all"
|
||||
style={{
|
||||
background: "var(--error-bg)",
|
||||
color: "var(--error-text)",
|
||||
border: "1px solid var(--error-border)",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--error-text)";
|
||||
e.currentTarget.style.color = "#fff";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "var(--error-bg)";
|
||||
e.currentTarget.style.color = "var(--error-text)";
|
||||
}}
|
||||
>
|
||||
<FiTrash2 size={14} />
|
||||
Удалить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: "20px" }}>
|
||||
{/* User и IP */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label style={labelStyle}>
|
||||
<span
|
||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||
>
|
||||
<FiServer size={14} />
|
||||
Пользователь *
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.user}
|
||||
onChange={(e) => handleChange("user", e.target.value)}
|
||||
required
|
||||
style={inputBaseStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>
|
||||
<span
|
||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||
>
|
||||
<FiGlobe size={14} />
|
||||
IP адрес *
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.ip}
|
||||
onChange={(e) => handleChange("ip", e.target.value)}
|
||||
required
|
||||
style={inputBaseStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder="192.168.1.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Метод аутентификации */}
|
||||
<div>
|
||||
<label style={labelStyle}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<FiKey size={14} />
|
||||
Метод аутентификации *
|
||||
</span>
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
{(["key", "password"] as const).map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
onClick={() => handleChange("authMethod", method)}
|
||||
className="flex-1 py-2.5 px-4 rounded-lg border transition-all font-medium"
|
||||
style={{
|
||||
backgroundColor:
|
||||
config.authMethod === method
|
||||
? "var(--accent)"
|
||||
: "var(--input-bg)",
|
||||
color:
|
||||
config.authMethod === method
|
||||
? "var(--accent-text)"
|
||||
: "var(--text-primary)",
|
||||
borderColor:
|
||||
config.authMethod === method
|
||||
? "var(--accent)"
|
||||
: "var(--border)",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
{method === "key" ? "SSH ключ" : "Пароль"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSH Key или Password */}
|
||||
{config.authMethod === "key" ? (
|
||||
<div>
|
||||
<label style={labelStyle}>
|
||||
<span
|
||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||
>
|
||||
<FiKey size={14} />
|
||||
SSH ключ *
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={config.sshKey || ""}
|
||||
onChange={(e) => handleChange("sshKey", e.target.value)}
|
||||
required
|
||||
rows={4}
|
||||
style={{
|
||||
...inputBaseStyle,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, monospace",
|
||||
resize: "vertical",
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label style={labelStyle}>
|
||||
<span
|
||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||
>
|
||||
<FiLock size={14} />
|
||||
Пароль *
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={config.password || ""}
|
||||
onChange={(e) => handleChange("password", e.target.value)}
|
||||
required
|
||||
style={inputBaseStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Дополнительные поля */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "12px",
|
||||
}}
|
||||
>
|
||||
<label style={{ ...labelStyle, marginBottom: 0 }}>
|
||||
<span
|
||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||
>
|
||||
<FiSettings size={14} />
|
||||
Дополнительные параметры
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addExtraField}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all"
|
||||
style={{
|
||||
background: "var(--accent)",
|
||||
color: "var(--accent-text)",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = "0.85";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = "1";
|
||||
}}
|
||||
>
|
||||
<FiPlus size={14} />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{config.extraFields.length === 0 && (
|
||||
<div
|
||||
className="text-center py-6 rounded-lg border border-dashed"
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<FiSettings
|
||||
size={20}
|
||||
style={{ margin: "0 auto 8px", opacity: 0.5 }}
|
||||
/>
|
||||
<p style={{ margin: 0, fontSize: "13px" }}>
|
||||
Нет дополнительных параметров
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.extraFields.map((extra, fieldIndex) => (
|
||||
<div
|
||||
key={fieldIndex}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr auto",
|
||||
gap: "8px",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={extra.key}
|
||||
onChange={(e) =>
|
||||
handleExtraFieldChange(fieldIndex, "key", e.target.value)
|
||||
}
|
||||
style={inputBaseStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder="Параметр"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={extra.value}
|
||||
onChange={(e) =>
|
||||
handleExtraFieldChange(fieldIndex, "value", e.target.value)
|
||||
}
|
||||
style={inputBaseStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder="Значение"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeExtraField(fieldIndex)}
|
||||
className="flex items-center justify-center rounded-lg border transition-all"
|
||||
style={{
|
||||
background: "var(--error-bg)",
|
||||
color: "var(--error-text)",
|
||||
borderColor: "var(--error-border)",
|
||||
cursor: "pointer",
|
||||
fontSize: "18px",
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--error-text)";
|
||||
e.currentTarget.style.color = "#fff";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "var(--error-bg)";
|
||||
e.currentTarget.style.color = "var(--error-text)";
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Тип развертывания */}
|
||||
<div>
|
||||
<label style={labelStyle}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<FiUploadCloud size={16} />
|
||||
Тип развертывания *
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
{DEPLOY_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleChange("deployType", option.value)}
|
||||
className="flex items-center justify-center gap-2 py-3 px-4 rounded-lg border transition-all font-medium"
|
||||
style={{
|
||||
backgroundColor:
|
||||
config.deployType === option.value
|
||||
? "var(--accent)"
|
||||
: "var(--input-bg)",
|
||||
color:
|
||||
config.deployType === option.value
|
||||
? "var(--accent-text)"
|
||||
: "var(--text-primary)",
|
||||
borderColor:
|
||||
config.deployType === option.value
|
||||
? "var(--accent)"
|
||||
: "var(--border)",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "18px" }}>{option.icon}</span>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { apiClient } from "@/shared/api/axios.instance";
|
||||
import type {
|
||||
AuthState,
|
||||
LoginCredentials,
|
||||
RegisterData,
|
||||
User,
|
||||
LoginResponse,
|
||||
} from "../types/auth.types";
|
||||
|
||||
const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>(
|
||||
"/auth/login",
|
||||
credentials,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const register = async (data: RegisterData): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>("/auth/register", {
|
||||
login: data.login,
|
||||
password: data.password,
|
||||
name: data.firstName,
|
||||
last_name: data.lastName,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const mapResponseToUser = (response: LoginResponse): User => ({
|
||||
login: response.login,
|
||||
name: response.name,
|
||||
last_name: response.last_name,
|
||||
permission_admin: response.permission_admin,
|
||||
permission_manage_agent: response.permission_manage_agent,
|
||||
permission_view: response.permission_view,
|
||||
});
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
login: async (credentials: LoginCredentials) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await login(credentials);
|
||||
const user = mapResponseToUser(response);
|
||||
set({ user, token: response.token, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Login failed",
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (data: RegisterData) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await register(data);
|
||||
const user = mapResponseToUser(response);
|
||||
set({ user, token: response.token, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error:
|
||||
error instanceof Error ? error.message : "Registration failed",
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
set({ user: null, token: null, error: null });
|
||||
},
|
||||
|
||||
clearError: () => {
|
||||
set({ error: null });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "auth-storage",
|
||||
partialize: (state) => ({ token: state.token, user: state.user }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface LoginCredentials {
|
||||
login: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterData {
|
||||
login: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
name: string;
|
||||
login: string;
|
||||
last_name: string;
|
||||
permission_admin: boolean;
|
||||
permission_manage_agent: boolean;
|
||||
permission_view: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
login: string;
|
||||
name: string;
|
||||
last_name: string;
|
||||
permission_admin: boolean;
|
||||
permission_manage_agent: boolean;
|
||||
permission_view: boolean;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
login: (credentials: LoginCredentials) => Promise<void>;
|
||||
register: (data: RegisterData) => Promise<void>;
|
||||
logout: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Theme } from "@/modules/auth/types/auth.types";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { applyTheme, getSavedTheme, getCurrentTheme } from "@/modules/theme-changer/utils/apply.theme";
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: "dark" as Theme,
|
||||
toggleTheme: () => {
|
||||
const currentTheme = getCurrentTheme();
|
||||
const newThemeType = currentTheme === "dark" || currentTheme === "nightowl" || currentTheme === "sunset" || currentTheme === "forest" || currentTheme === "ocean" || currentTheme === "coffee"
|
||||
? "light"
|
||||
: "dark";
|
||||
// Переключаемся между light и dark базовыми темами
|
||||
const newTheme = newThemeType === "dark" ? "dark" : "light";
|
||||
applyTheme(newTheme);
|
||||
set({ theme: newTheme as Theme });
|
||||
},
|
||||
setTheme: (theme: Theme) => {
|
||||
applyTheme(theme);
|
||||
set({ theme });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "theme-storage",
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { FiSun, FiMoon } from "react-icons/fi";
|
||||
import { getCurrentTheme, toggleDarkLight, getSavedTheme } from "../../theme-changer/utils/apply.theme";
|
||||
import { themes } from "../../theme-changer/config/theme.config";
|
||||
|
||||
export const ThemeToggle: React.FC = () => {
|
||||
const [currentTheme, setCurrentTheme] = useState<string>(() => getSavedTheme());
|
||||
|
||||
const currentThemeData = themes.find((t) => t.id === currentTheme);
|
||||
const isDark = currentThemeData?.type === "dark";
|
||||
|
||||
const handleClick = () => {
|
||||
const newTheme = toggleDarkLight();
|
||||
setCurrentTheme(newTheme);
|
||||
};
|
||||
|
||||
// Инициализация при монтировании
|
||||
useEffect(() => {
|
||||
const saved = getSavedTheme();
|
||||
const current = getCurrentTheme() || saved;
|
||||
setCurrentTheme(current);
|
||||
}, []);
|
||||
|
||||
// Слушаем изменения темы из других компонентов
|
||||
useEffect(() => {
|
||||
const handleThemeChange = (e: Event) => {
|
||||
const event = e as CustomEvent;
|
||||
setCurrentTheme(event.detail.theme);
|
||||
};
|
||||
window.addEventListener("themechange", handleThemeChange);
|
||||
return () => window.removeEventListener("themechange", handleThemeChange);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="p-2 rounded-lg transition-colors duration-200"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "var(--bg-tertiary)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
||||
}}
|
||||
aria-label="Переключить тему"
|
||||
title={isDark ? "Переключить на светлую тему" : "Переключить на тёмную тему"}
|
||||
>
|
||||
{isDark ? (
|
||||
<FiSun className="w-5 h-5" style={{ color: "var(--accent)" }} />
|
||||
) : (
|
||||
<FiMoon className="w-5 h-5" style={{ color: "var(--text-secondary)" }} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
export const themes = [
|
||||
{
|
||||
id: "light",
|
||||
name: "Светлая",
|
||||
description: "Чистая светлая тема",
|
||||
type: "light",
|
||||
colors: {
|
||||
primary: "#4f46e5",
|
||||
background: "#ffffff",
|
||||
surface: "#f8fafc",
|
||||
text: "#1f2937",
|
||||
border: "#e5e7eb",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dark",
|
||||
name: "Темная",
|
||||
description: "Элегантная темная тема",
|
||||
type: "dark",
|
||||
colors: {
|
||||
primary: "#6366f1",
|
||||
background: "#0f172a",
|
||||
surface: "#1e293b",
|
||||
text: "#f1f5f9",
|
||||
border: "#334155",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nightowl",
|
||||
name: "Night Owl",
|
||||
description: "Тема вдохновленная редактором кода",
|
||||
type: "dark",
|
||||
colors: {
|
||||
primary: "#7dd3fc",
|
||||
background: "#011627",
|
||||
surface: "#011e3c",
|
||||
text: "#d6deeb",
|
||||
border: "#1d3b53",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sunset",
|
||||
name: "Закат",
|
||||
description: "Теплые оранжевые тона",
|
||||
type: "dark",
|
||||
colors: {
|
||||
primary: "#f97316",
|
||||
background: "#1c1917",
|
||||
surface: "#292524",
|
||||
text: "#fafaf9",
|
||||
border: "#57534e",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "forest",
|
||||
name: "Лес",
|
||||
description: "Успокаивающая зеленая тема",
|
||||
type: "dark",
|
||||
colors: {
|
||||
primary: "#22c55e",
|
||||
background: "#052e16",
|
||||
surface: "#14532d",
|
||||
text: "#f0fdf4",
|
||||
border: "#166534",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ocean",
|
||||
name: "Океан",
|
||||
description: "Глубокие синие тона",
|
||||
type: "dark",
|
||||
colors: {
|
||||
primary: "#06b6d4",
|
||||
background: "#164e63",
|
||||
surface: "#0e7490",
|
||||
text: "#f0fdfd",
|
||||
border: "#0891b2",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lavender",
|
||||
name: "Лаванда",
|
||||
description: "Нежная фиолетовая тема",
|
||||
type: "light",
|
||||
colors: {
|
||||
primary: "#a855f7",
|
||||
background: "#faf5ff",
|
||||
surface: "#f3e8ff",
|
||||
text: "#581c87",
|
||||
border: "#e9d5ff",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "coffee",
|
||||
name: "Кофе",
|
||||
description: "Уютная коричневая тема",
|
||||
type: "dark",
|
||||
colors: {
|
||||
primary: "#d97706",
|
||||
background: "#292524",
|
||||
surface: "#44403c",
|
||||
text: "#f5f5f4",
|
||||
border: "#57534e",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ThemeInitialProvider } from "./provider/theme.initial.provider";
|
||||
export { ThemeChanger } from "./ui/theme.changer";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { applyTheme, initializeTheme } from "../utils/apply.theme";
|
||||
|
||||
export const ThemeInitialProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
useLayoutEffect(() => {
|
||||
const theme = initializeTheme();
|
||||
applyTheme(theme);
|
||||
}, []);
|
||||
|
||||
return children;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface ITheme {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
colors: {
|
||||
primary: string;
|
||||
background: string;
|
||||
surface: string;
|
||||
text: string;
|
||||
border: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ITheme } from "../../types/theme.type";
|
||||
|
||||
interface IProps {
|
||||
theme: ITheme;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ThemeCard: React.FC<IProps> = ({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { id, name, description } = theme;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(id)}
|
||||
className={`relative p-4 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-large w-full text-left ${
|
||||
isSelected
|
||||
? "border-accent ring-2 ring-accent ring-opacity-50"
|
||||
: "border-primary hover:border-secondary"
|
||||
} bg-secondary`}
|
||||
>
|
||||
<div
|
||||
className={`absolute -top-2 -right-2 w-6 h-6 rounded-full flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? "scale-100 opacity-100 accent-primary"
|
||||
: "scale-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className="h-20 rounded-lg border-2 p-2 space-y-1 bg-primary border-primary"
|
||||
data-theme={id}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded-full accent-primary" />
|
||||
<div className="h-1 flex-1 rounded bg-secondary" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div
|
||||
className="h-1 rounded accent-primary"
|
||||
style={{ width: "70%" }}
|
||||
/>
|
||||
<div
|
||||
className="h-1 rounded bg-secondary"
|
||||
style={{ width: "40%" }}
|
||||
/>
|
||||
<div
|
||||
className="h-1 rounded bg-secondary"
|
||||
style={{ width: "60%" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-sm text-primary">{name}</h3>
|
||||
<p className="text-xs text-secondary">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ITheme } from "../types/theme.type";
|
||||
import { applyTheme } from "../utils/apply.theme";
|
||||
import { ThemeCard } from "./components/theme.card";
|
||||
import { themes as baseThemes } from "../config/theme.config";
|
||||
|
||||
interface IProps {
|
||||
themes?: ITheme[];
|
||||
label: string;
|
||||
currentTheme?: string;
|
||||
setTheme?: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ThemeChanger: React.FC<IProps> = ({
|
||||
themes = baseThemes,
|
||||
label,
|
||||
currentTheme,
|
||||
setTheme,
|
||||
}) => {
|
||||
const onSelectTheme = (theme: string) => {
|
||||
applyTheme(theme);
|
||||
setTheme?.(theme);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<h4 className="text-sm font-medium text-secondary mb-3 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{label}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{themes.map((theme) => (
|
||||
<ThemeCard
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={currentTheme === theme.id}
|
||||
onSelect={onSelectTheme}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { themes } from "../config/theme.config";
|
||||
|
||||
export const applyTheme = (themeId: string) => {
|
||||
const theme = themes.find((t) => t.id === themeId);
|
||||
const root = document.documentElement;
|
||||
|
||||
if (theme) {
|
||||
try {
|
||||
root.setAttribute("data-theme", themeId);
|
||||
localStorage.setItem("theme", themeId);
|
||||
localStorage.setItem("theme-type", theme.type);
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("themechange", {
|
||||
detail: { theme: themeId, type: theme.type },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("❌ Error applying theme:", error);
|
||||
}
|
||||
} else {
|
||||
console.warn(`⚠️ Theme not found: ${themeId}, falling back to light theme`);
|
||||
applyTheme("light");
|
||||
}
|
||||
};
|
||||
|
||||
export const getSavedTheme = () => {
|
||||
try {
|
||||
return localStorage.getItem("theme") || "light";
|
||||
} catch (error) {
|
||||
console.error("Error reading theme from localStorage:", error);
|
||||
return "light";
|
||||
}
|
||||
};
|
||||
|
||||
export const initializeTheme = () => {
|
||||
const savedTheme = getSavedTheme();
|
||||
|
||||
const themeExists = themes.some((t) => t.id === savedTheme);
|
||||
const themeToApply = themeExists ? savedTheme : "light";
|
||||
|
||||
applyTheme(themeToApply);
|
||||
return themeToApply;
|
||||
};
|
||||
|
||||
export const getCurrentTheme = () => {
|
||||
return document.documentElement.getAttribute("data-theme") || "light";
|
||||
};
|
||||
|
||||
export const getCurrentThemeType = () => {
|
||||
const currentTheme = getCurrentTheme();
|
||||
const theme = themes.find((t) => t.id === currentTheme);
|
||||
return theme ? theme.type : "light";
|
||||
};
|
||||
|
||||
export const toggleDarkLight = () => {
|
||||
const currentTheme = getCurrentTheme();
|
||||
const currentThemeData = themes.find((t) => t.id === currentTheme);
|
||||
|
||||
if (currentThemeData) {
|
||||
const oppositeThemes = themes.filter(
|
||||
(t) => t.type !== currentThemeData.type,
|
||||
);
|
||||
if (oppositeThemes.length > 0) {
|
||||
applyTheme(oppositeThemes[0].id);
|
||||
return oppositeThemes[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
const newTheme = currentTheme === "light" ? "dark" : "light";
|
||||
applyTheme(newTheme);
|
||||
return newTheme;
|
||||
};
|
||||
|
||||
export const getNextTheme = () => {
|
||||
const currentTheme = getCurrentTheme();
|
||||
const currentIndex = themes.findIndex((t) => t.id === currentTheme);
|
||||
const nextIndex = (currentIndex + 1) % themes.length;
|
||||
return themes[nextIndex].id;
|
||||
};
|
||||
|
||||
export const applySystemTheme = () => {
|
||||
if (
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
) {
|
||||
applyTheme("dark");
|
||||
} else {
|
||||
applyTheme("light");
|
||||
}
|
||||
};
|
||||
|
||||
export const watchSystemTheme = () => {
|
||||
if (window.matchMedia) {
|
||||
window
|
||||
.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", (e) => {
|
||||
if (e.matches) {
|
||||
applyTheme("dark");
|
||||
} else {
|
||||
applyTheme("light");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { useState } from "react";
|
||||
import { SSHAgentForm } from "../modules/agent/ui/SSHAgentForm";
|
||||
import { FiPlusCircle, FiSend } from "react-icons/fi";
|
||||
|
||||
interface SSHAgentConfig {
|
||||
user: string;
|
||||
ip: string;
|
||||
authMethod: string;
|
||||
sshKey?: string;
|
||||
password?: string;
|
||||
extraFields: { key: string; value: string }[];
|
||||
deployType: string;
|
||||
}
|
||||
|
||||
const createEmptyAgentConfig = (): SSHAgentConfig => ({
|
||||
user: "",
|
||||
ip: "",
|
||||
authMethod: "key",
|
||||
sshKey: "",
|
||||
password: "",
|
||||
extraFields: [],
|
||||
deployType: "docker",
|
||||
});
|
||||
|
||||
export const AddAgentsPage: React.FC = () => {
|
||||
const [agents, setAgents] = useState<SSHAgentConfig[]>([
|
||||
createEmptyAgentConfig(),
|
||||
]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const handleAgentChange = (index: number, config: SSHAgentConfig) => {
|
||||
const newAgents = [...agents];
|
||||
newAgents[index] = config;
|
||||
setAgents(newAgents);
|
||||
};
|
||||
|
||||
const handleAgentRemove = (index: number) => {
|
||||
const newAgents = agents.filter((_, i) => i !== index);
|
||||
setAgents(newAgents);
|
||||
};
|
||||
|
||||
const handleAddAgent = () => {
|
||||
setAgents([...agents, createEmptyAgentConfig()]);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Валидация
|
||||
const isValid = agents.every((agent) => {
|
||||
if (!agent.user || !agent.ip) return false;
|
||||
if (agent.authMethod === "key" && !agent.sshKey) return false;
|
||||
if (agent.authMethod === "password" && !agent.password) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
setSubmitError("Пожалуйста, заполните все обязательные поля");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitMessage(null);
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
// TODO: Реальный API вызов для развертывания агентов
|
||||
console.log("Deploying agents:", agents);
|
||||
|
||||
// Имитация задержки API
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
setSubmitMessage(
|
||||
`Успешно отправлено ${agents.length} сервер(ов) на развертывание`,
|
||||
);
|
||||
setAgents([createEmptyAgentConfig()]);
|
||||
} catch (error) {
|
||||
setSubmitError("Ошибка при развертывании на серверах");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen py-8 px-4"
|
||||
style={{ backgroundColor: "var(--bg-primary)" }}
|
||||
>
|
||||
<div style={{ maxWidth: "900px", margin: "0 auto" }}>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div
|
||||
className="w-14 h-14 rounded-xl flex items-center justify-center"
|
||||
style={{ backgroundColor: "var(--bg-secondary)" }}
|
||||
>
|
||||
<FiSend className="w-7 h-7" style={{ color: "var(--accent)" }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
className="text-3xl font-bold mb-1"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
Развертывание агентов по SSH
|
||||
</h1>
|
||||
<p style={{ color: "var(--text-secondary)", fontSize: "16px" }}>
|
||||
Настройте SSH-серверы и разверните агенты
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Agent Forms */}
|
||||
<div className="space-y-5">
|
||||
{agents.map((agent, index) => (
|
||||
<SSHAgentForm
|
||||
key={index}
|
||||
index={index}
|
||||
config={agent}
|
||||
onChange={handleAgentChange}
|
||||
onRemove={handleAgentRemove}
|
||||
canRemove={agents.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Agent Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddAgent}
|
||||
className="w-full flex items-center justify-center gap-2 py-3.5 px-4 rounded-xl border-2 border-dashed transition-all mb-6 font-medium"
|
||||
style={{
|
||||
borderColor: "var(--border)",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--accent)",
|
||||
fontSize: "15px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--accent)";
|
||||
e.currentTarget.style.backgroundColor = "var(--accent)10";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}}
|
||||
>
|
||||
<FiPlusCircle size={18} />
|
||||
Добавить ещё один сервер
|
||||
</button>
|
||||
|
||||
{/* Messages */}
|
||||
{submitMessage && (
|
||||
<div
|
||||
className="mb-6 p-4 rounded-lg border text-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--success-bg)",
|
||||
borderColor: "var(--success-border)",
|
||||
color: "var(--success-text)",
|
||||
}}
|
||||
>
|
||||
{submitMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitError && (
|
||||
<div
|
||||
className="mb-6 p-4 rounded-lg border text-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--error-bg)",
|
||||
borderColor: "var(--error-border)",
|
||||
color: "var(--error-text)",
|
||||
}}
|
||||
>
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3.5 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed font-medium text-base"
|
||||
style={{
|
||||
backgroundColor: isSubmitting
|
||||
? "var(--bg-secondary)"
|
||||
: "var(--button-primary)",
|
||||
color: "var(--button-primary-text)",
|
||||
boxShadow: isSubmitting
|
||||
? "none"
|
||||
: "0 4px 14px var(--shadow-color)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSubmitting) {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"var(--button-primary-hover)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = isSubmitting
|
||||
? "var(--bg-secondary)"
|
||||
: "var(--button-primary)";
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
Подключение к серверам...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FiSend size={18} />
|
||||
Развернуть на {agents.length} сервер(ах)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { FiUser, FiLock, FiLogIn } from "react-icons/fi";
|
||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||
|
||||
export const AuthPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { login, isLoading, error, clearError, token } = useAuthStore();
|
||||
const [formData, setFormData] = useState({
|
||||
login: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
navigate("/");
|
||||
}
|
||||
}, [token, navigate]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await login(formData);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
// Error is handled by store
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
if (error) clearError();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center p-4"
|
||||
style={{ backgroundColor: "var(--bg-primary)" }}
|
||||
>
|
||||
<div className="w-full max-w-md">
|
||||
{/* Card */}
|
||||
<div
|
||||
className="rounded-2xl shadow-2xl p-8 border"
|
||||
style={{
|
||||
backgroundColor: "var(--card-bg)",
|
||||
borderColor: "var(--border)",
|
||||
boxShadow: "0 20px 60px var(--shadow-color)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div
|
||||
className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: "var(--bg-secondary)" }}
|
||||
>
|
||||
<FiUser className="w-8 h-8" style={{ color: "var(--accent)" }} />
|
||||
</div>
|
||||
<h1
|
||||
className="text-3xl font-bold mb-2"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
С возвращением!
|
||||
</h1>
|
||||
<p style={{ color: "var(--text-secondary)" }}>
|
||||
Войдите в свой аккаунт
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
className="mb-6 p-4 rounded-lg border text-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--error-bg)",
|
||||
borderColor: "var(--error-border)",
|
||||
color: "var(--error-text)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Логин
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FiUser
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="login"
|
||||
value={formData.login}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full pl-10 pr-3 py-2.5 rounded-lg border focus:outline-none focus:ring-2 transition-all"
|
||||
style={{
|
||||
backgroundColor: "var(--input-bg)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border-focus)";
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${e.currentTarget.style.borderColor}20`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
placeholder="Введите ваш логин"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FiLock
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full pl-10 pr-3 py-2.5 rounded-lg border focus:outline-none focus:ring-2 transition-all"
|
||||
style={{
|
||||
backgroundColor: "var(--input-bg)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border-focus)";
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${e.currentTarget.style.borderColor}20`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
placeholder="Введите ваш пароль"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--button-primary)",
|
||||
color: "var(--button-primary-text)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.backgroundColor = "var(--button-primary-hover)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "var(--button-primary)";
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
Вход...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FiLogIn />
|
||||
Войти
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||
Нет аккаунта?{" "}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium hover:underline transition-colors"
|
||||
style={{ color: "var(--link)" }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = "var(--link-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "var(--link)";
|
||||
}}
|
||||
>
|
||||
Зарегистрироваться
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export const HomePage = () => {
|
||||
const [test, setTest] = useState();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Home page</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,358 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { FiUser, FiLock, FiUserPlus } from "react-icons/fi";
|
||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||
|
||||
export const RegisterPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { register, isLoading, error, clearError, token } = useAuthStore();
|
||||
const [formData, setFormData] = useState({
|
||||
login: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
});
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
navigate("/");
|
||||
}
|
||||
}, [token, navigate]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setPasswordError("Пароли не совпадают");
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordError(null);
|
||||
|
||||
try {
|
||||
await register({
|
||||
login: formData.login,
|
||||
password: formData.password,
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
});
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
// Error is handled by store
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
if (error) clearError();
|
||||
if (passwordError) setPasswordError(null);
|
||||
};
|
||||
|
||||
const inputStyles = `
|
||||
w-full pl-10 pr-3 py-2.5 rounded-lg border focus:outline-none focus:ring-2 transition-all
|
||||
`;
|
||||
|
||||
const simpleInputStyles = `
|
||||
w-full px-3 py-2.5 rounded-lg border focus:outline-none focus:ring-2 transition-all
|
||||
`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center p-4"
|
||||
style={{ backgroundColor: "var(--bg-primary)" }}
|
||||
>
|
||||
<div className="w-full max-w-md">
|
||||
{/* Card */}
|
||||
<div
|
||||
className="rounded-2xl shadow-2xl p-8 border"
|
||||
style={{
|
||||
backgroundColor: "var(--card-bg)",
|
||||
borderColor: "var(--border)",
|
||||
boxShadow: "0 20px 60px var(--shadow-color)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div
|
||||
className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: "var(--bg-secondary)" }}
|
||||
>
|
||||
<FiUserPlus className="w-8 h-8" style={{ color: "var(--accent)" }} />
|
||||
</div>
|
||||
<h1
|
||||
className="text-3xl font-bold mb-2"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
Создать аккаунт
|
||||
</h1>
|
||||
<p style={{ color: "var(--text-secondary)" }}>
|
||||
Зарегистрируйтесь, чтобы начать
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
className="mb-6 p-4 rounded-lg border text-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--error-bg)",
|
||||
borderColor: "var(--error-border)",
|
||||
color: "var(--error-text)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name Fields */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Имя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className={simpleInputStyles}
|
||||
style={{
|
||||
backgroundColor: "var(--input-bg)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border-focus)";
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${e.currentTarget.style.borderColor}20`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
placeholder="Иван"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Фамилия
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className={simpleInputStyles}
|
||||
style={{
|
||||
backgroundColor: "var(--input-bg)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border-focus)";
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${e.currentTarget.style.borderColor}20`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
placeholder="Иванов"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Логин
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FiUser
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="login"
|
||||
value={formData.login}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className={inputStyles}
|
||||
style={{
|
||||
backgroundColor: "var(--input-bg)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border-focus)";
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${e.currentTarget.style.borderColor}20`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
placeholder="Придумайте логин"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FiLock
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className={inputStyles}
|
||||
style={{
|
||||
backgroundColor: "var(--input-bg)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border-focus)";
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${e.currentTarget.style.borderColor}20`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
placeholder="Придумайте пароль"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-2"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Подтвердите пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FiLock
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className={inputStyles}
|
||||
style={{
|
||||
backgroundColor: "var(--input-bg)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border-focus)";
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${e.currentTarget.style.borderColor}20`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
placeholder="Повторите пароль"
|
||||
/>
|
||||
</div>
|
||||
{passwordError && (
|
||||
<p
|
||||
className="mt-2 text-sm flex items-center gap-1"
|
||||
style={{ color: "var(--error-text)" }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{passwordError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--button-primary)",
|
||||
color: "var(--button-primary-text)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.backgroundColor = "var(--button-primary-hover)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "var(--button-primary)";
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
Регистрация...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FiUserPlus />
|
||||
Зарегистрироваться
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||
Уже есть аккаунт?{" "}
|
||||
<Link
|
||||
to="/auth"
|
||||
className="font-medium hover:underline transition-colors"
|
||||
style={{ color: "var(--link)" }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = "var(--link-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "var(--link)";
|
||||
}}
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ThemeChanger } from "@/modules/theme-changer";
|
||||
|
||||
export const ThemesPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<ThemeChanger label="Выбор тем приложения" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { apiClient } from "./axios.instance";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
data: T;
|
||||
message: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
async get<T>(url: string, config?: any): Promise<T> {
|
||||
const response: AxiosResponse<T> = await apiClient.get(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async post<T, D = any>(url: string, data?: D, config?: any): Promise<T> {
|
||||
const response: AxiosResponse<T> = await apiClient.post(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async put<T, D = any>(url: string, data?: D, config?: any): Promise<T> {
|
||||
const response: AxiosResponse<T> = await apiClient.put(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async patch<T, D = any>(url: string, data?: D, config?: any): Promise<T> {
|
||||
const response: AxiosResponse<T> = await apiClient.patch(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async delete<T>(url: string, config?: any): Promise<T> {
|
||||
const response: AxiosResponse<T> = await apiClient.delete(url, config);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiService = new ApiService();
|
||||
@@ -0,0 +1,77 @@
|
||||
import axios, {
|
||||
type AxiosInstance,
|
||||
type AxiosResponse,
|
||||
type AxiosError,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
data: T;
|
||||
message?: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private axiosInstance: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: "http://194.113.106.59:8080/api/v1",
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
validateStatus: (status) => {
|
||||
return status >= 200 && status < 500;
|
||||
},
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors(): void {
|
||||
this.axiosInstance.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
|
||||
// Получаем токен из localStorage
|
||||
const authStorage = localStorage.getItem("auth-storage");
|
||||
if (authStorage) {
|
||||
try {
|
||||
const parsed = JSON.parse(authStorage);
|
||||
const token = parsed.state?.token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Auth] Failed to parse auth storage:", e);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error: AxiosError): Promise<AxiosError> => {
|
||||
console.error("[Request Error]", error);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response: AxiosResponse): AxiosResponse => {
|
||||
console.log(`[Response] ${response.status} ${response.config.url}`);
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError): Promise<any> => {
|
||||
if (error.response?.status === 401) {
|
||||
window.location.href = "/auth";
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public getInstance(): AxiosInstance {
|
||||
return this.axiosInstance;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient().getInstance();
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
export function useApi() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const request = useCallback(
|
||||
async <T>(apiCall: () => Promise<T>): Promise<T | undefined> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await apiCall();
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Произошла ошибка";
|
||||
setError(errorMessage);
|
||||
return undefined;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
request,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { apiClient } from "./axios.instance";
|
||||
export { useApi } from "./hooks/use.api";
|
||||
@@ -0,0 +1,136 @@
|
||||
// shared/api/websocket.service.ts
|
||||
import { useAgentStore } from "@/components/layout/sidebar/store/agent.store";
|
||||
import { useWebSocket, type LogMessage } from "@/shared/hooks/useWebSocket";
|
||||
import { useEffect, useRef, useCallback, useMemo } from "react";
|
||||
|
||||
interface WebSocketServiceProps {
|
||||
onLogMessage?: (message: LogMessage) => void;
|
||||
}
|
||||
|
||||
export const useWebSocketService = ({
|
||||
onLogMessage,
|
||||
}: WebSocketServiceProps = {}) => {
|
||||
const { agents } = useAgentStore();
|
||||
const lastFilterRef = useRef<{ hosts: string[]; services: string[] }>({
|
||||
hosts: [],
|
||||
services: [],
|
||||
});
|
||||
|
||||
// Токен для аутентификации
|
||||
const TOKEN =
|
||||
"H0AB91gb7427xswom0xalJHq7Ked0tLt6F0gOyqw5yMWPDrroOcX8CjPXeD8uzsU";
|
||||
|
||||
// Получаем выбранные агенты и сервисы синхронно
|
||||
const getSelectedServices = useCallback(() => {
|
||||
const selectedServices: string[] = [];
|
||||
const selectedHosts: string[] = [];
|
||||
|
||||
agents.forEach((agent) => {
|
||||
agent.services.forEach((service) => {
|
||||
if (service.isSelected) {
|
||||
selectedServices.push(service.name);
|
||||
selectedHosts.push(agent.token);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { hosts: selectedHosts, services: selectedServices };
|
||||
}, [agents]);
|
||||
|
||||
// Формируем URL синхронно
|
||||
const wsUrl = useMemo(() => {
|
||||
const { hosts, services } = getSelectedServices();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (hosts.length === 0 && services.length === 0) {
|
||||
params.append("all", "true");
|
||||
} else {
|
||||
hosts.forEach((host) => {
|
||||
params.append("hosts", host);
|
||||
});
|
||||
services.forEach((service) => {
|
||||
params.append("services", service);
|
||||
});
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${import.meta.env.VITE_WS_URL}/ws?${queryString}`;
|
||||
|
||||
console.log("Generated WebSocket URL:", url);
|
||||
|
||||
return url;
|
||||
}, [getSelectedServices]);
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
isAuthenticated,
|
||||
lastMessage,
|
||||
error,
|
||||
reconnect,
|
||||
connect: wsConnect,
|
||||
disconnect: wsDisconnect,
|
||||
updateFilter,
|
||||
} = useWebSocket({
|
||||
url: wsUrl,
|
||||
token: TOKEN,
|
||||
autoConnect: false, // Отключаем авто-подключение, будем управлять вручную
|
||||
reconnectInterval: 3000,
|
||||
maxReconnectAttempts: 10,
|
||||
});
|
||||
|
||||
// Функция для подключения
|
||||
const connect = useCallback(() => {
|
||||
if (!isManualPausedRef.current) {
|
||||
wsConnect();
|
||||
}
|
||||
}, [wsConnect]);
|
||||
|
||||
// Функция для отключения
|
||||
const disconnect = useCallback(() => {
|
||||
wsDisconnect();
|
||||
}, [wsDisconnect]);
|
||||
|
||||
// Реф для отслеживания ручной паузы
|
||||
const isManualPausedRef = useRef(false);
|
||||
|
||||
// Принудительно переподключаемся при изменении URL, если не на паузе
|
||||
useEffect(() => {
|
||||
if (wsUrl && !isManualPausedRef.current) {
|
||||
console.log("URL changed, reconnecting...");
|
||||
setTimeout(() => {
|
||||
reconnect();
|
||||
}, 100);
|
||||
}
|
||||
}, [wsUrl, reconnect]);
|
||||
|
||||
// Обновляем фильтр при изменении выбранных сервисов
|
||||
useEffect(() => {
|
||||
const { hosts, services } = getSelectedServices();
|
||||
const currentFilter = { hosts, services };
|
||||
|
||||
const hasChanged =
|
||||
JSON.stringify(currentFilter) !== JSON.stringify(lastFilterRef.current);
|
||||
|
||||
if (hasChanged && isConnected && !isManualPausedRef.current) {
|
||||
console.log("Updating filter:", currentFilter);
|
||||
updateFilter(hosts, services);
|
||||
lastFilterRef.current = currentFilter;
|
||||
}
|
||||
}, [agents, isConnected, updateFilter, getSelectedServices]);
|
||||
|
||||
// Передаем сообщения в callback
|
||||
useEffect(() => {
|
||||
if (lastMessage && onLogMessage) {
|
||||
onLogMessage(lastMessage);
|
||||
}
|
||||
}, [lastMessage, onLogMessage]);
|
||||
|
||||
return {
|
||||
isConnected: isConnected && isAuthenticated,
|
||||
isAuthenticated,
|
||||
error,
|
||||
selectedCount: getSelectedServices().services.length,
|
||||
connect,
|
||||
disconnect,
|
||||
};
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,265 @@
|
||||
// shared/hooks/useWebSocket.ts
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
export interface LogMessage {
|
||||
timestamp: string;
|
||||
service: string;
|
||||
level: "log" | "info" | "success" | "warn" | "error";
|
||||
message: string;
|
||||
host: string;
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface WebSocketOptions {
|
||||
url: string;
|
||||
token?: string;
|
||||
autoConnect?: boolean;
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
}
|
||||
|
||||
export const useWebSocket = (options: WebSocketOptions) => {
|
||||
const {
|
||||
url,
|
||||
token,
|
||||
autoConnect = true,
|
||||
reconnectInterval = 3000,
|
||||
maxReconnectAttempts = 5,
|
||||
} = options;
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [lastMessage, setLastMessage] = useState<LogMessage | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const reconnectTimeoutRef = useRef<any>(null);
|
||||
const urlRef = useRef<string>(url);
|
||||
const tokenRef = useRef<string | undefined>(token);
|
||||
const authTimeoutRef = useRef<any>(null);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
if (authTimeoutRef.current) {
|
||||
clearTimeout(authTimeoutRef.current);
|
||||
authTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
const ws = wsRef.current;
|
||||
wsRef.current = null;
|
||||
|
||||
if (
|
||||
ws.readyState === WebSocket.OPEN ||
|
||||
ws.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
ws.close(1000, "Disconnecting");
|
||||
}
|
||||
}
|
||||
|
||||
setIsConnected(false);
|
||||
setIsAuthenticated(false);
|
||||
}, []);
|
||||
|
||||
const sendAuth = useCallback(() => {
|
||||
if (
|
||||
wsRef.current &&
|
||||
wsRef.current.readyState === WebSocket.OPEN &&
|
||||
tokenRef.current
|
||||
) {
|
||||
const authMessage = {
|
||||
type: "auth",
|
||||
payload: {
|
||||
token: tokenRef.current,
|
||||
},
|
||||
};
|
||||
console.log("Sending auth message...");
|
||||
wsRef.current.send(JSON.stringify(authMessage));
|
||||
|
||||
// Set timeout for auth response
|
||||
authTimeoutRef.current = setTimeout(() => {
|
||||
if (!isAuthenticated) {
|
||||
console.error("Auth timeout");
|
||||
setError("Authentication timeout");
|
||||
disconnect();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}, [isAuthenticated, disconnect]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
// Если URL изменился, пересоздаем соединение
|
||||
if (urlRef.current !== url) {
|
||||
console.log("URL changed, forcing new connection");
|
||||
disconnect();
|
||||
urlRef.current = url;
|
||||
tokenRef.current = token;
|
||||
}
|
||||
|
||||
// Если уже есть открытое соединение, не создаем новое
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
console.log("WebSocket already connected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Connecting to WebSocket:", url);
|
||||
|
||||
wsRef.current = new WebSocket(url);
|
||||
|
||||
wsRef.current.onopen = () => {
|
||||
console.log("WebSocket connected, sending auth...");
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
// Send authentication immediately after connection
|
||||
sendAuth();
|
||||
};
|
||||
|
||||
wsRef.current.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log("WebSocket message received:", data);
|
||||
|
||||
// Check if it's an auth response
|
||||
if (data.type === "auth") {
|
||||
if (data.success) {
|
||||
console.log("Authentication successful");
|
||||
setIsAuthenticated(true);
|
||||
setError(null);
|
||||
if (authTimeoutRef.current) {
|
||||
clearTimeout(authTimeoutRef.current);
|
||||
}
|
||||
} else {
|
||||
console.error("Authentication failed:", data.error);
|
||||
setError(data.error || "Authentication failed");
|
||||
setIsAuthenticated(false);
|
||||
disconnect();
|
||||
}
|
||||
} else {
|
||||
// Regular log message
|
||||
setLastMessage(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to parse WebSocket message:", err);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current.onerror = (event) => {
|
||||
console.error("WebSocket error:", event);
|
||||
setError("Connection error");
|
||||
};
|
||||
|
||||
wsRef.current.onclose = (event) => {
|
||||
console.log(
|
||||
"WebSocket disconnected, code:",
|
||||
event.code,
|
||||
"reason:",
|
||||
event.reason,
|
||||
);
|
||||
setIsConnected(false);
|
||||
setIsAuthenticated(false);
|
||||
|
||||
// Если URL изменился, не переподключаемся автоматически
|
||||
if (urlRef.current !== url) {
|
||||
console.log("URL changed, will reconnect manually");
|
||||
return;
|
||||
}
|
||||
|
||||
// Не переподключаемся при нормальном закрытии
|
||||
if (event.code === 1000) {
|
||||
console.log("Normal closure, not reconnecting");
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt reconnection
|
||||
if (
|
||||
reconnectAttemptsRef.current < maxReconnectAttempts &&
|
||||
tokenRef.current
|
||||
) {
|
||||
console.log(`Reconnecting in ${reconnectInterval}ms...`);
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectAttemptsRef.current++;
|
||||
connect();
|
||||
}, reconnectInterval);
|
||||
} else if (reconnectAttemptsRef.current >= maxReconnectAttempts) {
|
||||
setError("Max reconnection attempts reached");
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to connect");
|
||||
}
|
||||
}, [
|
||||
url,
|
||||
token,
|
||||
reconnectInterval,
|
||||
maxReconnectAttempts,
|
||||
disconnect,
|
||||
sendAuth,
|
||||
]);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
console.log("Manual reconnect triggered");
|
||||
disconnect();
|
||||
setTimeout(() => {
|
||||
connect();
|
||||
}, 100);
|
||||
}, [disconnect, connect]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(data: any) => {
|
||||
if (
|
||||
wsRef.current &&
|
||||
wsRef.current.readyState === WebSocket.OPEN &&
|
||||
isAuthenticated
|
||||
) {
|
||||
wsRef.current.send(JSON.stringify(data));
|
||||
return true;
|
||||
} else {
|
||||
console.warn("WebSocket is not authenticated or not connected");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[isAuthenticated],
|
||||
);
|
||||
|
||||
const updateFilter = useCallback(
|
||||
(hosts: string[], services: string[]) => {
|
||||
const message = {
|
||||
type: "filter",
|
||||
payload: { hosts, services },
|
||||
};
|
||||
console.log("Sending filter update:", message);
|
||||
sendMessage(message);
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
// Подключаемся при монтировании и при изменении URL или токена
|
||||
useEffect(() => {
|
||||
if (autoConnect && url && token) {
|
||||
connect();
|
||||
}
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [autoConnect, connect, disconnect, url, token]);
|
||||
|
||||
return {
|
||||
isConnected: isConnected && isAuthenticated,
|
||||
isAuthenticated,
|
||||
lastMessage,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
reconnect,
|
||||
sendMessage,
|
||||
updateFilter,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||
import { ThemeToggle } from "@/modules/theme-bw/ui/ThemeToggle";
|
||||
import React from "react";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
|
||||
interface DefaultLayoutProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DefaultLayout: React.FC<DefaultLayoutProps> = ({ children }) => {
|
||||
const { user, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/auth");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}>
|
||||
{/* Header */}
|
||||
<header
|
||||
className="border-b sticky top-0 z-50"
|
||||
style={{
|
||||
backgroundColor: "var(--header-bg)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex justify-between items-center">
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="text-xl font-bold cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
HellreigN
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
{user && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||
{user.firstName} {user.lastName}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-3 py-1.5 text-sm rounded-lg transition-colors font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--button-danger)",
|
||||
color: "var(--button-danger-text)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "var(--button-danger-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "var(--button-danger)";
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1">{children || <Outlet />}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer
|
||||
className="border-t py-4 mt-auto"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="container mx-auto px-4">
|
||||
<p className="text-center text-sm" style={{ color: "var(--text-muted)" }}>
|
||||
© 2026 HellreigN. Все права защищены.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import "./normalize.css";
|
||||
@import "./root.css";
|
||||
@import "./themes.css";
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
|
||||
|
||||
/* Document
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Correct the line height in all browsers.
|
||||
* 2. Prevent adjustments of font size after orientation changes in iOS.
|
||||
*/
|
||||
|
||||
html {
|
||||
line-height: 1.15;
|
||||
/* 1 */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/* Sections
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the margin in all browsers.
|
||||
*/
|
||||
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `main` element consistently in IE.
|
||||
*/
|
||||
|
||||
main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the font size and margin on `h1` elements within `section` and
|
||||
* `article` contexts in Chrome, Firefox, and Safari.
|
||||
*/
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
/* Grouping content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in Firefox.
|
||||
* 2. Show the overflow in Edge and IE.
|
||||
*/
|
||||
|
||||
hr {
|
||||
box-sizing: content-box;
|
||||
/* 1 */
|
||||
height: 0;
|
||||
/* 1 */
|
||||
overflow: visible;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
pre {
|
||||
font-family: monospace, monospace;
|
||||
/* 1 */
|
||||
font-size: 1em;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/* Text-level semantics
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the gray background on active links in IE 10.
|
||||
*/
|
||||
|
||||
/* a {
|
||||
background-color: transparent;
|
||||
} */
|
||||
|
||||
/**
|
||||
* 1. Remove the bottom border in Chrome 57-
|
||||
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
|
||||
*/
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: none;
|
||||
/* 1 */
|
||||
text-decoration: underline;
|
||||
/* 2 */
|
||||
text-decoration: underline dotted;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font weight in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: monospace, monospace;
|
||||
/* 1 */
|
||||
font-size: 1em;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent `sub` and `sup` elements from affecting the line height in
|
||||
* all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/* Embedded content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the border on images inside links in IE 10.
|
||||
*/
|
||||
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
/* Forms
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Change the font styles in all browsers.
|
||||
* 2. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Show the overflow in IE.
|
||||
* 1. Show the overflow in Edge.
|
||||
*/
|
||||
|
||||
button,
|
||||
input {
|
||||
/* 1 */
|
||||
overflow: visible;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inheritance of text transform in Edge, Firefox, and IE.
|
||||
* 1. Remove the inheritance of text transform in Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select {
|
||||
/* 1 */
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
[type="button"],
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner border and padding in Firefox.
|
||||
*/
|
||||
|
||||
button::-moz-focus-inner,
|
||||
[type="button"]::-moz-focus-inner,
|
||||
[type="reset"]::-moz-focus-inner,
|
||||
[type="submit"]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the focus styles unset by the previous rule.
|
||||
*/
|
||||
|
||||
button:-moz-focusring,
|
||||
[type="button"]:-moz-focusring,
|
||||
[type="reset"]:-moz-focusring,
|
||||
[type="submit"]:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the padding in Firefox.
|
||||
*/
|
||||
|
||||
fieldset {
|
||||
padding: 0.35em 0.75em 0.625em;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the text wrapping in Edge and IE.
|
||||
* 2. Correct the color inheritance from `fieldset` elements in IE.
|
||||
* 3. Remove the padding so developers are not caught out when they zero out
|
||||
* `fieldset` elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
box-sizing: border-box;
|
||||
/* 1 */
|
||||
color: inherit;
|
||||
/* 2 */
|
||||
display: table;
|
||||
/* 1 */
|
||||
max-width: 100%;
|
||||
/* 1 */
|
||||
padding: 0;
|
||||
/* 3 */
|
||||
white-space: normal;
|
||||
/* 1 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the default vertical scrollbar in IE 10+.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in IE 10.
|
||||
* 2. Remove the padding in IE 10.
|
||||
*/
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
box-sizing: border-box;
|
||||
/* 1 */
|
||||
padding: 0;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the cursor style of increment and decrement buttons in Chrome.
|
||||
*/
|
||||
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the odd appearance in Chrome and Safari.
|
||||
* 2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield;
|
||||
/* 1 */
|
||||
outline-offset: -2px;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||
* 2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
/* 1 */
|
||||
font: inherit;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/* Interactive
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Add the correct display in Edge, IE 10+, and Firefox.
|
||||
*/
|
||||
|
||||
details {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*
|
||||
* Add the correct display in all browsers.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/* Misc
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10+.
|
||||
*/
|
||||
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10.
|
||||
*/
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/* Дополнительные стили для PrimeReact с вашей темой */
|
||||
.p-tabmenu .p-tabmenuitem .p-menuitem-link {
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.p-tabmenu .p-tabmenuitem .p-menuitem-link:not(.p-disabled):hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.p-tabmenu .p-tabmenuitem.p-highlight .p-menuitem-link {
|
||||
color: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.p-menubar {
|
||||
background-color: var(--bg-secondary);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.p-menubar .p-menuitem-link {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.p-menubar .p-menuitem-link:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.p-button.p-button-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.p-button.p-button-text:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* ==================== Стили для скроллов ==================== */
|
||||
|
||||
/* WebKit браузеры (Chrome, Safari, Edge, Opera) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-secondary);
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-secondary) var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* Для элементов с прокруткой (кастомные классы) */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-secondary) var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--border-secondary);
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Для горизонтальных скроллов */
|
||||
.horizontal-scrollbar {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.horizontal-scrollbar::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
/* Для очень тонких скроллов (например, в таблицах) */
|
||||
.thin-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--border-secondary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Для темных тем - дополнительная стилизация */
|
||||
[data-theme="dark"] ::-webkit-scrollbar-track,
|
||||
[data-theme="nightowl"] ::-webkit-scrollbar-track,
|
||||
[data-theme="sunset"] ::-webkit-scrollbar-track,
|
||||
[data-theme="forest"] ::-webkit-scrollbar-track,
|
||||
[data-theme="ocean"] ::-webkit-scrollbar-track,
|
||||
[data-theme="coffee"] ::-webkit-scrollbar-track,
|
||||
[data-theme="midnight"] ::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] ::-webkit-scrollbar-thumb,
|
||||
[data-theme="nightowl"] ::-webkit-scrollbar-thumb,
|
||||
[data-theme="sunset"] ::-webkit-scrollbar-thumb,
|
||||
[data-theme="forest"] ::-webkit-scrollbar-thumb,
|
||||
[data-theme="ocean"] ::-webkit-scrollbar-thumb,
|
||||
[data-theme="coffee"] ::-webkit-scrollbar-thumb,
|
||||
[data-theme="midnight"] ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover,
|
||||
[data-theme="nightowl"] ::-webkit-scrollbar-thumb:hover,
|
||||
[data-theme="sunset"] ::-webkit-scrollbar-thumb:hover,
|
||||
[data-theme="forest"] ::-webkit-scrollbar-thumb:hover,
|
||||
[data-theme="ocean"] ::-webkit-scrollbar-thumb:hover,
|
||||
[data-theme="coffee"] ::-webkit-scrollbar-thumb:hover,
|
||||
[data-theme="midnight"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Для светлых тем - более контрастные скроллы */
|
||||
[data-theme="light"] ::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
[data-theme="light"] ::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
[data-theme="light"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Для лавандовой темы */
|
||||
[data-theme="lavender"] ::-webkit-scrollbar-track {
|
||||
background: #e9d5ff;
|
||||
}
|
||||
|
||||
[data-theme="lavender"] ::-webkit-scrollbar-thumb {
|
||||
background: #c084fc;
|
||||
}
|
||||
|
||||
[data-theme="lavender"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Для розовой темы */
|
||||
[data-theme="rose"] ::-webkit-scrollbar-track {
|
||||
background: #fecdd3;
|
||||
}
|
||||
|
||||
[data-theme="rose"] ::-webkit-scrollbar-thumb {
|
||||
background: #fb7185;
|
||||
}
|
||||
|
||||
[data-theme="rose"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Стили для скролла в текстовых полях и textarea */
|
||||
textarea::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
background: var(--border-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Стили для скролла в выпадающих списках PrimeReact */
|
||||
.p-dropdown-panel .p-dropdown-items-wrapper::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.p-dropdown-panel .p-dropdown-items-wrapper::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.p-dropdown-panel .p-dropdown-items-wrapper::-webkit-scrollbar-thumb {
|
||||
background: var(--border-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.p-dropdown-panel .p-dropdown-items-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Стили для скролла в таблицах */
|
||||
.p-datatable-wrapper::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.p-datatable-wrapper::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.p-datatable-wrapper::-webkit-scrollbar-thumb {
|
||||
background: var(--border-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.p-datatable-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Стили для скролла в модальных окнах */
|
||||
.p-dialog .p-dialog-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.p-dialog .p-dialog-content::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.p-dialog .p-dialog-content::-webkit-scrollbar-thumb {
|
||||
background: var(--border-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.p-dialog .p-dialog-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Стили для скролла в меню */
|
||||
.p-menu .p-menu-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.p-menu .p-menu-list::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.p-menu .p-menu-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.p-menu .p-menu-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Tailwind dark mode через data-theme атрибут */
|
||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
/* Кастомные утилиты */
|
||||
@layer utilities {
|
||||
.transition-theme {
|
||||
transition-property: background-color, border-color, color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
БАЗОВЫЕ ТЕМЫ (Light/Dark)
|
||||
=========================== */
|
||||
|
||||
/* Светлая тема (по умолчанию) */
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #94a3b8;
|
||||
--border: #e2e8f0;
|
||||
--border-focus: #94a3b8;
|
||||
--card-bg: #ffffff;
|
||||
--input-bg: #ffffff;
|
||||
--header-bg: #ffffff;
|
||||
--button-primary: #0f172a;
|
||||
--button-primary-text: #ffffff;
|
||||
--button-primary-hover: #1e293b;
|
||||
--button-danger: #ef4444;
|
||||
--button-danger-text: #ffffff;
|
||||
--button-danger-hover: #dc2626;
|
||||
--error-bg: #fef2f2;
|
||||
--error-border: #fecaca;
|
||||
--error-text: #dc2626;
|
||||
--shadow-color: rgba(0, 0, 0, 0.1);
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #4f46e5;
|
||||
--link: #0f172a;
|
||||
--link-hover: #475569;
|
||||
}
|
||||
|
||||
/* Темная тема */
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #64748b;
|
||||
--border: #334155;
|
||||
--border-focus: #64748b;
|
||||
--card-bg: #1e293b;
|
||||
--input-bg: #1e293b;
|
||||
--header-bg: #0f172a;
|
||||
--button-primary: #f8fafc;
|
||||
--button-primary-text: #0f172a;
|
||||
--button-primary-hover: #e2e8f0;
|
||||
--button-danger: #ef4444;
|
||||
--button-danger-text: #ffffff;
|
||||
--button-danger-hover: #f87171;
|
||||
--error-bg: rgba(239, 68, 68, 0.1);
|
||||
--error-border: rgba(239, 68, 68, 0.3);
|
||||
--error-text: #fca5a5;
|
||||
--shadow-color: rgba(0, 0, 0, 0.3);
|
||||
--accent: #818cf8;
|
||||
--accent-hover: #a5b4fc;
|
||||
--link: #f8fafc;
|
||||
--link-hover: #cbd5e1;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
ЦВЕТНЫЕ ТЕМЫ
|
||||
=========================== */
|
||||
|
||||
/* Night Owl */
|
||||
[data-theme="nightowl"] {
|
||||
--bg-primary: #011627;
|
||||
--bg-secondary: #011e3c;
|
||||
--bg-tertiary: #0d2f4f;
|
||||
--text-primary: #d6deeb;
|
||||
--text-secondary: #8892b0;
|
||||
--text-muted: #4a5568;
|
||||
--border: #1d3b53;
|
||||
--border-focus: #7dd3fc;
|
||||
--card-bg: #011e3c;
|
||||
--input-bg: #011e3c;
|
||||
--header-bg: #011627;
|
||||
--button-primary: #7dd3fc;
|
||||
--button-primary-text: #011627;
|
||||
--button-primary-hover: #bae6fd;
|
||||
--button-danger: #f87171;
|
||||
--button-danger-text: #ffffff;
|
||||
--button-danger-hover: #fca5a5;
|
||||
--error-bg: rgba(248, 113, 113, 0.1);
|
||||
--error-border: rgba(248, 113, 113, 0.3);
|
||||
--error-text: #fca5a5;
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--accent: #7dd3fc;
|
||||
--accent-hover: #bae6fd;
|
||||
--link: #7dd3fc;
|
||||
--link-hover: #bae6fd;
|
||||
}
|
||||
|
||||
/* Sunset */
|
||||
[data-theme="sunset"] {
|
||||
--bg-primary: #1c1917;
|
||||
--bg-secondary: #292524;
|
||||
--bg-tertiary: #44403c;
|
||||
--text-primary: #fafaf9;
|
||||
--text-secondary: #d6d3d1;
|
||||
--text-muted: #78716c;
|
||||
--border: #57534e;
|
||||
--border-focus: #f97316;
|
||||
--card-bg: #292524;
|
||||
--input-bg: #292524;
|
||||
--header-bg: #1c1917;
|
||||
--button-primary: #f97316;
|
||||
--button-primary-text: #1c1917;
|
||||
--button-primary-hover: #fb923c;
|
||||
--button-danger: #ef4444;
|
||||
--button-danger-text: #ffffff;
|
||||
--button-danger-hover: #f87171;
|
||||
--error-bg: rgba(239, 68, 68, 0.1);
|
||||
--error-border: rgba(239, 68, 68, 0.3);
|
||||
--error-text: #fca5a5;
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--accent: #f97316;
|
||||
--accent-hover: #fb923c;
|
||||
--link: #f97316;
|
||||
--link-hover: #fb923c;
|
||||
}
|
||||
|
||||
/* Forest */
|
||||
[data-theme="forest"] {
|
||||
--bg-primary: #052e16;
|
||||
--bg-secondary: #14532d;
|
||||
--bg-tertiary: #166534;
|
||||
--text-primary: #f0fdf4;
|
||||
--text-secondary: #bbf7d0;
|
||||
--text-muted: #4ade80;
|
||||
--border: #166534;
|
||||
--border-focus: #22c55e;
|
||||
--card-bg: #14532d;
|
||||
--input-bg: #14532d;
|
||||
--header-bg: #052e16;
|
||||
--button-primary: #22c55e;
|
||||
--button-primary-text: #052e16;
|
||||
--button-primary-hover: #4ade80;
|
||||
--button-danger: #ef4444;
|
||||
--button-danger-text: #ffffff;
|
||||
--button-danger-hover: #f87171;
|
||||
--error-bg: rgba(239, 68, 68, 0.1);
|
||||
--error-border: rgba(239, 68, 68, 0.3);
|
||||
--error-text: #fca5a5;
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--accent: #22c55e;
|
||||
--accent-hover: #4ade80;
|
||||
--link: #22c55e;
|
||||
--link-hover: #4ade80;
|
||||
}
|
||||
|
||||
/* Ocean */
|
||||
[data-theme="ocean"] {
|
||||
--bg-primary: #164e63;
|
||||
--bg-secondary: #0e7490;
|
||||
--bg-tertiary: #0891b2;
|
||||
--text-primary: #f0fdfd;
|
||||
--text-secondary: #a5f3fc;
|
||||
--text-muted: #22d3ee;
|
||||
--border: #0891b2;
|
||||
--border-focus: #06b6d4;
|
||||
--card-bg: #0e7490;
|
||||
--input-bg: #0e7490;
|
||||
--header-bg: #164e63;
|
||||
--button-primary: #06b6d4;
|
||||
--button-primary-text: #164e63;
|
||||
--button-primary-hover: #22d3ee;
|
||||
--button-danger: #ef4444;
|
||||
--button-danger-text: #ffffff;
|
||||
--button-danger-hover: #f87171;
|
||||
--error-bg: rgba(239, 68, 68, 0.1);
|
||||
--error-border: rgba(239, 68, 68, 0.3);
|
||||
--error-text: #fca5a5;
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--accent: #06b6d4;
|
||||
--accent-hover: #22d3ee;
|
||||
--link: #06b6d4;
|
||||
--link-hover: #22d3ee;
|
||||
}
|
||||
|
||||
/* Lavender */
|
||||
[data-theme="lavender"] {
|
||||
--bg-primary: #faf5ff;
|
||||
--bg-secondary: #f3e8ff;
|
||||
--bg-tertiary: #e9d5ff;
|
||||
--text-primary: #581c87;
|
||||
--text-secondary: #7e22ce;
|
||||
--text-muted: #a855f7;
|
||||
--border: #e9d5ff;
|
||||
--border-focus: #a855f7;
|
||||
--card-bg: #f3e8ff;
|
||||
--input-bg: #f3e8ff;
|
||||
--header-bg: #faf5ff;
|
||||
--button-primary: #a855f7;
|
||||
--button-primary-text: #faf5ff;
|
||||
--button-primary-hover: #c084fc;
|
||||
--button-danger: #ef4444;
|
||||
--button-danger-text: #ffffff;
|
||||
--button-danger-hover: #f87171;
|
||||
--error-bg: rgba(239, 68, 68, 0.1);
|
||||
--error-border: rgba(239, 68, 68, 0.3);
|
||||
--error-text: #dc2626;
|
||||
--shadow-color: rgba(88, 28, 135, 0.1);
|
||||
--accent: #a855f7;
|
||||
--accent-hover: #c084fc;
|
||||
--link: #7e22ce;
|
||||
--link-hover: #a855f7;
|
||||
}
|
||||
|
||||
/* Coffee */
|
||||
[data-theme="coffee"] {
|
||||
--bg-primary: #292524;
|
||||
--bg-secondary: #44403c;
|
||||
--bg-tertiary: #57534e;
|
||||
--text-primary: #f5f5f4;
|
||||
--text-secondary: #d6d3d1;
|
||||
--text-muted: #a8a29e;
|
||||
--border: #57534e;
|
||||
--border-focus: #d97706;
|
||||
--card-bg: #44403c;
|
||||
--input-bg: #44403c;
|
||||
--header-bg: #292524;
|
||||
--button-primary: #d97706;
|
||||
--button-primary-text: #292524;
|
||||
--button-primary-hover: #fbbf24;
|
||||
--button-danger: #ef4444;
|
||||
--button-danger-text: #ffffff;
|
||||
--button-danger-hover: #f87171;
|
||||
--error-bg: rgba(239, 68, 68, 0.1);
|
||||
--error-border: rgba(239, 68, 68, 0.3);
|
||||
--error-text: #fca5a5;
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--accent: #d97706;
|
||||
--accent-hover: #fbbf24;
|
||||
--link: #d97706;
|
||||
--link-hover: #fbbf24;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
БАЗОВЫЕ СТИЛИ
|
||||
=========================== */
|
||||
|
||||
body {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@/components/*": ["src/components/*"],
|
||||
"@/store/*": ["src/store/*"],
|
||||
"@/services/*": ["src/services/*"],
|
||||
"@/styles/*": ["src/styles/*"],
|
||||
"@/pages/*": ["src/pages/*"],
|
||||
"@/types/*": ["src/components/layout/sidebar/types/*"],
|
||||
"@/lib/*": ["src/lib/*"]
|
||||
},
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.d.ts",
|
||||
"env.d.ts",
|
||||
"src/modules/workaspace/terminal/hooks"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
+3935
File diff suppressed because it is too large
Load Diff
-107
@@ -1,107 +0,0 @@
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dmarkham/enumer v1.6.3/go.mod h1:DyjXaqCglj4GhELF73oWiparNkYkXvmOBLza/o4kO74=
|
||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
||||
github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
|
||||
github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY=
|
||||
github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
|
||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mkevac/debugcharts v0.0.0-20191222103121-ae1c48aa8615/go.mod h1:Ad7oeElCZqA1Ufj0U9/liOF4BtVepxRcTvr2ey7zTvM=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pascaldekloe/name v1.0.1/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver v1.11.4 h1:4ayjakA013OdpGyL2K3ZqylTac/rMjrJOMZ1EHizXas=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user