Compare commits
50 Commits
b69f2e4c9a
...
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 | |||
| 514e3e30b6 | |||
| 94ff261c9a | |||
| a44630cfea | |||
| 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 = `
|
||||
|
||||
`
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
type HellreigN struct {
|
||||
Database Databases `yaml:"database"`
|
||||
Admin Admin `yaml:"admin"`
|
||||
}
|
||||
|
||||
type Databases struct {
|
||||
@@ -11,3 +12,9 @@ type Databases struct {
|
||||
Clickhouse_password string `yaml:"clickhouse_password"`
|
||||
Clickhouse_database string `yaml:"clickhouse_database"`
|
||||
}
|
||||
type Admin struct {
|
||||
Admin_name string `yaml:"admin_name"`
|
||||
Admin_last_name string `yaml:"admin_last_name"`
|
||||
Admin_login string `yaml:"admin_login"`
|
||||
Admin_password string `yaml:"admin_password"`
|
||||
}
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user