diff --git a/.dockerignore b/.dockerignore index 07856ab99..5a52a21f2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,4 @@ build/* logs/* data/* release/* +ui/node_modules/ \ No newline at end of file diff --git a/.github/workflows/build-base-image.yml b/.github/workflows/build-base-image.yml new file mode 100644 index 000000000..542fdf47c --- /dev/null +++ b/.github/workflows/build-base-image.yml @@ -0,0 +1,63 @@ +name: Build and Push Base Image + +on: + push: + branches: + - 'pr*' + paths: + - 'go.mod' + - 'Dockerfile-base' + - 'ui/package.json' + - 'package.json' + - 'ui/yarn.lock' + - 'yarn.lock' + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract date + id: vars + run: echo "IMAGE_TAG=$(date +'%Y%m%d_%H%M%S')" >> $GITHUB_ENV + + - name: Extract repository name + id: repo + run: echo "REPO=$(basename ${{ github.repository }})" >> $GITHUB_ENV + + - name: Build and push multi-arch image + uses: docker/build-push-action@v6 + with: + platforms: linux/amd64,linux/arm64 + push: true + file: Dockerfile-base + tags: jumpserver/${{ env.REPO }}-base:${{ env.IMAGE_TAG }} + + - name: Update Dockerfile + run: | + sed -i 's|-base:.* AS stage-build|-base:${{ env.IMAGE_TAG }} AS stage-build|' Dockerfile + + - name: Commit changes + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git add Dockerfile + git commit -m "perf: Update Dockerfile with new base image tag" + git push + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-ghcr-image.yml b/.github/workflows/build-ghcr-image.yml new file mode 100644 index 000000000..d6a74fff2 --- /dev/null +++ b/.github/workflows/build-ghcr-image.yml @@ -0,0 +1,61 @@ +name: build image and push to ghcr.io + +on: + workflow_dispatch: + inputs: + BRANCH: + description: 'branch' + type: string + default: 'dev' + VERSION: + description: 'version' + type: string + default: 'dev' + PLATFORMS: + description: 'platforms' + type: string + default: 'linux/amd64,linux/arm64' + IMAGE_TAG: + description: "image tag" + type: string + default: 'dev' + required: true +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ inputs.BRANCH }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract repository name + id: repo + run: echo "REPO=$(basename ${{ github.repository }})" >> $GITHUB_ENV + + - name: Extract image prefix + run: | + echo "IMAGE_PREFIX=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV + + - name: Build and push multi-arch image + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ inputs.PLATFORMS }} + push: true + build-args: VERSION=${{ inputs.VERSION }} + file: Dockerfile + tags: ${{ env.IMAGE_PREFIX }}/${{ env.REPO }}:${{ inputs.IMAGE_TAG }} + diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 000000000..179f97e95 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,51 @@ +name: golangci-lint +on: + pull_request: + push: + branches: + - dev + +permissions: + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + # pull-requests: read + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Create ui/dist directory + run: | + mkdir -p ui/dist + touch ui/dist/.gitkeep + + - uses: actions/setup-go@v5 + with: + go-version: stable + + - uses: golangci/golangci-lint-action@v6 + with: + # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version + version: latest + + # Optional: working directory, useful for monorepos + # working-directory: somedir + + # Optional: golangci-lint command line arguments. + # args: --issues-exit-code=0 + + # Optional: show only new issues if it's a pull request. The default value is `false`. + # only-new-issues: true + + # Optional: if set to true then the all caching functionality will be complete disabled, + # takes precedence over all other caching options. + # skip-cache: true + + # Optional: if set to true then the action don't cache or restore ~/go/pkg. + # skip-pkg-cache: true + + # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. + # skip-build-cache: true \ No newline at end of file diff --git a/.github/workflows/jms-build-test.yml.disabled b/.github/workflows/jms-build-test.yml.disabled new file mode 100644 index 000000000..683634b92 --- /dev/null +++ b/.github/workflows/jms-build-test.yml.disabled @@ -0,0 +1,64 @@ +name: "Run Build Test" +on: + push: + paths: + - 'Dockerfile' + - 'Dockerfile*' + - 'Dockerfile-*' + - 'go.mod' + - 'go.sum' + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + component: [koko] + version: [v4] + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + + - name: Prepare Build + run: | + sed -i 's@registry.npmmirror.com@registry.yarnpkg.com@g' ui/yarn.lock + sed -i 's@^FROM registry.fit2cloud.com/jumpserver@FROM ghcr.io/jumpserver@g' Dockerfile-ee + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build CE Image + uses: docker/build-push-action@v5 + with: + context: . + push: true + file: Dockerfile + tags: ghcr.io/jumpserver/${{ matrix.component }}:${{ matrix.version }}-ce + platforms: linux/amd64 + build-args: | + VERSION=${{ matrix.version }} + GOPROXY=direct + APT_MIRROR=http://deb.debian.org + NPM_REGISTRY=https://registry.yarnpkg.com + outputs: type=image,oci-mediatypes=true,compression=zstd,compression-level=3,force-compression=true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build EE Image + uses: docker/build-push-action@v5 + with: + context: . + push: false + file: Dockerfile-ee + tags: ghcr.io/jumpserver/${{ matrix.component }}:${{ matrix.version }} + platforms: linux/amd64 + build-args: | + VERSION=${{ matrix.version }} + APT_MIRROR=http://deb.debian.org + outputs: type=image,oci-mediatypes=true,compression=zstd,compression-level=3,force-compression=true + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/jms-generic-action-handler.yml b/.github/workflows/jms-generic-action-handler.yml index 3f499cfb9..e4bd6ddef 100644 --- a/.github/workflows/jms-generic-action-handler.yml +++ b/.github/workflows/jms-generic-action-handler.yml @@ -1,12 +1,36 @@ -on: [push, pull_request, release] +on: + push: + pull_request: + types: [opened, synchronize, closed] + release: + types: [created] name: JumpServer repos generic handler jobs: - generic_handler: - name: Run generic handler + handle_pull_request: + if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: jumpserver/action-generic-handler@master env: GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }} + I18N_TOKEN: ${{ secrets.I18N_TOKEN }} + + handle_push: + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: jumpserver/action-generic-handler@master + env: + GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }} + I18N_TOKEN: ${{ secrets.I18N_TOKEN }} + + handle_release: + if: github.event_name == 'release' + runs-on: ubuntu-latest + steps: + - uses: jumpserver/action-generic-handler@master + env: + GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }} + I18N_TOKEN: ${{ secrets.I18N_TOKEN }} diff --git a/.github/workflows/llm-code-review.yml b/.github/workflows/llm-code-review.yml new file mode 100644 index 000000000..ce4710487 --- /dev/null +++ b/.github/workflows/llm-code-review.yml @@ -0,0 +1,28 @@ +name: LLM Code Review + +permissions: + contents: read + pull-requests: write + +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + llm-code-review: + runs-on: ubuntu-latest + steps: + - uses: fit2cloud/LLM-CodeReview-Action@main + env: + GITHUB_TOKEN: ${{ secrets.FIT2CLOUDRD_LLM_CODE_REVIEW_TOKEN }} + OPENAI_API_KEY: ${{ secrets.ALIYUN_LLM_API_KEY }} + LANGUAGE: English + OPENAI_API_ENDPOINT: https://dashscope.aliyuncs.com/compatible-mode/v1 + MODEL: qwen2-1.5b-instruct + PROMPT: "Please check the following code differences for any irregularities, potential issues, or optimization suggestions, and provide your answers in English." + top_p: 1 + temperature: 1 + # max_tokens: 10000 + MAX_PATCH_LENGTH: 10000 + IGNORE_PATTERNS: "/node_modules,*.md,/dist,/.github" + FILE_PATTERNS: "*.java,*.go,*.py,*.vue,*.ts,*.js,*.css,*.scss,*.html" diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index de44b51f5..478f5fabc 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -7,45 +7,65 @@ on: name: Create Release And Upload assets jobs: - create-realese: + create-release: name: Create Release runs-on: ubuntu-latest + strategy: + matrix: + go_version: [ 'stable' ] + node_version: [ '20' ] outputs: upload_url: ${{ steps.create_release.outputs.upload_url }} steps: - - name: Checkout code - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + + - uses: actions/cache@v4 + with: + path: | + ~/.npm + ~/.cache + ~/go/pkg/mod + key: ${{ runner.os }}-build-${{ github.sha }} + restore-keys: ${{ runner.os }}-build- + - name: Get version id: get_version run: | TAG=$(basename ${GITHUB_REF}) - VERSION=${TAG/v/} - echo "::set-output name=TAG::$TAG" - echo "::set-output name=VERSION::$VERSION" + echo "TAG=$TAG" >> $GITHUB_OUTPUT + - name: Create Release id: create_release - uses: release-drafter/release-drafter@v5 + uses: release-drafter/release-drafter@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: config-name: release-config.yml - version: ${{ steps.get_version.outputs.VERSION }} + version: ${{ steps.get_version.outputs.TAG }} tag: ${{ steps.get_version.outputs.TAG }} - - uses: actions/setup-go@v2 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node_version }} + + - uses: actions/setup-go@v5 with: - go-version: '1.17.x' # The Go version to download (if necessary) and use. + go-version: ${{ matrix.go_version }} + cache: false + - name: Make Build id: make_build + run: | + make all -s && ls build env: VERSION: ${{ steps.get_version.outputs.TAG }} - run: | - make -s && ls build + - name: Release Upload Assets - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: draft: true files: | build/*.gz env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 89edb7718..c22f11649 100644 --- a/.gitignore +++ b/.gitignore @@ -16,19 +16,16 @@ log/ vendor/ config.yml host_key -cmd/koko cmd/logs cmd/kokodir build +build/ release !config_example.yml -*.yml - .DS_Store -node_modules -dist/ - +ui/node_modules/ +ui/dist/ # local env files .env.local @@ -48,3 +45,6 @@ pnpm-debug.log* *.njsproj *.sln *.sw? +demo + +docker-compose.yaml \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 000000000..2faa39ae2 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,25 @@ +run: + timeout: 5m + modules-download-mode: readonly + +issues: + exclude-dirs: + - cmd/demo + - cmd/i18ntool + - data + - locale + - docs + - ui + - .git + + exclude-files: + - pkg/utils/terminal.go + +linters: + enable: + - govet + - staticcheck + +output: + formats: + - format: colored-line-number \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 000000000..8d92552ac --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,72 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 + +project_name: koko + +before: + hooks: + - go mod tidy + - go generate ./... + +builds: + - id: koko + main: ./cmd/koko/ + binary: koko + goos: + - linux + - darwin + - freebsd + - netbsd + goarch: + - amd64 + - arm64 + - mips64le + - ppc64le + - s390x + - riscv64 + - loong64 + env: + - CGO_ENABLED=0 + ldflags: + - -w -s + - -X 'main.Buildstamp={{ .Date }}' + - -X 'main.Githash={{ .ShortCommit }}' + - -X 'main.Goversion={{ .Env.GOVERSION }}' + - -X 'main.Version={{ .Tag }}' + - -X 'github.com/jumpserver/koko/pkg/config.CipherKey={{ .Env.CipherKey }}' + +archives: + - format: tar.gz + wrap_in_directory: true + files: + - LICENSE + - README.md + - config_example.yml + - entrypoint.sh + - locale/* + - src: utils/init-kubectl.sh + dst: init-kubectl + strip_parent: true + + format_overrides: + - goos: windows + format: zip + name_template: "{{ .ProjectName }}-{{ .Tag }}-{{ .Os }}-{{ .Arch }}{{- if .Arm }}v{{ .Arm }}{{ end }}" + +checksum: + name_template: "checksums.txt" + +release: + draft: true + mode: append + extra_files: + - glob: dist/*.tar.gz + - glob: dist/*.txt + name_template: "Release {{.Tag}}" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index cae6ea255..29ac345ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,67 +1,71 @@ -FROM node:10 as ui-build -ARG NPM_REGISTRY="https://registry.npm.taobao.org" -ENV NPM_REGISTY=$NPM_REGISTRY +FROM jumpserver/koko-base:20260422_103200 AS stage-build WORKDIR /opt/koko -RUN npm config set registry ${NPM_REGISTRY} -RUN yarn config set registry ${NPM_REGISTRY} +ARG TARGETARCH +COPY . . + +ARG VERSION +ENV VERSION=$VERSION -COPY ui ui/ -RUN ls . && cd ui/ && npm install -i && yarn build && ls -al . +WORKDIR /opt/koko/ui +RUN yarn build -FROM golang:1.17-alpine as stage-build -LABEL stage=stage-build WORKDIR /opt/koko -ARG GOPROXY=https://goproxy.io -ARG VERSION=Unknown +RUN make build -s \ + && set -x && ls -al . \ + && mv /opt/koko/build/koko /opt/koko/koko \ + && mv /opt/koko/bin/rawhelm /opt/koko/bin/helm \ + && mv /opt/koko/bin/rawkubectl /opt/koko/bin/kubectl + +RUN mkdir /opt/koko/release \ + && mv /opt/koko/locale /opt/koko/release \ + && mv /opt/koko/config_example.yml /opt/koko/release \ + && mv /opt/koko/entrypoint.sh /opt/koko/release \ + && mv /opt/koko/utils/init-kubectl.sh /opt/koko/release \ + && chmod 755 /opt/koko/release/entrypoint.sh /opt/koko/release/init-kubectl.sh + +FROM debian:trixie ARG TARGETARCH -ENV GOPROXY=$GOPROXY -ENV VERSION=$VERSION -ENV TARGETARCH=$TARGETARCH -ENV GO111MODULE=on -ENV GOOS=linux +ENV LANG=en_US.UTF-8 -RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ - && apk update \ - && apk add git +LABEL org.opencontainers.image.source=https://github.com/jumpserver/koko +LABEL org.opencontainers.image.description="JumpServer Koko" -RUN wget https://download.jumpserver.org/public/kubectl-linux-${TARGETARCH}.tar.gz -O kubectl.tar.gz \ - && tar -xzf kubectl.tar.gz \ - && chmod +x kubectl \ - && mv kubectl rawkubectl \ - && wget http://download.jumpserver.org/public/kubectl_aliases.tar.gz -O kubectl_aliases.tar.gz \ - && tar -xzvf kubectl_aliases.tar.gz -COPY . . +ARG DEPENDENCIES=" \ + bash-completion \ + jq \ + less \ + redis-tools \ + ca-certificates" -RUN cd utils && sh -ixeu build.sh - -FROM debian:bullseye-slim -ENV LANG en_US.utf8 -RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \ - && sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \ - && apt update \ - && apt-get install -y locales \ - && localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \ - && apt-get install -y --no-install-recommends openssh-client procps curl gdb ca-certificates jq iproute2 less bash-completion unzip sysstat acl net-tools iputils-ping telnet dnsutils wget vim git freetds-bin mariadb-client redis-tools gnupg\ - && wget -qO - https://www.mongodb.org/static/pgp/server-5.0.asc | apt-key add - \ - && echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu bionic/mongodb-org/5.0 multiverse" | tee /etc/apt/sources.list.d/mongodb-org-5.0.list \ - && apt update \ - && apt-get install -y --no-install-recommends mongodb-mongosh \ +ARG APT_MIRROR=http://deb.debian.org + +RUN set -ex \ + && sed -i "s@http://.*.debian.org@${APT_MIRROR}@g" /etc/apt/sources.list.d/debian.sources \ + && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ + && apt-get update \ + && apt-get install -y --no-install-recommends ${DEPENDENCIES} \ + && apt-get clean all \ && rm -rf /var/lib/apt/lists/* -ENV TZ Asia/Shanghai -WORKDIR /opt/koko/ -COPY --from=stage-build /opt/koko/release/koko /opt/koko -COPY --from=stage-build /opt/koko/release/koko/kubectl /usr/local/bin/kubectl -COPY --from=stage-build /opt/koko/rawkubectl /usr/local/bin/rawkubectl -COPY --from=stage-build /opt/koko/utils/coredump.sh . -COPY --from=stage-build /opt/koko/entrypoint.sh . -COPY --from=stage-build /opt/koko/utils/init-kubectl.sh . +WORKDIR /opt/koko + COPY --from=stage-build /opt/koko/.kubectl_aliases /opt/kubectl-aliases/.kubectl_aliases -COPY --from=ui-build /opt/koko/ui/dist ui/dist +COPY --from=stage-build /opt/koko/bin /usr/local/bin +COPY --from=stage-build /opt/koko/lib /usr/local/lib +COPY --from=stage-build /opt/koko/release . +COPY --from=stage-build /opt/koko/koko . + +ARG VERSION +ENV VERSION=${VERSION} + +VOLUME /opt/koko/data + +ENTRYPOINT ["./entrypoint.sh"] + +EXPOSE 2222 -RUN chmod 755 entrypoint.sh && chmod 755 init-kubectl.sh +STOPSIGNAL SIGQUIT -EXPOSE 2222 5000 -CMD ["./entrypoint.sh"] +CMD [ "./koko" ] diff --git a/Dockerfile-base b/Dockerfile-base new file mode 100644 index 000000000..5c503ce8b --- /dev/null +++ b/Dockerfile-base @@ -0,0 +1,85 @@ +FROM golang:1.24-trixie AS stage-go-build + +FROM node:20-trixie +COPY --from=stage-go-build /usr/local/go/ /usr/local/go/ +COPY --from=stage-go-build /go/ /go/ +ENV GOPATH=/go +ENV PATH=/go/bin:/usr/local/go/bin:$PATH +ARG TARGETARCH +ARG NPM_REGISTRY="https://registry.npmmirror.com" +ENV NPM_REGISTY=$NPM_REGISTRY + +RUN set -ex \ + && npm config set registry ${NPM_REGISTRY} \ + && yarn config set registry ${NPM_REGISTRY} + +WORKDIR /opt + +ARG HELM_VERSION=v3.17.4 +ARG KUBECTL_VERSION=v1.32.9 +ARG CHECK_VERSION=v1.0.5 +ARG USQL_VERSION=v0.1.10 + + +RUN set -ex \ + && mkdir -p /opt/koko/bin \ + && wget -O kubectl.tar.gz https://dl.k8s.io/${KUBECTL_VERSION}/kubernetes-client-linux-${TARGETARCH}.tar.gz \ + && tar -xf kubectl.tar.gz --strip-components=3 -C /opt/koko/bin/ kubernetes/client/bin/kubectl \ + && mv /opt/koko/bin/kubectl /opt/koko/bin/rawkubectl \ + && wget https://get.helm.sh/helm-${HELM_VERSION}-linux-${TARGETARCH}.tar.gz \ + && tar -xf helm-${HELM_VERSION}-linux-${TARGETARCH}.tar.gz --strip-components=1 -C /opt/koko/bin/ linux-${TARGETARCH}/helm \ + && mv /opt/koko/bin/helm /opt/koko/bin/rawhelm \ + && wget https://github.com/jumpserver-dev/healthcheck/releases/download/${CHECK_VERSION}/check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz \ + && tar -xf check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz -C /opt/koko/bin/ check \ + && wget https://github.com/jumpserver-dev/usql/releases/download/${USQL_VERSION}/usql-${USQL_VERSION}-linux-${TARGETARCH}.tar.gz \ + && tar -xf usql-${USQL_VERSION}-linux-${TARGETARCH}.tar.gz -C /opt/koko/bin/ \ + && wget -O /opt/koko/.kubectl_aliases https://github.com/ahmetb/kubectl-aliases/raw/master/.kubectl_aliases \ + && chmod 755 /opt/koko/bin/* \ + && chown root:root /opt/koko/bin/* \ + && rm -f *.tar.gz + +WORKDIR /opt/koko + +ARG MONGOSH_VERSION=2.3.4 +RUN set -ex \ + && mkdir -p /opt/koko/lib \ + && \ + case "${TARGETARCH}" in \ + amd64) \ + wget https://downloads.mongodb.com/compass/mongosh-${MONGOSH_VERSION}-linux-x64.tgz \ + && tar -xf mongosh-${MONGOSH_VERSION}-linux-x64.tgz \ + && chown root:root mongosh-${MONGOSH_VERSION}-linux-x64/bin/* \ + && mv mongosh-${MONGOSH_VERSION}-linux-x64/bin/mongosh /opt/koko/bin/ \ + && mv mongosh-${MONGOSH_VERSION}-linux-x64/bin/mongosh_crypt_v1.so /opt/koko/lib/ \ + && rm -rf mongosh-${MONGOSH_VERSION}-linux-x64* \ + ;; \ + arm64|ppc64le|s390x) \ + wget https://downloads.mongodb.com/compass/mongosh-${MONGOSH_VERSION}-linux-${TARGETARCH}.tgz \ + && tar -xf mongosh-${MONGOSH_VERSION}-linux-${TARGETARCH}.tgz \ + && chown root:root mongosh-${MONGOSH_VERSION}-linux-${TARGETARCH}/bin/* \ + && mv mongosh-${MONGOSH_VERSION}-linux-${TARGETARCH}/bin/mongosh /opt/koko/bin/ \ + && mv mongosh-${MONGOSH_VERSION}-linux-${TARGETARCH}/bin/mongosh_crypt_v1.so /opt/koko/lib/ \ + && rm -rf mongosh-${MONGOSH_VERSION}-linux-${TARGETARCH}* \ + ;; \ + *) \ + echo "Unsupported architecture: ${TARGETARCH}" \ + ;; \ + esac + +WORKDIR /opt/koko/ui + +RUN --mount=type=cache,target=/usr/local/share/.cache/yarn,sharing=locked,id=koko \ + --mount=type=bind,source=ui/package.json,target=package.json \ + --mount=type=bind,source=ui/yarn.lock,target=yarn.lock \ + yarn install + +WORKDIR /opt/koko/ + +ENV CGO_ENABLED=0 +ENV GO111MODULE=on + +COPY go.mod go.sum ./ + +RUN go mod download -x + + diff --git a/Dockerfile-ee b/Dockerfile-ee new file mode 100644 index 000000000..1475e2282 --- /dev/null +++ b/Dockerfile-ee @@ -0,0 +1,24 @@ +ARG VERSION=dev + +FROM jumpserver/koko:${VERSION}-ce +ARG TARGETARCH + +ARG DEPENDENCIES=" \ + curl \ + git \ + git-lfs \ + iputils-ping \ + openssh-client \ + telnet \ + unzip \ + vim \ + wget \ + xz-utils" + +ARG APT_MIRROR=http://deb.debian.org + +RUN set -ex \ + && sed -i "s@http://.*.debian.org@${APT_MIRROR}@g" /etc/apt/sources.list.d/debian.sources \ + && apt-get update \ + && apt-get install -y --no-install-recommends ${DEPENDENCIES} \ + && apt-get clean diff --git a/Makefile b/Makefile index 70d3c0b34..f884c3b05 100644 --- a/Makefile +++ b/Makefile @@ -1,129 +1,112 @@ NAME=koko BUILDDIR=build +VERSION ?= Unknown +BuildTime := $(shell date -u '+%Y-%m-%d %I:%M:%S%p') +COMMIT := $(shell git rev-parse HEAD) +GOVERSION := $(shell go version) +CipherKey := $(shell head -c 100 /dev/urandom | base64 | head -c 32) + BASEPATH := $(shell pwd) -BRANCH := $(shell git symbolic-ref HEAD 2>/dev/null | cut -d"/" -f 3) -BUILD := $(shell git rev-parse --short HEAD) KOKOSRCFILE := $(BASEPATH)/cmd/koko/ -KUBECTLFILE := $(BASEPATH)/cmd/kubectl/ -VERSION ?= $(BRANCH)-$(BUILD) -BuildTime:= $(shell date -u '+%Y-%m-%d %I:%M:%S%p') -COMMIT:= $(shell git rev-parse HEAD) -GOVERSION:= $(shell go version) -CipherKey := $(shell head -c 100 /dev/urandom | base64 | head -c 32) -TARGETARCH ?= amd64 +GOOS := $(shell go env GOOS) +GOARCH := $(shell go env GOARCH) -UIDIR=ui -NPMINSTALL=npm i -NPMBUILD=npm run-script build +LDFLAGS=-w -s KOKOLDFLAGS+=-X 'main.Buildstamp=$(BuildTime)' KOKOLDFLAGS+=-X 'main.Githash=$(COMMIT)' KOKOLDFLAGS+=-X 'main.Goversion=$(GOVERSION)' -KOKOLDFLAGS+=-X 'github.com/jumpserver/koko/pkg/koko.Version=$(VERSION)' +KOKOLDFLAGS+=-X 'main.Version=$(VERSION)' KOKOLDFLAGS+=-X 'github.com/jumpserver/koko/pkg/config.CipherKey=$(CipherKey)' -KUBECTLFLAGS="-X 'github.com/jumpserver/koko/pkg/config.CipherKey=$(CipherKey)'" - -KOKOBUILD=CGO_ENABLED=0 go build -trimpath -ldflags "$(KOKOLDFLAGS)" -KUBECTLBUILD=CGO_ENABLED=0 go build -trimpath -ldflags $(KUBECTLFLAGS) - -PLATFORM_LIST = \ - darwin-amd64 \ - darwin-arm64 \ - linux-amd64 \ - linux-arm64 - -all-arch: $(PLATFORM_LIST) - -darwin-amd64:koko-ui - GOARCH=amd64 GOOS=darwin $(KOKOBUILD) -o $(BUILDDIR)/$(NAME)-$@ $(KOKOSRCFILE) - GOARCH=amd64 GOOS=darwin $(KUBECTLBUILD) -o $(BUILDDIR)/kubectl-$@ $(KUBECTLFILE) - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/locale/ - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/static/ - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/templates/ - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/$(UIDIR)/dist/ - - cp $(BUILDDIR)/$(NAME)-$@ $(BUILDDIR)/$(NAME)-$(VERSION)-$@/$(NAME) - cp $(BUILDDIR)/kubectl-$@ $(BUILDDIR)/$(NAME)-$(VERSION)-$@/kubectl - cp -r $(BASEPATH)/locale/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/locale/ - cp -r $(BASEPATH)/static/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/static/ - cp -r $(BASEPATH)/templates/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/templates/ - cp -r $(BASEPATH)/config_example.yml $(BUILDDIR)/$(NAME)-$(VERSION)-$@/config_example.yml - cp -r $(BASEPATH)/utils/init-kubectl.sh $(BUILDDIR)/$(NAME)-$(VERSION)-$@/init-kubectl.sh - cp -r $(UIDIR)/dist/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/$(UIDIR)/dist/ - - cd $(BUILDDIR) && tar -czvf $(NAME)-$(VERSION)-$@.tar.gz $(NAME)-$(VERSION)-$@ - rm -rf $(BUILDDIR)/$(NAME)-$(VERSION)-$@ $(BUILDDIR)/$(NAME)-$@ $(BUILDDIR)/kubectl-$@ - -darwin-arm64:koko-ui - GOARCH=arm64 GOOS=darwin $(KOKOBUILD) -o $(BUILDDIR)/$(NAME)-$@ $(KOKOSRCFILE) - GOARCH=arm64 GOOS=darwin $(KUBECTLBUILD) -o $(BUILDDIR)/kubectl-$@ $(KUBECTLFILE) - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/locale/ - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/static/ - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/templates/ - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/$(UIDIR)/dist/ - - cp $(BUILDDIR)/$(NAME)-$@ $(BUILDDIR)/$(NAME)-$(VERSION)-$@/$(NAME) - cp $(BUILDDIR)/kubectl-$@ $(BUILDDIR)/$(NAME)-$(VERSION)-$@/kubectl - cp -r $(BASEPATH)/locale/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/locale/ - cp -r $(BASEPATH)/static/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/static/ - cp -r $(BASEPATH)/templates/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/templates/ - cp -r $(BASEPATH)/config_example.yml $(BUILDDIR)/$(NAME)-$(VERSION)-$@/config_example.yml - cp -r $(BASEPATH)/utils/init-kubectl.sh $(BUILDDIR)/$(NAME)-$(VERSION)-$@/init-kubectl.sh - cp -r $(UIDIR)/dist/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/$(UIDIR)/dist/ - - cd $(BUILDDIR) && tar -czvf $(NAME)-$(VERSION)-$@.tar.gz $(NAME)-$(VERSION)-$@ - rm -rf $(BUILDDIR)/$(NAME)-$(VERSION)-$@ $(BUILDDIR)/$(NAME)-$@ $(BUILDDIR)/kubectl-$@ - -linux-amd64:koko-ui - GOARCH=amd64 GOOS=linux $(KOKOBUILD) -o $(BUILDDIR)/$(NAME)-$@ $(KOKOSRCFILE) - GOARCH=amd64 GOOS=linux $(KUBECTLBUILD) -o $(BUILDDIR)/kubectl-$@ $(KUBECTLFILE) - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/locale/ - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/static/ - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/templates/ - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/$(UIDIR)/dist/ - - cp $(BUILDDIR)/$(NAME)-$@ $(BUILDDIR)/$(NAME)-$(VERSION)-$@/$(NAME) - cp $(BUILDDIR)/kubectl-$@ $(BUILDDIR)/$(NAME)-$(VERSION)-$@/kubectl - cp -r $(BASEPATH)/locale/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/locale/ - cp -r $(BASEPATH)/static/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/static/ - cp -r $(BASEPATH)/templates/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/templates/ - cp -r $(BASEPATH)/config_example.yml $(BUILDDIR)/$(NAME)-$(VERSION)-$@/config_example.yml - cp -r $(BASEPATH)/utils/init-kubectl.sh $(BUILDDIR)/$(NAME)-$(VERSION)-$@/init-kubectl.sh - cp -r $(UIDIR)/dist/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/$(UIDIR)/dist/ - - cd $(BUILDDIR) && tar -czvf $(NAME)-$(VERSION)-$@.tar.gz $(NAME)-$(VERSION)-$@ - rm -rf $(BUILDDIR)/$(NAME)-$(VERSION)-$@ $(BUILDDIR)/$(NAME)-$@ $(BUILDDIR)/kubectl-$@ - -linux-arm64:koko-ui - GOARCH=arm64 GOOS=linux $(KOKOBUILD) -o $(BUILDDIR)/$(NAME)-$@ $(KOKOSRCFILE) - GOARCH=arm64 GOOS=linux $(KUBECTLBUILD) -o $(BUILDDIR)/kubectl-$@ $(KUBECTLFILE) - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/locale/ - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/static/ - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/templates/ - mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$@/$(UIDIR)/dist/ - cp $(BUILDDIR)/$(NAME)-$@ $(BUILDDIR)/$(NAME)-$(VERSION)-$@/$(NAME) - cp $(BUILDDIR)/kubectl-$@ $(BUILDDIR)/$(NAME)-$(VERSION)-$@/kubectl - cp -r $(BASEPATH)/locale/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/locale/ - cp -r $(BASEPATH)/static/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/static/ - cp -r $(BASEPATH)/templates/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/templates/ - cp -r $(BASEPATH)/config_example.yml $(BUILDDIR)/$(NAME)-$(VERSION)-$@/config_example.yml - cp -r $(BASEPATH)/utils/init-kubectl.sh $(BUILDDIR)/$(NAME)-$(VERSION)-$@/init-kubectl.sh - cp -r $(UIDIR)/dist/* $(BUILDDIR)/$(NAME)-$(VERSION)-$@/$(UIDIR)/dist/ - cd $(BUILDDIR) && tar -czvf $(NAME)-$(VERSION)-$@.tar.gz $(NAME)-$(VERSION)-$@ - rm -rf $(BUILDDIR)/$(NAME)-$(VERSION)-$@ $(BUILDDIR)/$(NAME)-$@ $(BUILDDIR)/kubectl-$@ +KOKOBUILD=CGO_ENABLED=0 go build -trimpath -ldflags "$(KOKOLDFLAGS) ${LDFLAGS}" + +UIDIR=ui + +define make_artifact_full + GOOS=$(1) GOARCH=$(2) $(KOKOBUILD) -o $(BUILDDIR)/$(NAME)-$(1)-$(2) $(KOKOSRCFILE) + mkdir -p $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/locale/ + cp $(BUILDDIR)/$(NAME)-$(1)-$(2) $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/$(NAME) + cp README.md $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/README.md + cp LICENSE $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/LICENSE + cp config_example.yml $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/config_example.yml + cp entrypoint.sh $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/entrypoint.sh + cp utils/init-kubectl.sh $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/init-kubectl.sh + cp -r locale/* $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2)/locale/ + + cd $(BUILDDIR) && tar -czvf $(NAME)-$(VERSION)-$(1)-$(2).tar.gz $(NAME)-$(VERSION)-$(1)-$(2) + rm -rf $(BUILDDIR)/$(NAME)-$(VERSION)-$(1)-$(2) $(BUILDDIR)/$(NAME)-$(1)-$(2) +endef + +build: + GOARCH=$(GOARCH) GOOS=$(GOOS) $(KOKOBUILD) -o $(BUILDDIR)/$(NAME) $(KOKOSRCFILE) + +all: koko-ui + $(call make_artifact_full,darwin,amd64) + $(call make_artifact_full,darwin,arm64) + $(call make_artifact_full,linux,amd64) + $(call make_artifact_full,linux,arm64) + $(call make_artifact_full,linux,mips64le) + $(call make_artifact_full,linux,ppc64le) + $(call make_artifact_full,linux,s390x) + $(call make_artifact_full,linux,riscv64) + $(call make_artifact_full,linux,loong64) + +local: koko-ui + $(call make_artifact_full,$(shell go env GOOS),$(shell go env GOARCH)) + +darwin-amd64: koko-ui + $(call make_artifact_full,darwin,amd64) + +darwin-arm64: koko-ui + $(call make_artifact_full,darwin,arm64) + +linux-amd64: koko-ui + $(call make_artifact_full,linux,amd64) + +linux-arm64: koko-ui + $(call make_artifact_full,linux,arm64) + +linux-loong64: koko-ui + $(call make_artifact_full,linux,loong64) + +linux-mips64le: koko-ui + $(call make_artifact_full,linux,mips64le) + +linux-ppc64le: koko-ui + $(call make_artifact_full,linux,ppc64le) + +linux-s390x: koko-ui + $(call make_artifact_full,linux,s390x) + +linux-riscv64: koko-ui + $(call make_artifact_full,linux,riscv64) koko-ui: @echo "build ui" - @cd $(UIDIR) && $(NPMINSTALL) && $(NPMBUILD) + @cd $(UIDIR) && yarn install && yarn build .PHONY: docker docker: @echo "build docker images" - docker build --build-arg VERSION=$(VERSION) --build-arg TARGETARCH=$(TARGETARCH) -t jumpserver/koko . + docker buildx build --build-arg VERSION=$(VERSION) -t jumpserver/koko:$(VERSION)-ce . --load + +.PHONY: docker-ee +docker-ee:docker + @echo "build docker images" + docker buildx build --build-arg VERSION=$(VERSION) -t jumpserver/koko-ee:$(VERSION)-ce -f Dockerfile-ee . --load .PHONY: clean clean: -rm -rf $(BUILDDIR) + -rm -rf $(UIDIR)/dist/* + +.PHONY: run +run: + go run ./cmd/koko/ + +.PHONY: run-ui +run-ui: + cd $(UIDIR) && yarn run serve \ No newline at end of file diff --git a/README.md b/README.md index 7b1036a3f..aa0357fda 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,57 @@ # KoKo -Koko 是 JumpServer 连接字符协议的终端组件,支持 SSH、TELNET、MySQL、Redis 等协议。 +**English** · [简体中文](./README_zh-CN.md) -Koko 使用 Golang 和 Vue 来实现,名字来自 Dota 英雄 [Kunkka](https://www.dota2.com.cn/hero/kunkka)。 +KoKo is a connector of JumpServer for secure connections using character protocols, supporting SSH, Telnet, Kubernetes, SFTP and database protocols -## 主要功能 +Koko is implemented using Golang and Vue, and the name comes from a Dota hero [Kunkka](https://www.dota2.com.cn/hero/kunkka)。 + +## Features - SSH - SFTP -- web terminal -- web文件管理 +- Web Terminal +- Web File Management -## 安装 +## Installation -1.下载项目 +1. Clone the project ```shell git clone https://github.com/jumpserver/koko.git ``` -2.编译应用 +2. Build the application -在 koko 项目下构建应用. +Build the application in the koko project. ```shell make ``` -> 如果构建成功,会在项目下自动生成 build 文件夹,里面包含当前分支各种架构版本的压缩包。 -默认构建的 VERSION 为 [branch name]-[commit]。 -因为使用go mod进行依赖管理,可以设置环境变量 GOPROXY=https://goproxy.io 代理下载部分依赖包。 +> If the build is successful, the build folder will be automatically generated under the project, which contains compressed packages of various architectures of the current branch. -## 使用 (以 Linux amd64 服务器为例) +## Usage (for Linux amd64 server) -1.拷贝压缩包文件到对应的服务器 +1. Copy the compressed package file to the corresponding server ``` -通过 make 构建默认的压缩包,文件名如下: +Build the default compressed package through make, the file name is as follows: koko-[branch name]-[commit]-linux-amd64.tar.gz ``` -2.解压编译的压缩包 +2. Unzip the compiled compressed package ```shell tar xzvf koko-[branch name]-[commit]-linux-amd64.tar.gz ``` -3.创建配置文件config.yml,配置参数请参考[config_example.yml](https://github.com/jumpserver/koko/blob/master/config_example.yml)文件 +3. Create the file `config.yml`, refer to [config_example.yml](https://github.com/jumpserver/koko/blob/master/config_example.yml) ```shell touch config.yml ``` -4.运行koko +4. run koko ```shell cd koko-[branch name]-[commit]-linux-amd64 @@ -59,9 +59,35 @@ cd koko-[branch name]-[commit]-linux-amd64 ``` -## 构建docker镜像 +## Setup development environment + +1. Run the backend server + +```shell + +$ cp config_example.yml config.yml # 1. Prepare the configuration file +$ vim config.yml # 2. Modify the configuration file, edit the address and bootstrap key +CORE_HOST: http://127.0.0.1:8080 +BOOTSTRAP_TOKEN: PleaseChangeMe + +$ go run ./cmd/koko/ # 3. Run, running requires go if not, download and install from go.dev +``` + + +2. Run the ui frontend + +```shell +$ cd ui +$ yarn install +$ npm run serve +``` + +## Docker +To build multi-platform images using Docker Buildx, you need to install Docker version 19.03 or higher and enable the Docker Buildx plugin. ```shell make docker ``` -构建成功后,生成koko镜像 + +## Acknowledgments +This project depends on [usql](https://github.com/xo/usql) for database connections. We appreciate their support. diff --git a/README_zh-CN.md b/README_zh-CN.md new file mode 100644 index 000000000..9540498e8 --- /dev/null +++ b/README_zh-CN.md @@ -0,0 +1,96 @@ + +# KoKo + +**简体中文** · [English](./README.md) + +Koko 是 JumpServer 连接字符协议的终端组件,支持 SSH、TELNET、MySQL、Redis 等协议。 + +Koko 使用 Golang 和 Vue 来实现,名字来自 Dota 英雄 [Kunkka](https://www.dota2.com.cn/hero/kunkka)。 + +## 主要功能 + + +- SSH +- SFTP +- web terminal +- web文件管理 + + +## 安装 + +1.下载项目 + +```shell +git clone https://github.com/jumpserver/koko.git +``` + +2.编译应用 + +在 koko 项目下构建应用. +```shell +make +``` +> 如果构建成功,会在项目下自动生成 build 文件夹,里面包含当前分支各种架构版本的压缩包。 +默认构建的 VERSION 为 [branch name]-[commit]。 +因为使用go mod进行依赖管理,可以设置环境变量 GOPROXY=https://goproxy.io 代理下载部分依赖包。 + +## 使用 (以 Linux amd64 服务器为例) + +1.拷贝压缩包文件到对应的服务器 + +``` +通过 make 构建默认的压缩包,文件名如下: +koko-[branch name]-[commit]-linux-amd64.tar.gz +``` + +2.解压编译的压缩包 +```shell +tar xzvf koko-[branch name]-[commit]-linux-amd64.tar.gz +``` + +3.创建配置文件config.yml,配置参数请参考[config_example.yml](https://github.com/jumpserver/koko/blob/master/config_example.yml)文件 +```shell +touch config.yml +``` + +4.运行koko +```shell +cd koko-[branch name]-[commit]-linux-amd64 + +./koko +``` + + +## 开发环境 + +1. 运行 server 后端 + +```shell + +$ cp config_example.yml config.yml # 1. 准备配置文件 +$ vim config.yml # 2. 修改配置文件, 编辑其中的地址 和 bootstrap key +CORE_HOST: http://127.0.0.1:8080 +BOOTSTRAP_TOKEN: PleaseChangeMe<改成和core一样的> + +$ go run cmd/koko/koko.go # 3. 运行, 运行需要 go 如果没有,golang.org 下载安装 +``` + + +2. 运行 ui 前端 + +```shell +$ cd ui +$ yarn install +$ npm run serve +``` + +3. 测试 +在 luna 访问 linux 资产,复制 iframe 地址,端口修改为 9530 即可,也可以修改 nginx 将 /koko 映射到这里 + +## 构建docker镜像 +依赖 docker buildx 构建多平台镜像,需要安装 docker 19.03+ 版本,并开启 docker buildx 插件。 + +```shell +make docker +``` +构建成功后,生成koko镜像 diff --git a/assets.go b/assets.go new file mode 100644 index 000000000..249d38ef7 --- /dev/null +++ b/assets.go @@ -0,0 +1,12 @@ +package koko + +import "embed" + +//go:embed static/* +var StaticFs embed.FS + +//go:embed ui/dist/* +var UIFs embed.FS + +//go:embed templates/* +var TemplateFs embed.FS diff --git a/cmd/i18ntool/geni18n.go b/cmd/i18ntool/geni18n.go index a2a30dfee..4e146645b 100644 --- a/cmd/i18ntool/geni18n.go +++ b/cmd/i18ntool/geni18n.go @@ -59,6 +59,9 @@ func getDomainFile(domain string) *os.File { } // If the file doesn't exist, create it. + if _, err := os.Stat(*outputDir); err != nil { + _ = os.MkdirAll(*outputDir, 0755) + } filePath := path.Join(*outputDir, domain+".po") f, err := os.OpenFile(filePath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { diff --git a/cmd/koko/koko.go b/cmd/koko/koko.go index 0a87bb534..73eaa67c7 100644 --- a/cmd/koko/koko.go +++ b/cmd/koko/koko.go @@ -3,52 +3,23 @@ package main import ( "flag" "fmt" - "io/ioutil" - "log" - "os" - "strconv" - "syscall" - - "github.com/sevlyar/go-daemon" + "time" "github.com/jumpserver/koko/pkg/koko" ) -func startAsDaemon() { - ctx := &daemon.Context{ - PidFileName: "/tmp/koko.pid", - PidFilePerm: 0644, - Umask: 027, - WorkDir: "./", - } - child, err := ctx.Reborn() - if err != nil { - log.Fatalf("run failed: %v", err) - } - if child != nil { - return - } - defer ctx.Release() - koko.RunForever(configPath) -} - var ( Buildstamp = "" Githash = "" Goversion = "" + Version = "unknown" - pidPath = "/tmp/koko.pid" - - daemonFlag = false - runSignalFlag = "start" - infoFlag = false + infoFlag = false configPath = "" ) func init() { - flag.BoolVar(&daemonFlag, "d", false, "start as Daemon") - flag.StringVar(&runSignalFlag, "s", "start", "start | stop") flag.StringVar(&configPath, "f", "config.yml", "config.yml path") flag.BoolVar(&infoFlag, "V", false, "version info") } @@ -56,33 +27,20 @@ func init() { func main() { flag.Parse() if infoFlag { - fmt.Printf("Version: %s\n", koko.Version) + fmt.Printf("Version: %s\n", Version) fmt.Printf("Git Commit Hash: %s\n", Githash) fmt.Printf("UTC Build Time : %s\n", Buildstamp) fmt.Printf("Go Version: %s\n", Goversion) return } - - if runSignalFlag == "stop" { - pid, err := ioutil.ReadFile(pidPath) - if err != nil { - log.Fatal("File not exist") - return - } - pidInt, _ := strconv.Atoi(string(pid)) - err = syscall.Kill(pidInt, syscall.SIGTERM) - if err != nil { - log.Fatalf("Stop failed: %v", err) - } else { - _ = os.Remove(pidPath) - } - return - } - - switch { - case daemonFlag: - startAsDaemon() - default: - koko.RunForever(configPath) - } + fmt.Printf(startWelcomeMsg, time.Now().Format(timeFormat), Version) + koko.RunForever(configPath) } + +const ( + timeFormat = "2006-01-02 15:04:05" + startWelcomeMsg = `%s +KoKo Version %s, more see https://www.jumpserver.com +Quit the server with CONTROL-C. +` +) diff --git a/cmd/koko/koko.service b/cmd/koko/koko.service new file mode 100644 index 000000000..e6bb9b14d --- /dev/null +++ b/cmd/koko/koko.service @@ -0,0 +1,15 @@ +[Unit] +Description=JumpServer KoKo Service +After=network.target + +[Service] +Type=simple +User=root +Group=root + +WorkingDirectory=/opt/koko/ +ExecStart=/opt/koko/koko -f config.yml +Restart=on-failure + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/cmd/kubectl/kubectl.go b/cmd/kubectl/kubectl.go deleted file mode 100644 index 7188350a1..000000000 --- a/cmd/kubectl/kubectl.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/exec" - "os/signal" - "strings" - - "github.com/jumpserver/koko/pkg/config" - "github.com/jumpserver/koko/pkg/utils" -) - -const ( - commandName = "rawkubectl" - envName = "K8S_ENCRYPTED_TOKEN" -) - -func main() { - gracefulStop := make(chan os.Signal, 1) - // Ctrl + C 中断操作特殊处理,防止命令无法终止 - signal.Notify(gracefulStop, os.Interrupt) - go func() { - <-gracefulStop - // 增加换行符 - fmt.Println("") - os.Exit(1) - }() - - encryptToken := os.Getenv(envName) - var token string - if encryptToken != "" { - token, _ = utils.Decrypt(encryptToken, config.CipherKey) - } - - args := os.Args[1:] - var s strings.Builder - for i := range args { - s.WriteString(args[i]) - s.WriteString(" ") - } - commandPrefix := commandName - if token != "" { - token = strings.ReplaceAll(token, "'", "") - commandPrefix = fmt.Sprintf(`%s --token='%s'`, commandName, token) - } - - commandString := fmt.Sprintf("%s %s", commandPrefix, s.String()) - c := exec.Command("bash", "-c", commandString) - c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr - _ = c.Run() -} diff --git a/config_example.yml b/config_example.yml index 829cc7782..28cc50614 100644 --- a/config_example.yml +++ b/config_example.yml @@ -23,6 +23,9 @@ BOOTSTRAP_TOKEN: # SSH连接超时时间 (default 15 seconds) # SSH_TIMEOUT: 15 +# Api Http请求的超时时间 (default 30 seconds) +# HTTP_REQUEST_TIMEOUT: 30 + # 语言 [en,zh] # LANGUAGE_CODE: zh diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 000000000..0c583a591 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,30 @@ +variable "VERSION" { + default = "dev" +} + +variable "PUSH_ENABLED" { + default = false +} + +group "default" { + targets = ["ce"] +} + +target "ce" { + dockerfile = "Dockerfile" + tags = ["jumpserver/koko:${VERSION}-ce"] + output = PUSH_ENABLED ? ["type=registry"] : ["type=docker"] +} + +target "ee" { + dockerfile = "Dockerfile-ee" + tags = ["jumpserver/koko:${VERSION}-ee"] + contexts = { + "jumpserver/koko:${VERSION}-ce" = "target:ce" + } + output = PUSH_ENABLED ? ["type=registry"] : ["type=docker"] + args = { + VERSION = "${VERSION}" + } + VERSION = "${VERSION}" +} \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh index d11875706..674929adf 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,11 +1,27 @@ #!/bin/sh # -while [ "$(curl -I -m 10 -o /dev/null -s -w %{http_code} ${CORE_HOST}/api/health/)" != "200" ] -do - echo "wait for jms_core $CORE_HOST ready" - sleep 2 -done - -cd /opt/koko -./koko +if [ -n "$CORE_HOST" ]; then + until check ${CORE_HOST}/api/health/; do + echo "wait for jms_core ${CORE_HOST} ready" + sleep 2 + done +fi + +: ${LOG_LEVEL:='ERROR'} + +echo +date +echo "KoKo Version $VERSION, more see https://www.jumpserver.com" +echo "Quit the server with CONTROL-C." +echo + +# 创建 server.key server.crt +if [ ! -f /opt/koko/server.key ]; then + openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout /opt/koko/server.key -out /opt/koko/server.crt -subj "/C=CN/ST=Beijing/L=Beijing/O=JumpServer/OU=JumpServer/CN=JumpServer" +fi + +# /opt/koko to 700 disable other user access +chmod 700 /opt/koko + +exec "$@" \ No newline at end of file diff --git a/go.mod b/go.mod index b0a1019d1..809752e7c 100644 --- a/go.mod +++ b/go.mod @@ -1,116 +1,150 @@ module github.com/jumpserver/koko -go 1.17 +go 1.24.6 require ( - github.com/Azure/azure-storage-blob-go v0.6.0 - github.com/LeeEirc/elfinder v0.0.14 - github.com/LeeEirc/tclientlib v0.0.1 - github.com/LeeEirc/terminalparser v0.0.0-20210105090630-135adbff588a - github.com/aliyun/aliyun-oss-go-sdk v1.9.8 - github.com/aws/aws-sdk-go v1.19.46 - github.com/creack/pty v1.1.11 - github.com/denisenkom/go-mssqldb v0.11.0 + github.com/Azure/azure-storage-blob-go v0.15.0 + github.com/LeeEirc/elfinder v0.0.15 + github.com/LeeEirc/tclientlib v0.0.3-0.20230803101925-fb52a90cb08d + github.com/LeeEirc/terminalparser v0.0.0-20251128105433-6b0450c643a3 + github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible + github.com/aws/aws-sdk-go v1.55.8 + github.com/creack/pty v1.1.24 github.com/elastic/go-elasticsearch/v6 v6.8.5 - github.com/gin-gonic/gin v1.7.7 + github.com/elastic/go-elasticsearch/v8 v8.14.0 + github.com/gin-gonic/gin v1.10.1 github.com/gliderlabs/ssh v0.3.3 - github.com/go-sql-driver/mysql v1.6.0 - github.com/gorilla/websocket v1.4.2 - github.com/huaweicloud/huaweicloud-sdk-go-obs v3.21.1+incompatible + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 + github.com/huaweicloud/huaweicloud-sdk-go-obs v3.25.4+incompatible + github.com/influxdata/influxdb-client-go/v2 v2.14.0 github.com/jarcoal/httpmock v1.0.4 + github.com/jumpserver-dev/sdk-go v0.0.0-20251124103107-b0606d78540f github.com/leonelquinteros/gotext v1.4.0 github.com/mediocregopher/radix/v3 v3.8.0 - github.com/olekukonko/tablewriter v0.0.1 - github.com/pires/go-proxyproto v0.0.0-20190615163442-2c19fd512994 - github.com/pkg/sftp v1.12.0 - github.com/satori/go.uuid v1.2.0 - github.com/sevlyar/go-daemon v0.1.5 - github.com/shirou/gopsutil/v3 v3.20.11 - github.com/sirupsen/logrus v1.4.2 - github.com/spf13/viper v1.7.1 - github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca + github.com/olekukonko/tablewriter v0.0.5 + github.com/pires/go-proxyproto v0.8.1 + github.com/pkg/sftp v1.13.10 + github.com/sashabaranov/go-openai v1.40.2 + github.com/shirou/gopsutil/v3 v3.24.5 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/viper v1.18.2 + github.com/xlab/treeprint v1.1.0 go.mongodb.org/mongo-driver v1.8.3 - golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 - golang.org/x/text v0.3.7 - gopkg.in/natefinch/lumberjack.v2 v2.0.0-20170531160350-a96e63847dc3 - gopkg.in/twindagger/httpsig.v1 v1.2.0 - k8s.io/api v0.23.1 - k8s.io/client-go v0.23.1 + golang.org/x/crypto v0.45.0 + golang.org/x/term v0.37.0 + golang.org/x/text v0.31.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 + k8s.io/api v0.34.0 + k8s.io/apimachinery v0.34.0 + k8s.io/client-go v0.34.0 ) require ( - github.com/Azure/azure-pipeline-go v0.1.9 // indirect - github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect + github.com/Azure/azure-pipeline-go v0.2.3 // indirect + github.com/LeeEirc/httpsig v1.2.1 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect - github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/fsnotify/fsnotify v1.4.9 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect - github.com/go-logr/logr v1.2.0 // indirect - github.com/go-ole/go-ole v1.2.4 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/danielgatis/go-utf8 v1.0.1 // indirect + github.com/danielgatis/go-vte v1.0.9 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/elastic/elastic-transport-go/v8 v8.6.0 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-playground/form v3.1.4+incompatible // indirect - github.com/go-playground/locales v0.14.0 // indirect - github.com/go-playground/universal-translator v0.18.0 // indirect - github.com/go-playground/validator/v10 v10.9.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-stack/stack v1.8.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect - github.com/golang/protobuf v1.5.2 // indirect - github.com/golang/snappy v0.0.1 // indirect - github.com/google/go-cmp v0.5.5 // indirect - github.com/google/gofuzz v1.1.0 // indirect - github.com/googleapis/gnostic v0.5.5 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af // indirect + github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect - github.com/klauspost/compress v1.13.6 // indirect - github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kr/fs v0.1.0 // indirect - github.com/leodido/go-urn v1.2.1 // indirect - github.com/magiconair/properties v1.8.1 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect - github.com/mattn/go-runewidth v0.0.9 // indirect - github.com/mitchellh/mapstructure v1.1.2 // indirect - github.com/moby/spdystream v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-ieproxy v0.0.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml v1.2.0 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/oapi-codegen/runtime v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/spf13/afero v1.2.2 // indirect - github.com/spf13/cast v1.3.0 // indirect - github.com/spf13/jwalterweatherman v1.0.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/subosito/gotenv v1.2.0 // indirect - github.com/ugorji/go/codec v1.2.6 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.0.2 // indirect github.com/xdg-go/stringprep v1.0.2 // indirect github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect - golang.org/x/net v0.0.0-20211209124913-491a49abca63 // indirect - golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f // indirect - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect - golang.org/x/sys v0.0.0-20211111213525-f221eed1c01e // indirect - golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b // indirect - golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect - golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.27.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/exp v0.0.0-20240529005216-23cca8864a10 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect + google.golang.org/protobuf v1.36.7 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/go-playground/assert.v1 v1.2.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/ini.v1 v1.51.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect - k8s.io/apimachinery v0.23.1 // indirect - k8s.io/klog/v2 v2.30.0 // indirect - k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 // indirect - k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b // indirect - sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.1.2 // indirect - sigs.k8s.io/yaml v1.2.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) -replace ( - github.com/gliderlabs/ssh v0.3.3 => github.com/LeeEirc/ssh v0.1.2-0.20220323091501-23b956e1e5a8 - golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 => github.com/LeeEirc/crypto v0.0.0-20220323080723-e1953f456d73 -) +replace github.com/gliderlabs/ssh => github.com/jumpserver-dev/ssh v0.3.10 diff --git a/go.sum b/go.sum index 7d9684faa..1d51734ed 100644 --- a/go.sum +++ b/go.sum @@ -1,913 +1,423 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/Azure/azure-pipeline-go v0.1.8/go.mod h1:XA1kFWRVhSK+KNFiOhfv83Fv8L9achrP7OxIzeTn1Yg= -github.com/Azure/azure-pipeline-go v0.1.9 h1:u7JFb9fFTE6Y/j8ae2VK33ePrRqJqoCM/IWkQdAZ+rg= -github.com/Azure/azure-pipeline-go v0.1.9/go.mod h1:XA1kFWRVhSK+KNFiOhfv83Fv8L9achrP7OxIzeTn1Yg= -github.com/Azure/azure-storage-blob-go v0.6.0 h1:SEATKb3LIHcaSIX+E6/K4kJpwfuozFEsmt5rS56N6CE= -github.com/Azure/azure-storage-blob-go v0.6.0/go.mod h1:oGfmITT1V6x//CswqY2gtAHND+xIP64/qL7a5QJix0Y= +github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVtCdfBE21U= +github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k= +github.com/Azure/azure-storage-blob-go v0.15.0 h1:rXtgp8tN1p29GvpGgfJetavIG0V7OgcSXPpwp3tx6qk= +github.com/Azure/azure-storage-blob-go v0.15.0/go.mod h1:vbjsVbX0dlxnRc4FFMPsS9BsJWPcne7GB7onqlPvz58= +github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= -github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= +github.com/Azure/go-autorest/autorest/adal v0.9.13 h1:Mp5hbtOePIzM8pJVRa3YLrWWmZtoxRXqUEzCfJt3+/Q= github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= +github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw= github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg= github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= -github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/LeeEirc/crypto v0.0.0-20220323080723-e1953f456d73 h1:Qupe4H1YuSuuChXQpINj8awZAI84jMQwkArVlt0J5k4= -github.com/LeeEirc/crypto v0.0.0-20220323080723-e1953f456d73/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -github.com/LeeEirc/elfinder v0.0.14 h1:6ObxwIoC5zmrnKArUU5Mz++/T3lzgl1Ja0pS1Smd3j4= -github.com/LeeEirc/elfinder v0.0.14/go.mod h1:d1bMAAydkZSBxSN/EuQjBg6B0xcPP3boHuYEpzEHYTs= -github.com/LeeEirc/ssh v0.1.2-0.20220323091501-23b956e1e5a8 h1:UxED5pKJd9yel/LXEUHDn8C+pYhDogxwx7G9HZcov4w= -github.com/LeeEirc/ssh v0.1.2-0.20220323091501-23b956e1e5a8/go.mod h1:bSl4MzlGJ2FbMCzfyuwruG2mrWY0dxE8wqWoAIhKe8k= -github.com/LeeEirc/tclientlib v0.0.1 h1:UXBYJyBUjgsgkVlX+7Qq/TQ05Yn41+fnQ8pIQAQMBzU= -github.com/LeeEirc/tclientlib v0.0.1/go.mod h1:TF2v0XZYyRcZfx4NmA/EEFRkdKZLsQd8YnlhGKl1KUA= -github.com/LeeEirc/terminalparser v0.0.0-20210105090630-135adbff588a h1:bCk3bTkRdgN3Kdx0eco+cNZchIS7gIqMge2qbdMIAKU= -github.com/LeeEirc/terminalparser v0.0.0-20210105090630-135adbff588a/go.mod h1:tiLv6VBLH4Z3KdBSe2qIKRwQDGCVQ9/F5fOKpQGvyoA= -github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= -github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d h1:G0m3OIz70MZUWq3EgK3CesDbo8upS2Vm9/P3FtgI+Jk= -github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/aliyun/aliyun-oss-go-sdk v1.9.8 h1:BOflvK0Zs/zGmoabyFIzTg5c3kguktWTXEwewwbuba0= -github.com/aliyun/aliyun-oss-go-sdk v1.9.8/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= +github.com/LeeEirc/elfinder v0.0.15 h1:ZnBJqkcbyt6zUgcGzhPHwsa88k0lhbNOa5rVsoJTG9s= +github.com/LeeEirc/elfinder v0.0.15/go.mod h1:d1bMAAydkZSBxSN/EuQjBg6B0xcPP3boHuYEpzEHYTs= +github.com/LeeEirc/httpsig v1.2.1 h1:GGmCc2Bug3KeCchlZHwrfyjyAnw+JlzMjKDobPypirs= +github.com/LeeEirc/httpsig v1.2.1/go.mod h1:aoLZLXCSNDgkzsH2sGLWn3hlVbF+Voe8fCArxLt9nWA= +github.com/LeeEirc/tclientlib v0.0.3-0.20230803101925-fb52a90cb08d h1:4qUSGc/34IALiDs2kBrjbCKfx7zvAt16K+gTRzNN8Fo= +github.com/LeeEirc/tclientlib v0.0.3-0.20230803101925-fb52a90cb08d/go.mod h1:TF2v0XZYyRcZfx4NmA/EEFRkdKZLsQd8YnlhGKl1KUA= +github.com/LeeEirc/terminalparser v0.0.0-20251128105433-6b0450c643a3 h1:73x+lau2Wd79oZwV+PWGLGtPE6zgwwCdb4SPA74z174= +github.com/LeeEirc/terminalparser v0.0.0-20251128105433-6b0450c643a3/go.mod h1:+Qo7bjL1vsdwoGMGOceOoSyap+OKyBA3pI2cTC1aS/s= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g= +github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= -github.com/aws/aws-sdk-go v1.19.46 h1:lRqljzjkGmEeiawkw4z1QgtCnU/S5Jw8lNeUuvmydUQ= -github.com/aws/aws-sdk-go v1.19.46/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f h1:ZNv7On9kyUzm7fvRZumSyy/IUiSC7AzL0I1jKKtwooA= -github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= -github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= +github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= -github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danielgatis/go-utf8 v1.0.1 h1:0tXC3eI9I+11X3DwF46Auqjcq1KrsQCQsaI8k/ymGhU= +github.com/danielgatis/go-utf8 v1.0.1/go.mod h1:TGCny5E5wwT7AHD5cAj941LnjZLTqcHXlK4Zq0FqDPw= +github.com/danielgatis/go-vte v1.0.9 h1:Y0w8Cwb4ivoOoVksNnmvwwA1LNEDEKWJgoSCYh/xj+Q= +github.com/danielgatis/go-vte v1.0.9/go.mod h1:ITnFCD8DqOM9TPhxvxcGABmbcUfC1yJsb3e+l2NCKJQ= 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/denisenkom/go-mssqldb v0.11.0 h1:9rHa233rhdOyrz2GcP9NM+gi2psgJZ4GWDpL/7ND8HI= -github.com/denisenkom/go-mssqldb v0.11.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= -github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= -github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/elastic/elastic-transport-go/v8 v8.6.0 h1:Y2S/FBjx1LlCv5m6pWAF2kDJAHoSjSRSJCApolgfthA= +github.com/elastic/elastic-transport-go/v8 v8.6.0/go.mod h1:YLHer5cj0csTzNFXoNQ8qhtGY1GTvSqPnKWKaqQE3Hk= github.com/elastic/go-elasticsearch/v6 v6.8.5 h1:U2HtkBseC1FNBmDr0TR2tKltL6FxoY+niDAlj5M8TK8= github.com/elastic/go-elasticsearch/v6 v6.8.5/go.mod h1:UwaDJsD3rWLM5rKNFzv9hgox93HoX8utj1kxD9aFUcI= -github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153 h1:yUdfgN0XgIJw7foRItutHYUIhlcKzcSf5vDpdhQAKTc= -github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= -github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/elastic/go-elasticsearch/v8 v8.14.0 h1:1ywU8WFReLLcxE1WJqii3hTtbPUE2hc38ZK/j4mMFow= +github.com/elastic/go-elasticsearch/v8 v8.14.0/go.mod h1:WRvnlGkSuZyp83M2U8El/LGXpCjYLrvlkSgkAH4O5I4= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= -github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/getkin/kin-openapi v0.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs= -github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= -github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= -github.com/go-logr/logr v1.2.0 h1:QK40JKJyMdUDz+h+xvCsru/bJhvG0UxvePV0ufL/AcE= -github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI= -github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= -github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= -github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/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-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +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/form v3.1.4+incompatible h1:lvKiHVxE2WvzDIoyMnWcjyiBxKt2+uFJyZcPYWsLnjI= github.com/go-playground/form v3.1.4+incompatible/go.mod h1:lhcKXfTuhRtIZCIKUeJ0b5F207aeQCPbZU09ScKjwWg= -github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= -github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= -github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= -github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= -github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= -github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= -github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= -github.com/go-playground/validator/v10 v10.9.0 h1:NgTtmN58D0m8+UuxtYmGztBJB7VnPgjj221I1QHci2A= -github.com/go-playground/validator/v10 v10.9.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +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= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/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/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= -github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU= -github.com/googleapis/gnostic v0.5.5 h1:9fHAtK0uDfpveeqqo1hkEZJcFvYXAiCN3UutL8F9xHw= -github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huaweicloud/huaweicloud-sdk-go-obs v3.21.1+incompatible h1:EFjtiulITiEktaZrr0OPlymTmrlpvSAa/xvv08kTQEU= -github.com/huaweicloud/huaweicloud-sdk-go-obs v3.21.1+incompatible/go.mod h1:l7VUhRbTKCzdOacdT4oWCwATKyvZqUOlOqr0Ous3k4s= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/huaweicloud/huaweicloud-sdk-go-obs v3.25.4+incompatible h1:yNjwdvn9fwuN6Ouxr0xHM0cVu03YMUWUyFmu2van/Yc= +github.com/huaweicloud/huaweicloud-sdk-go-obs v3.25.4+incompatible/go.mod h1:l7VUhRbTKCzdOacdT4oWCwATKyvZqUOlOqr0Ous3k4s= +github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= +github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI= +github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf h1:7JTmneyiNEwVBOHSjoMxiWAqB992atOeepeFYegn5RU= +github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= github.com/jarcoal/httpmock v1.0.4 h1:jp+dy/+nonJE4g4xbVtl9QdrUNbn6/3hDT5R4nDIZnA= github.com/jarcoal/httpmock v1.0.4/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= -github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= -github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= -github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= -github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/jumpserver-dev/sdk-go v0.0.0-20251124103107-b0606d78540f h1:e/bXeiA4Hrc6QsVoRxHCg5sW5hEDZFtkjRih6ThSSfQ= +github.com/jumpserver-dev/sdk-go v0.0.0-20251124103107-b0606d78540f/go.mod h1:CDNCGOGgeudYS4IKsHOG+ma1rBSbjv8toKtRax2Z0EE= +github.com/jumpserver-dev/ssh v0.3.10 h1:0KOrEqcYV2Bzrc8sfWhMyL9ze8XQcKzAA9MdSbAJUxs= +github.com/jumpserver-dev/ssh v0.3.10/go.mod h1:QIQNh76fVXsi6H3NXi/S9Gvfu2GYxV0H5a4Sf/ujxrg= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= -github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +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/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +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.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= -github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= -github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +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/leonelquinteros/gotext v1.4.0 h1:2NHPCto5IoMXbrT0bldPrxj0qM5asOCwtb1aUQZ1tys= github.com/leonelquinteros/gotext v1.4.0/go.mod h1:yZGXREmoGTtBvZHNcc+Yfug49G/2spuF/i/Qlsvz1Us= -github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= -github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-ieproxy v0.0.1 h1:qiyop7gCflfhwCzGyeT0gro3sF9AIg9HU98JORTkqfI= +github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E= +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/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mediocregopher/radix/v3 v3.8.0 h1:HI8EgkaM7WzsrFpYAkOXIgUKbjNonb2Ne7K6Le61Pmg= github.com/mediocregopher/radix/v3 v3.8.0/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= -github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= -github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88= -github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= -github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.14.0 h1:2mOpI4JVVPBN+WQRa0WKH2eXR+Ey+uK4n7Zj0aYpIQA= -github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= -github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= -github.com/pires/go-proxyproto v0.0.0-20190615163442-2c19fd512994 h1:3ssKn22MN6oLH+l2iimsBdCliSgELXTBWWR+yooB2lQ= -github.com/pires/go-proxyproto v0.0.0-20190615163442-2c19fd512994/go.mod h1:6/gX3+E/IYGa0wMORlSMla999awQFdbaeQCHjSMKIzY= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= +github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.12.0 h1:/f3b24xrDhkhddlaobPe2JgBqfdt+gC/NYl0QY9IOuI= -github.com/pkg/sftp v1.12.0/go.mod h1:fUqqXB5vEgVCZ131L+9say31RAri6aF6KDViawhxKK8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= -github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= -github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sevlyar/go-daemon v0.1.5 h1:Zy/6jLbM8CfqJ4x4RPr7MJlSKt90f00kNM1D401C+Qk= -github.com/sevlyar/go-daemon v0.1.5/go.mod h1:6dJpPatBT9eUwM5VCw9Bt6CdX9Tk6UWvhW3MebLDRKE= -github.com/shirou/gopsutil/v3 v3.20.11 h1:NeVf1K0cgxsWz+N3671ojRptdgzvp7BXL3KV21R0JnA= -github.com/shirou/gopsutil/v3 v3.20.11/go.mod h1:igHnfak0qnw1biGeI2qKQvu0ZkwvEkUcCLlYhZzdr/4= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= -github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= -github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk= -github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= -github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sashabaranov/go-openai v1.40.2 h1:IALpUnkdy6BDp2ZSAiD4vz+C2wpiKOlfUQcViLrfTOk= +github.com/sashabaranov/go-openai v1.40.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= -github.com/ugorji/go v1.2.6 h1:tGiWC9HENWE2tqYycIqFTNorMmFRVhNwCpDOpWqnk8E= -github.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0= -github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= -github.com/ugorji/go/codec v1.2.6 h1:7kbGefxLoDBuYXOms4yD7223OpNMMPNPZxXk5TvFcyQ= -github.com/ugorji/go/codec v1.2.6/go.mod h1:V6TCNZ4PHqoHGFZuSG1W8nrCzzdgA2DozYxWFFpvxTw= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.0.2 h1:akYIkZ28e6A96dkWNJQu3nmCzH3YfwMPQExUYDaRv7w= github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca h1:1CFlNzQhALwjS9mBAUkycX616GzgsuYUOCHA5+HSlXI= -github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg= +github.com/xlab/treeprint v1.1.0 h1:G/1DjNkPpfZCFt9CSh6b5/nY4VimlbHF3Rh4obvtzDk= +github.com/xlab/treeprint v1.1.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.mongodb.org/mongo-driver v1.8.3 h1:TDKlTkGDKm9kkJVUOAXDK5/fkqKHJVwYQSpoRfB43R4= go.mongodb.org/mongo-driver v1.8.3/go.mod h1:0sQWfOeY63QTntERDJJ/0SuKK0T1uVSgKCuAROlKEPY= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8= +go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +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.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20240529005216-23cca8864a10 h1:vpzMC/iZhYFAjJzHU0Cfuq+w1vLLsF2vLkDrPjzKYck= +golang.org/x/exp v0.0.0-20240529005216-23cca8864a10/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191112182307-2180aed22343/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211209124913-491a49abca63 h1:iocB37TsdFuN6IBRZ+ry36wrkoV51/tl5vOWqkcPGvY= -golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f h1:Qmd2pbz05z7z6lm0DrgQVVPuBm92jqujBKMHMOlOQEw= -golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201024232916-9f70ab9862d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211111213525-f221eed1c01e h1:zeJt6jBtVDK23XK9QXcmG0FvO0elikp0dYZQZOeL1y0= -golang.org/x/sys v0.0.0-20211111213525-f221eed1c01e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE= -golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs= -golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.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/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= -gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/natefinch/lumberjack.v2 v2.0.0-20170531160350-a96e63847dc3 h1:AFxeG48hTWHhDTQDk/m2gorfVHUEa9vo3tp3D7TzwjI= -gopkg.in/natefinch/lumberjack.v2 v2.0.0-20170531160350-a96e63847dc3/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= -gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -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/twindagger/httpsig.v1 v1.2.0 h1:GHT8oYp1sdRKr89MYwpixUcDOx4iEY5EO/Rk+A5FenY= -gopkg.in/twindagger/httpsig.v1 v1.2.0/go.mod h1:J1gOUnY2juidmnrHbYPnCoTacqx3oIAUsyKfASUXlU8= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -k8s.io/api v0.23.1 h1:ncu/qfBfUoClqwkTGbeRqqOqBCRoUAflMuOaOD7J0c8= -k8s.io/api v0.23.1/go.mod h1:WfXnOnwSqNtG62Y1CdjoMxh7r7u9QXGCkA1u0na2jgo= -k8s.io/apimachinery v0.23.1 h1:sfBjlDFwj2onG0Ijx5C+SrAoeUscPrmghm7wHP+uXlo= -k8s.io/apimachinery v0.23.1/go.mod h1:SADt2Kl8/sttJ62RRsi9MIV4o8f5S3coArm0Iu3fBno= -k8s.io/client-go v0.23.1 h1:Ma4Fhf/p07Nmj9yAB1H7UwbFHEBrSPg8lviR24U2GiQ= -k8s.io/client-go v0.23.1/go.mod h1:6QSI8fEuqD4zgFK0xbdwfB/PthBsIxCJMa3s17WlcO0= -k8s.io/gengo v0.0.0-20210813121822-485abfe95c7c/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= -k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= -k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= -k8s.io/klog/v2 v2.30.0 h1:bUO6drIvCIsvZ/XFgfxoGFQU/a4Qkh0iAlvUR7vlHJw= -k8s.io/klog/v2 v2.30.0/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= -k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 h1:E3J9oCLlaobFUqsjG9DfKbP2BmgwBL2p7pn0A3dG9W4= -k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65/go.mod h1:sX9MT8g7NVZM5lVL/j8QyCCJe8YSMW30QvGZWaCIDIk= -k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= -k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b h1:wxEMGetGMur3J1xuGLQY7GEQYg9bZxKn3tKo5k/eYcs= -k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6 h1:fD1pz4yfdADVNfFmcP2aBEtudwUQ1AlLnRBALr33v3s= -sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6/go.mod h1:p4QtZmO4uMYipTQNzagwnNoseA6OxSUutVw05NhYDRs= -sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= -sigs.k8s.io/structured-merge-diff/v4 v4.1.2 h1:Hr/htKFmJEbtMgS/UD0N+gtgctAqz81t3nu+sPzynno= -sigs.k8s.io/structured-merge-diff/v4 v4.1.2/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4= -sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= -sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= +k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= +k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= +k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= +k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/locale/en_US/LC_MESSAGES/koko.mo b/locale/en/LC_MESSAGES/koko.mo similarity index 100% rename from locale/en_US/LC_MESSAGES/koko.mo rename to locale/en/LC_MESSAGES/koko.mo diff --git a/locale/en/LC_MESSAGES/koko.po b/locale/en/LC_MESSAGES/koko.po new file mode 100644 index 000000000..c4cc0f6fa --- /dev/null +++ b/locale/en/LC_MESSAGES/koko.po @@ -0,0 +1,586 @@ +msgid "" +msgstr "" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: xgotext\n" + +#. lang.T +#: pkg/handler/app_database.go:11 +msgid "No Databases" +msgstr "" + +#. lang.T +#: pkg/handler/app_k8s.go:15 +msgid "No kubernetes" +msgstr "" + +#. lang.T +#: pkg/handler/app_k8s.go:31 +msgid "Page: %d, Count: %d, Total Page: %d, Total Count: %d" +msgstr "" + +#. lang.T +#: pkg/handler/app_k8s.go:45 +msgid "" +"Enter ID number directly login, multiple search use // + field, such as: //16" +msgstr "" + +#. lang.T +#: pkg/handler/app_k8s.go:46 +msgid "Page up: b\tPage down: n" +msgstr "" + +#. lang.T +#: pkg/handler/asset.go:45 +msgid "No Assets" +msgstr "" + +#. lang.T +#: pkg/handler/asset.go:57 +msgid "ID" +msgstr "" + +#. lang.T +#: pkg/handler/asset.go:58 +msgid "Name" +msgstr "" + +#. lang.T +#: pkg/handler/asset.go:59 +msgid "Address" +msgstr "" + +#. lang.T +#: pkg/handler/asset.go:60 +msgid "Platform" +msgstr "" + +#. lang.T +#: pkg/handler/asset.go:61 +msgid "Organization" +msgstr "" + +#. lang.T +#: pkg/handler/asset.go:62 +msgid "Comment" +msgstr "" + +#. lang.T +#: pkg/handler/asset.go:176 +msgid "%s protocol client not installed." +msgstr "" + +#. lang.T +#: pkg/handler/asset.go:179 +msgid "" +"Terminal does not support protocol %s, please use web terminal to access" +msgstr "" + +#. lang.T +#: pkg/handler/asset.go:214 +msgid "Core API failed" +msgstr "" + +#. lang.T +#: pkg/handler/asset.go:220 +msgid "ACL reject" +msgstr "" + +#. lang.T +#: pkg/handler/asset.go:226 +msgid "" +"Face ACL is not supported yet. Please use the WebTerminal to connect the " +"asset." +msgstr "" + +#. lang.T +#. lang.T +#: pkg/handler/asset.go:241 pkg/handler/asset.go:250 +msgid "Unknown error code: %s, detail: %s" +msgstr "" + +#. lang.T +#: pkg/handler/asset.go:261 +msgid "get connect token err" +msgstr "" + +#. lang.T +#: pkg/handler/asset_node.go:22 +msgid "%s node has no assets" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:30 +msgid "Welcome to use JumpServer open source fortress system" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "part IP, Hostname, Comment" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "to search login if unique" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "/ + IP, Hostname, Comment" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "to search, such as: /192.168" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:34 +msgid "display the assets you have permission" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:35 +msgid "display the node that you have permission" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:36 +msgid "display the hosts that you have permission" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:37 +msgid "display the databases that you have permission" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:38 +msgid "display the kubernetes that you have permission" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:39 +msgid "refresh your assets and nodes" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:40 +msgid "language switch" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:41 +msgid "print help" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:42 +msgid "exit" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:60 +msgid "\t%2d) Enter {{.GreenBoldColor}}%s{{.ColorEnd}} to %s.%s" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:85 +msgid "Announcement: " +msgstr "" + +#. lang.T +#: pkg/handler/direct_handler.go:272 +msgid "No Account found." +msgstr "" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:282 pkg/handler/direct_handler.go:283 +#: pkg/handler/direct_handler.go:284 +msgid "Username" +msgstr "" + +#. lang.T +#: pkg/handler/direct_handler.go:313 +msgid "Tips: Enter asset[%s] account ID" +msgstr "" + +#. lang.T +#: pkg/handler/direct_handler.go:314 +msgid "Back: B/b" +msgstr "" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:349 pkg/handler/direct_handler.go:350 +msgid "Hostname" +msgstr "" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:351 pkg/handler/direct_handler.go:352 +#: pkg/handler/direct_handler.go:381 +msgid "select one asset to login" +msgstr "" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:395 pkg/handler/direct_handler.go:400 +msgid "not found matched username %s" +msgstr "" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:433 pkg/handler/direct_handler.go:439 +#: pkg/handler/direct_handler.go:445 +msgid "" +"Face verification is not supported yet. Please use the WebTerminal to " +"connect the asset." +msgstr "" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:460 pkg/handler/direct_handler.go:476 +#: pkg/handler/dispatch.go:127 pkg/handler/dispatch.go:128 +#: pkg/handler/dispatch.go:153 +msgid "Tips: switch language by ID (Current session only)" +msgstr "" + +#. lang.T +#: pkg/handler/dispatch.go:154 +msgid "" +"Tips: To set a default language, go to Personal Settings → Preferences on Web" +msgstr "" + +#. lang.T +#. lang.T +#: pkg/handler/dispatch.go:155 pkg/handler/dispatch.go:183 +msgid "Invalid ID" +msgstr "" + +#. lang.T +#: pkg/handler/dispatch.go:189 +msgid "Switch language successfully" +msgstr "" + +#. lang.T +#: pkg/handler/dispatch.go:199 +msgid "Node: [ ID.Name(Asset amount) ]" +msgstr "" + +#. lang.T +#: pkg/handler/dispatch.go:201 +msgid "Tips: Enter g+NodeID to display the host under the node, such as g1" +msgstr "" + +#. lang.T +#: pkg/handler/interactive.go:64 +msgid "Connect idle more than %d minutes, disconnect" +msgstr "" + +#. lang.T +#: pkg/handler/interactive.go:198 +msgid "No account found." +msgstr "" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:208 pkg/handler/interactive.go:209 +#: pkg/handler/interactive.go:210 pkg/handler/interactive.go:240 +#: pkg/handler/interactive.go:241 pkg/handler/interactive.go:267 +msgid "Select account exceed max retry times." +msgstr "" + +#. lang.T +#: pkg/handler/interactive.go:278 +msgid "No protocol found." +msgstr "" + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:287 pkg/handler/interactive.go:288 +msgid "Protocol" +msgstr "" + +#. lang.T +#: pkg/handler/interactive.go:315 +msgid "Tips: Enter protocol ID" +msgstr "" + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:316 pkg/handler/interactive.go:342 +msgid "Select protocol exceed max retry times." +msgstr "" + +#. lang.T +#: pkg/handler/interactive.go:379 +msgid "Refresh done" +msgstr "" + +#. lang.T +#: pkg/handler/login_confirm.go:38 +msgid "Need ACL review, continue? (y/n): " +msgstr "" + +#. lang.T +#: pkg/handler/login_confirm.go:53 +msgid "Cancel to login asset or max 3 retry" +msgstr "" + +#. lang.T +#. lang.T +#: pkg/handler/login_confirm.go:67 pkg/handler/login_confirm.go:98 +msgid "Need ticket confirm to login, already send email to the reviewers" +msgstr "" + +#. lang.T +#: pkg/handler/login_confirm.go:99 +msgid "Ticket Reviewers: %s" +msgstr "" + +#. lang.T +#: pkg/handler/login_confirm.go:100 +msgid "Could copy website URL to notify reviewers: %s" +msgstr "" + +#. lang.T +#: pkg/handler/login_confirm.go:101 +msgid "Please waiting for the reviewers to confirm, enter q to exit. " +msgstr "" + +#. lang.T +#: pkg/handler/login_confirm.go:130 +msgid "Unknown status" +msgstr "" + +#. lang.T +#: pkg/handler/login_confirm.go:134 +msgid "%s approved" +msgstr "" + +#. lang.T +#: pkg/handler/login_confirm.go:139 +msgid "%s rejected" +msgstr "" + +#. lang.T +#: pkg/handler/login_confirm.go:143 +msgid "Cancel confirm" +msgstr "" + +#. lang.T +#: pkg/handler/select_handler.go:208 +msgid "Search: %s" +msgstr "" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:223 +msgid "Must be unique asset for %s" +msgstr "" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:230 pkg/handler/server_ssh.go:252 +msgid "Must be unique account for %s" +msgstr "" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:260 +msgid "Must be auto login account for %s" +msgstr "" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:682 pkg/handler/server_ssh.go:686 +msgid "No found asset" +msgstr "" + +#. i18nLang.T +#. i18nLang.T +#. i18nLang.T +#. lang.T +#: pkg/handler/server_ssh.go:716 pkg/handler/server_ssh.go:721 +#: pkg/handler/server_ssh.go:739 pkg/httpd/userwebsocket.go:110 +msgid "Create k8s client err: %s" +msgstr "" + +#. lang.T +#: pkg/proxy/parser.go:265 +msgid "have no permission to upload file" +msgstr "" + +#. lang.T +#: pkg/proxy/parser.go:301 +msgid "" +"The command you executed is risky and an alert notification will be sent to " +"the administrator. Do you want to continue?[Y/N]" +msgstr "" + +#. lang.T +#: pkg/proxy/parser.go:318 +msgid "The command '%s' requires review. Continue or not [Y/n]?" +msgstr "" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:338 pkg/proxy/parser.go:345 pkg/proxy/parser.go:436 +msgid "Command `%s` is forbidden" +msgstr "" + +#. lang.T +#: pkg/proxy/parser.go:503 +msgid "have no permission to download file" +msgstr "" + +#. lang.T +#: pkg/proxy/parser.go:572 +msgid "" +"Please waiting for the reviewers to confirm command `%s`, cancel by CTRL+C " +"or CTRL+D." +msgstr "" + +#. lang.T +#: pkg/proxy/parser.go:582 +msgid "" +"Need ticket confirm to execute command, already send email to the reviewers" +msgstr "" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:583 pkg/proxy/parser.go:584 pkg/proxy/server.go:58 +#: pkg/proxy/server.go:62 +msgid "" +"HandleTask does not support protocol %s, please use web terminal to access" +msgstr "" + +#. lang.T +#: pkg/proxy/server.go:70 +msgid "Account <%s> and asset <%s> protocol are inconsistent." +msgstr "" + +#. lang.T +#: pkg/proxy/server.go:102 +msgid "You don't have permission login %s" +msgstr "" + +#. lang.T +#: pkg/proxy/server.go:337 +msgid "You get auth token failed" +msgstr "" + +#. lang.T +#: pkg/proxy/server.go:348 +msgid "Get auth password failed" +msgstr "" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:364 pkg/proxy/server.go:421 +msgid "Reuse SSH connections (%s@%s) [Number of connections: %d]" +msgstr "" + +#. lang.T +#: pkg/proxy/server.go:689 +msgid "Switched to %s" +msgstr "" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:791 pkg/proxy/server.go:936 +msgid "Connect with api server failed" +msgstr "" + +#. lang.T +#: pkg/proxy/server.go:997 +msgid "Start domain gateway failed %s" +msgstr "" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:1005 pkg/proxy/server_options.go:108 +msgid "Manual" +msgstr "" + +#. lang.T +#: pkg/proxy/server_options.go:110 +msgid "Dynamic" +msgstr "" + +#. lang.T +#: pkg/proxy/server_options.go:113 +msgid "Connecting to %s@%s" +msgstr "" + +#. lang.T +#: pkg/proxy/server_options.go:117 +msgid "Connecting to Database %s" +msgstr "" + +#. lang.T +#: pkg/proxy/server_options.go:119 +msgid "Connecting to Kubernetes %s" +msgstr "" + +#. lang.T +#: pkg/proxy/server_options.go:121 +msgid "Connecting to Kubernetes %s container %s" +msgstr "" + +#. lang.T +#: pkg/proxy/switch.go:315 +msgid "Session max time reached, disconnect" +msgstr "" + +#. lang.T +#. lang.T +#: pkg/proxy/switch.go:324 pkg/proxy/switch.go:331 +msgid "Permission has expired, disconnect" +msgstr "" + +#. lang.T +#: pkg/proxy/switch.go:341 +msgid "Terminated by admin %s" +msgstr "" + +#. lang.T +#: pkg/proxy/tools.go:28 +msgid "Authentication failed" +msgstr "" + +#. lang.T +#: pkg/proxy/tools.go:31 +msgid "Connection refused" +msgstr "" + +#. lang.T +#: pkg/proxy/tools.go:34 +msgid "i/o timeout" +msgstr "" + +#. lang.T +#: pkg/proxy/tools.go:37 +msgid "No route to host" +msgstr "" + +#. lang.T +#: pkg/proxy/tools.go:40 +msgid "network is unreachable" +msgstr "" diff --git a/locale/en_US/LC_MESSAGES/koko.po b/locale/en_US/LC_MESSAGES/koko.po deleted file mode 100644 index cdcf8d331..000000000 --- a/locale/en_US/LC_MESSAGES/koko.po +++ /dev/null @@ -1,491 +0,0 @@ -msgid "" -msgstr "" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: xgotext\n" - -#. lang.T -#: pkg/handler/app_k8s.go:55 -msgid "No kubernetes" -msgstr "" - -#. lang.T -#: pkg/handler/app_k8s.go:68 -msgid "ID" -msgstr "" - -#. lang.T -#: pkg/handler/app_k8s.go:69 -msgid "Name" -msgstr "" - -#. lang.T -#: pkg/handler/app_k8s.go:70 -msgid "Cluster" -msgstr "" - -#. lang.T -#: pkg/handler/app_k8s.go:71 -msgid "Comment" -msgstr "" - -#. lang.T -#: pkg/handler/app_k8s.go:90 -msgid "Page: %d, Count: %d, Total Page: %d, Total Count: %d" -msgstr "" - -#. lang.T -#: pkg/handler/app_k8s.go:110 -msgid "" -"Enter ID number directly login the kubernetes, multiple search use // + " -"field, such as: //16" -msgstr "" - -#. lang.T -#: pkg/handler/app_k8s.go:111 -msgid "Page up: b\tPage down: n" -msgstr "" - -#. lang.T -#: pkg/handler/app_mysql.go:56 -msgid "No Databases" -msgstr "" - -#. lang.T -#. lang.T -#. lang.T -#: pkg/handler/app_mysql.go:69 pkg/handler/app_mysql.go:70 -#: pkg/handler/app_mysql.go:71 -msgid "IP" -msgstr "" - -#. lang.T -#: pkg/handler/app_mysql.go:72 -msgid "DBType" -msgstr "" - -#. lang.T -#: pkg/handler/app_mysql.go:73 -msgid "DB Name" -msgstr "" - -#. lang.T -#. lang.T -#. lang.T -#: pkg/handler/app_mysql.go:74 pkg/handler/app_mysql.go:96 -#: pkg/handler/app_mysql.go:117 -msgid "" -"Enter ID number directly login the database, multiple search use // + field, " -"such as: //16" -msgstr "" - -#. lang.T -#. lang.T -#: pkg/handler/app_mysql.go:118 pkg/handler/asset.go:56 -msgid "No Assets" -msgstr "" - -#. lang.T -#. lang.T -#: pkg/handler/asset.go:85 pkg/handler/asset.go:86 -msgid "Hostname" -msgstr "" - -#. lang.T -#. lang.T -#. lang.T -#. lang.T -#: pkg/handler/asset.go:87 pkg/handler/asset.go:88 pkg/handler/asset.go:106 -#: pkg/handler/asset.go:125 -msgid "" -"Enter ID number directly login the asset, multiple search use // + field, " -"such as: //16" -msgstr "" - -#. lang.T -#. lang.T -#: pkg/handler/asset.go:126 pkg/handler/asset_node.go:24 -msgid "%s node has no assets" -msgstr "" - -#. lang.T -#: pkg/handler/banner.go:29 -msgid "Welcome to use JumpServer open source fortress system" -msgstr "" - -#. lang.T -#: pkg/handler/banner.go:31 -msgid "part IP, Hostname, Comment" -msgstr "" - -#. lang.T -#: pkg/handler/banner.go:31 -msgid "to search login if unique" -msgstr "" - -#. lang.T -#: pkg/handler/banner.go:32 -msgid "/ + IP, Hostname, Comment" -msgstr "" - -#. lang.T -#: pkg/handler/banner.go:32 -msgid "to search, such as: /192.168" -msgstr "" - -#. lang.T -#: pkg/handler/banner.go:33 -msgid "display the host you have permission" -msgstr "" - -#. lang.T -#: pkg/handler/banner.go:34 -msgid "display the node that you have permission" -msgstr "" - -#. lang.T -#: pkg/handler/banner.go:35 -msgid "display the databases that you have permission" -msgstr "" - -#. lang.T -#: pkg/handler/banner.go:36 -msgid "display the kubernetes that you have permission" -msgstr "" - -#. lang.T -#: pkg/handler/banner.go:37 -msgid "refresh your assets and nodes" -msgstr "" - -#. lang.T -#: pkg/handler/banner.go:38 -msgid "Chinese-English-Japanese switch" -msgstr "" - -#. lang.T -#: pkg/handler/banner.go:39 -msgid "print help" -msgstr "" - -#. lang.T -#: pkg/handler/banner.go:40 -msgid "exit" -msgstr "" - -#. lang.T -#: pkg/handler/banner.go:58 -msgid "\t%d) Enter {{.GreenBoldColor}}%s{{.ColorEnd}} to %s.%s" -msgstr "" - -#. i18n.T -#: pkg/handler/direct_handler.go:110 -msgid "Core API failed" -msgstr "" - -#. i18n.T -#: pkg/handler/direct_handler.go:114 -msgid "not found matched asset %s" -msgstr "" - -#. i18n.T -#: pkg/handler/direct_handler.go:200 -msgid "No system user found." -msgstr "" - -#. i18n.T -#. i18n.T -#. i18n.T -#: pkg/handler/direct_handler.go:212 pkg/handler/direct_handler.go:213 -#: pkg/handler/direct_handler.go:214 -msgid "Username" -msgstr "" - -#. i18n.T -#: pkg/handler/direct_handler.go:243 -msgid "Tips: Enter system user ID and directly login" -msgstr "" - -#. i18n.T -#: pkg/handler/direct_handler.go:244 -msgid "Back: B/b" -msgstr "" - -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#: pkg/handler/direct_handler.go:274 pkg/handler/direct_handler.go:275 -#: pkg/handler/direct_handler.go:276 pkg/handler/direct_handler.go:277 -#: pkg/handler/direct_handler.go:306 -msgid "select one asset to login" -msgstr "" - -#. i18n.T -#: pkg/handler/direct_handler.go:319 -msgid "not found matched username %s" -msgstr "" - -#. i18n.T -#. i18n.T -#. lang.T -#: pkg/handler/direct_handler.go:352 pkg/handler/direct_handler.go:360 -#: pkg/handler/dispatch.go:128 -msgid "Node: [ ID.Name(Asset amount) ]" -msgstr "" - -#. lang.T -#: pkg/handler/dispatch.go:130 -msgid "Tips: Enter g+NodeID to display the host under the node, such as g1" -msgstr "" - -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#: pkg/handler/interactive.go:145 pkg/handler/interactive.go:157 -#: pkg/handler/interactive.go:158 pkg/handler/interactive.go:159 -#: pkg/handler/interactive.go:188 pkg/handler/interactive.go:189 -#: pkg/handler/interactive.go:242 -msgid "Refresh done" -msgstr "" - -#. lang.T -#: pkg/handler/select_handler.go:199 -msgid "Search: %s" -msgstr "" - -#. lang.T -#: pkg/handler/select_handler.go:226 -msgid "The asset is inactive" -msgstr "" - -#. i18n.T -#: pkg/koko/server_ssh.go:195 -msgid "Must be unique asset for %s" -msgstr "" - -#. i18n.T -#: pkg/koko/server_ssh.go:207 -msgid "Must be unique system user for %s" -msgstr "" - -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. lang.T -#: pkg/koko/server_ssh.go:349 pkg/koko/server_ssh.go:356 -#: pkg/koko/server_ssh.go:367 pkg/koko/server_ssh.go:374 -#: pkg/proxy/login_confirm.go:21 -msgid "validate Login confirm err: Core Api failed" -msgstr "" - -#. lang.T -#: pkg/proxy/login_confirm.go:51 -msgid "Need ticket confirm to login, already send email to the reviewers" -msgstr "" - -#. lang.T -#: pkg/proxy/login_confirm.go:52 -msgid "Ticket Reviewers: %s" -msgstr "" - -#. lang.T -#: pkg/proxy/login_confirm.go:53 -msgid "Could copy website URL to notify reviewers: %s" -msgstr "" - -#. lang.T -#: pkg/proxy/login_confirm.go:54 -msgid "Please waiting for the reviewers to confirm, enter q to exit. " -msgstr "" - -#. lang.T -#: pkg/proxy/login_confirm.go:81 -msgid "Unknown status" -msgstr "" - -#. lang.T -#: pkg/proxy/login_confirm.go:85 -msgid "%s approved" -msgstr "" - -#. lang.T -#: pkg/proxy/login_confirm.go:90 -msgid "%s rejected" -msgstr "" - -#. lang.T -#: pkg/proxy/login_confirm.go:94 -msgid "Cancel confirm" -msgstr "" - -#. lang.T -#: pkg/proxy/parser.go:172 -msgid "have no permission to upload file" -msgstr "" - -#. lang.T -#: pkg/proxy/parser.go:207 -msgid "the reviewers will confirm. continue or not [Y/n]" -msgstr "" - -#. lang.T -#. lang.T -#. lang.T -#: pkg/proxy/parser.go:226 pkg/proxy/parser.go:232 pkg/proxy/parser.go:271 -msgid "Command review is not currently supported" -msgstr "" - -#. lang.T -#: pkg/proxy/parser.go:312 -msgid "Command `%s` is forbidden" -msgstr "" - -#. lang.T -#: pkg/proxy/parser.go:379 -msgid "have no permission to download file" -msgstr "" - -#. lang.T -#: pkg/proxy/parser.go:440 -msgid "" -"Please waiting for the reviewers to confirm command `%s`, cancel by CTRL+C." -msgstr "" - -#. lang.T -#: pkg/proxy/parser.go:448 -msgid "" -"Need ticket confirm to execute command, already send email to the reviewers" -msgstr "" - -#. lang.T -#. lang.T -#. lang.T -#: pkg/proxy/parser.go:449 pkg/proxy/parser.go:450 pkg/proxy/server.go:143 -msgid "Connecting to %s@%s" -msgstr "" - -#. lang.T -#: pkg/proxy/server.go:146 -msgid "Connecting to Database %s" -msgstr "" - -#. lang.T -#: pkg/proxy/server.go:148 -msgid "Connecting to Kubernetes %s" -msgstr "" - -#. lang.T -#: pkg/proxy/server.go:150 -msgid "Connecting to Kubernetes %s container %s" -msgstr "" - -#. lang.T -#: pkg/proxy/server.go:197 -msgid "%s protocol client not installed." -msgstr "" - -#. lang.T -#: pkg/proxy/server.go:201 -msgid "" -"Terminal does not support protocol %s, please use web terminal to access" -msgstr "" - -#. lang.T -#: pkg/proxy/server.go:283 -msgid "System user <%s> and asset <%s> protocol are inconsistent." -msgstr "" - -#. lang.T -#: pkg/proxy/server.go:348 -msgid "You don't have permission login %s" -msgstr "" - -#. lang.T -#: pkg/proxy/server.go:601 -msgid "You get auth token failed" -msgstr "" - -#. lang.T -#: pkg/proxy/server.go:608 -msgid "Get auth username failed" -msgstr "" - -#. lang.T -#: pkg/proxy/server.go:613 -msgid "Get auth password failed" -msgstr "" - -#. lang.T -#. lang.T -#. lang.T -#. lang.T -#: pkg/proxy/server.go:619 pkg/proxy/server.go:625 pkg/proxy/server.go:640 -#: pkg/proxy/server.go:690 -msgid "Reuse SSH connections (%s@%s) [Number of connections: %d]" -msgstr "" - -#. lang.T -#: pkg/proxy/server.go:959 -msgid "Switched to %s" -msgstr "" - -#. lang.T -#: pkg/proxy/server.go:1143 -msgid "Connect with api server failed" -msgstr "" - -#. lang.T -#: pkg/proxy/server.go:1172 -msgid "Start domain gateway failed %s" -msgstr "" - -#. lang.T -#. lang.T -#: pkg/proxy/server.go:1180 pkg/proxy/switch.go:238 -msgid "Connect idle more than %d minutes, disconnect" -msgstr "" - -#. lang.T -#: pkg/proxy/switch.go:246 -msgid "Permission has expired, disconnect" -msgstr "" - -#. lang.T -#: pkg/proxy/switch.go:257 -msgid "Terminated by admin %s" -msgstr "" - -#. lang.T -#: pkg/proxy/tools.go:28 -msgid "Authentication failed" -msgstr "" - -#. lang.T -#: pkg/proxy/tools.go:31 -msgid "Connection refused" -msgstr "" - -#. lang.T -#: pkg/proxy/tools.go:34 -msgid "i/o timeout" -msgstr "" - -#. lang.T -#: pkg/proxy/tools.go:37 -msgid "No route to host" -msgstr "" - -#. lang.T -#: pkg/proxy/tools.go:40 -msgid "network is unreachable" -msgstr "" diff --git a/locale/es/LC_MESSAGES/koko.mo b/locale/es/LC_MESSAGES/koko.mo new file mode 100644 index 000000000..c1d0321fe Binary files /dev/null and b/locale/es/LC_MESSAGES/koko.mo differ diff --git a/locale/es/LC_MESSAGES/koko.po b/locale/es/LC_MESSAGES/koko.po new file mode 100644 index 000000000..bf8baaec9 --- /dev/null +++ b/locale/es/LC_MESSAGES/koko.po @@ -0,0 +1,609 @@ +msgid "" +msgstr "" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: xgotext\n" + +#. lang.T +#: pkg/handler/app_database.go:11 +msgid "No Databases" +msgstr "Sin bases de datos" + +#. lang.T +#: pkg/handler/app_k8s.go:15 +msgid "No kubernetes" +msgstr "Sin Kubernetes" + +#. lang.T +#: pkg/handler/app_k8s.go:31 +msgid "Page: %d, Count: %d, Total Page: %d, Total Count: %d" +msgstr "" +"Página: %d, filas por página: %d, páginas totales: %d, total de elementos: %d" + +#. lang.T +#: pkg/handler/app_k8s.go:45 +msgid "" +"Enter ID number directly login, multiple search use // + field, such as: //16" +msgstr "" +"Consejo: introduzca directamente el ID del activo para iniciar sesión; para " +"búsquedas múltiples use // + campo, por ejemplo: //192" + +#. lang.T +#: pkg/handler/app_k8s.go:46 +msgid "Page up: b\tPage down: n" +msgstr "Página anterior: b\tPágina siguiente: n" + +#. lang.T +#: pkg/handler/asset.go:45 +msgid "No Assets" +msgstr "Sin activos" + +#. lang.T +#: pkg/handler/asset.go:57 +msgid "ID" +msgstr "ID" + +#. lang.T +#: pkg/handler/asset.go:58 +msgid "Name" +msgstr "Nombre" + +#. lang.T +#: pkg/handler/asset.go:59 +msgid "Address" +msgstr "Dirección" + +#. lang.T +#: pkg/handler/asset.go:60 +msgid "Platform" +msgstr "Plataforma" + +#. lang.T +#: pkg/handler/asset.go:61 +msgid "Organization" +msgstr "Organización" + +#. lang.T +#: pkg/handler/asset.go:62 +msgid "Comment" +msgstr "Comentario" + +#. lang.T +#: pkg/handler/asset.go:176 +msgid "%s protocol client not installed." +msgstr "Cliente del protocolo %s no está instalado." + +#. lang.T +#: pkg/handler/asset.go:179 +msgid "" +"Terminal does not support protocol %s, please use web terminal to access" +msgstr "" +"El terminal no admite el protocolo %s; utilice la terminal web para acceder." + +#. lang.T +#: pkg/handler/asset.go:214 +msgid "Core API failed" +msgstr "Error en la API principal" + +#. lang.T +#: pkg/handler/asset.go:220 +msgid "ACL reject" +msgstr "" +"Inicio de sesión rechazado por restricciones de la política de control de " +"acceso" + +#. lang.T +#: pkg/handler/asset.go:226 +msgid "" +"Face ACL is not supported yet. Please use the WebTerminal to connect the " +"asset." +msgstr "" +"Las reglas de ACL facial aún no son compatibles; utilice la terminal web " +"para conectarse al activo." + +#. lang.T +#. lang.T +#: pkg/handler/asset.go:241 pkg/handler/asset.go:250 +msgid "Unknown error code: %s, detail: %s" +msgstr "Código de error desconocido: %s, detalle: %s" + +#. lang.T +#: pkg/handler/asset.go:261 +msgid "get connect token err" +msgstr "Error al obtener el token de conexión" + +#. lang.T +#: pkg/handler/asset_node.go:22 +msgid "%s node has no assets" +msgstr "El nodo %s no tiene activos" + +#. lang.T +#: pkg/handler/banner.go:30 +msgid "Welcome to use JumpServer open source fortress system" +msgstr "Bienvenido al sistema JumpServer de fortaleza de código abierto" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "part IP, Hostname, Comment" +msgstr "IP parcial, nombre de host, comentario" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "to search login if unique" +msgstr "Buscar inicio de sesión (si es único)" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "/ + IP, Hostname, Comment" +msgstr "/ + IP, nombre de host, comentario" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "to search, such as: /192.168" +msgstr "Buscar, por ejemplo: /192.168" + +#. lang.T +#: pkg/handler/banner.go:34 +msgid "display the assets you have permission" +msgstr "Mostrar los activos para los que tiene permiso" + +#. lang.T +#: pkg/handler/banner.go:35 +msgid "display the node that you have permission" +msgstr "Mostrar los nodos para los que tiene permiso" + +#. lang.T +#: pkg/handler/banner.go:36 +msgid "display the hosts that you have permission" +msgstr "Mostrar los hosts para los que tiene permiso" + +#. lang.T +#: pkg/handler/banner.go:37 +msgid "display the databases that you have permission" +msgstr "Mostrar las bases de datos para las que tiene permiso" + +#. lang.T +#: pkg/handler/banner.go:38 +msgid "display the kubernetes that you have permission" +msgstr "Mostrar los Kubernetes para los que tiene permiso" + +#. lang.T +#: pkg/handler/banner.go:39 +msgid "refresh your assets and nodes" +msgstr "Actualizar la información más reciente de máquinas y nodos" + +#. lang.T +#: pkg/handler/banner.go:40 +msgid "language switch" +msgstr "Cambio de idioma" + +#. lang.T +#: pkg/handler/banner.go:41 +msgid "print help" +msgstr "Mostrar ayuda" + +#. lang.T +#: pkg/handler/banner.go:42 +msgid "exit" +msgstr "Salir" + +#. lang.T +#: pkg/handler/banner.go:60 +msgid "\t%2d) Enter {{.GreenBoldColor}}%s{{.ColorEnd}} to %s.%s" +msgstr "\t%d) Ingrese {{.GreenBoldColor}}%s{{.ColorEnd}} para %s.%s" + +#. lang.T +#: pkg/handler/banner.go:85 +msgid "Announcement: " +msgstr "Anuncio: " + +#. lang.T +#: pkg/handler/direct_handler.go:272 +msgid "No Account found." +msgstr "No se encontró ninguna cuenta" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:282 pkg/handler/direct_handler.go:283 +#: pkg/handler/direct_handler.go:284 +msgid "Username" +msgstr "Nombre de usuario" + +#. lang.T +#: pkg/handler/direct_handler.go:313 +msgid "Tips: Enter asset[%s] account ID" +msgstr "Consejo: introduzca el ID de cuenta del activo [%s]" + +#. lang.T +#: pkg/handler/direct_handler.go:314 +msgid "Back: B/b" +msgstr "Volver: B/b" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:349 pkg/handler/direct_handler.go:350 +msgid "Hostname" +msgstr "Nombre de host" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:351 pkg/handler/direct_handler.go:352 +#: pkg/handler/direct_handler.go:381 +msgid "select one asset to login" +msgstr "Seleccione un activo para iniciar sesión" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:395 pkg/handler/direct_handler.go:400 +msgid "not found matched username %s" +msgstr "No se encontró un nombre de usuario coincidente con %s" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:433 pkg/handler/direct_handler.go:439 +#: pkg/handler/direct_handler.go:445 +msgid "" +"Face verification is not supported yet. Please use the WebTerminal to " +"connect the asset." +msgstr "" +"La verificación facial aún no es compatible; utilice la terminal web para " +"conectar el activo." + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:460 pkg/handler/direct_handler.go:476 +#: pkg/handler/dispatch.go:127 pkg/handler/dispatch.go:128 +#: pkg/handler/dispatch.go:153 +#, fuzzy +msgid "Tips: switch language by ID (Current session only)" +msgstr "Consejo: cambie el idioma mediante el ID" + +#. lang.T +#: pkg/handler/dispatch.go:154 +msgid "" +"Tips: To set a default language, go to Personal Settings → Preferences on Web" +msgstr "Consejo: para configurar el idioma predeterminado, vaya a “Configuración personal → Preferencias” en la web." + +#. lang.T +#. lang.T +#: pkg/handler/dispatch.go:155 pkg/handler/dispatch.go:183 +msgid "Invalid ID" +msgstr "ID inválido" + +#. lang.T +#: pkg/handler/dispatch.go:189 +msgid "Switch language successfully" +msgstr "Cambio de idioma exitoso" + +#. lang.T +#: pkg/handler/dispatch.go:199 +msgid "Node: [ ID.Name(Asset amount) ]" +msgstr "Nodo: [ ID.Nombre(Cantidad de activos) ]" + +#. lang.T +#: pkg/handler/dispatch.go:201 +msgid "Tips: Enter g+NodeID to display the host under the node, such as g1" +msgstr "" +"Consejo: ingrese g+IDNodo para mostrar el host bajo el nodo, por ejemplo g1" + +#. lang.T +#: pkg/handler/interactive.go:64 +msgid "Connect idle more than %d minutes, disconnect" +msgstr "Conexión inactiva por más de %d minutos, desconectando" + +#. lang.T +#: pkg/handler/interactive.go:198 +msgid "No account found." +msgstr "No se encontró ninguna cuenta." + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:208 pkg/handler/interactive.go:209 +#: pkg/handler/interactive.go:210 pkg/handler/interactive.go:240 +#: pkg/handler/interactive.go:241 pkg/handler/interactive.go:267 +msgid "Select account exceed max retry times." +msgstr "Selección de cuenta excede el número máximo de intentos." + +#. lang.T +#: pkg/handler/interactive.go:278 +msgid "No protocol found." +msgstr "No se encontró ningún protocolo." + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:287 pkg/handler/interactive.go:288 +msgid "Protocol" +msgstr "Protocolo" + +#. lang.T +#: pkg/handler/interactive.go:315 +msgid "Tips: Enter protocol ID" +msgstr "Consejo: ingrese el ID del protocolo" + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:316 pkg/handler/interactive.go:342 +msgid "Select protocol exceed max retry times." +msgstr "Selección de protocolo excede el número máximo de intentos." + +#. lang.T +#: pkg/handler/interactive.go:379 +msgid "Refresh done" +msgstr "Actualización completada" + +#. lang.T +#: pkg/handler/login_confirm.go:38 +msgid "Need ACL review, continue? (y/n): " +msgstr "¿Se requiere revisión de control de acceso? ¿Continuar? (y/n): " + +#. lang.T +#: pkg/handler/login_confirm.go:53 +msgid "Cancel to login asset or max 3 retry" +msgstr "Cancelar inicio de sesión del activo o máximo 3 reintentos" + +#. lang.T +#. lang.T +#: pkg/handler/login_confirm.go:67 pkg/handler/login_confirm.go:98 +msgid "Need ticket confirm to login, already send email to the reviewers" +msgstr "" +"Se requiere confirmación de ticket para iniciar sesión; ya se ha enviado un " +"correo a los revisores" + +#. lang.T +#: pkg/handler/login_confirm.go:99 +msgid "Ticket Reviewers: %s" +msgstr "Revisores de tickets: %s" + +#. lang.T +#: pkg/handler/login_confirm.go:100 +msgid "Could copy website URL to notify reviewers: %s" +msgstr "Puede copiar la URL del sitio web para notificar a los revisores: %s" + +#. lang.T +#: pkg/handler/login_confirm.go:101 +msgid "Please waiting for the reviewers to confirm, enter q to exit. " +msgstr "" +"Por favor espere la confirmación de los revisores; ingrese q para salir." + +#. lang.T +#: pkg/handler/login_confirm.go:130 +msgid "Unknown status" +msgstr "Estado desconocido" + +#. lang.T +#: pkg/handler/login_confirm.go:134 +msgid "%s approved" +msgstr "%s aprobado" + +#. lang.T +#: pkg/handler/login_confirm.go:139 +msgid "%s rejected" +msgstr "%s rechazado" + +#. lang.T +#: pkg/handler/login_confirm.go:143 +msgid "Cancel confirm" +msgstr "Cancelar confirmación" + +#. lang.T +#: pkg/handler/select_handler.go:208 +msgid "Search: %s" +msgstr "Buscar: %s" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:223 +msgid "Must be unique asset for %s" +msgstr "Debe ser un activo único para %s" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:230 pkg/handler/server_ssh.go:252 +msgid "Must be unique account for %s" +msgstr "Debe ser una cuenta única para %s" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:260 +msgid "Must be auto login account for %s" +msgstr "Debe ser una cuenta de inicio de sesión automático para %s" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:682 pkg/handler/server_ssh.go:686 +msgid "No found asset" +msgstr "No se encontró el activo" + +#. i18nLang.T +#. i18nLang.T +#. i18nLang.T +#. lang.T +#: pkg/handler/server_ssh.go:716 pkg/handler/server_ssh.go:721 +#: pkg/handler/server_ssh.go:739 pkg/httpd/userwebsocket.go:110 +msgid "Create k8s client err: %s" +msgstr "Crear cliente k8s err: %s" + +#. lang.T +#: pkg/proxy/parser.go:265 +msgid "have no permission to upload file" +msgstr "No tiene permiso para subir archivos" + +#. lang.T +#: pkg/proxy/parser.go:301 +msgid "" +"The command you executed is risky and an alert notification will be sent to " +"the administrator. Do you want to continue?[Y/N]" +msgstr "" +"El comando que ejecutó es riesgoso y se enviará una notificación de alerta " +"al administrador. ¿Desea continuar? [Y/N]" + +#. lang.T +#: pkg/proxy/parser.go:318 +msgid "The command '%s' requires review. Continue or not [Y/n]?" +msgstr "El comando '%s' requiere revisión. ¿Continuar o no? [Y/n]?" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:338 pkg/proxy/parser.go:345 pkg/proxy/parser.go:436 +msgid "Command `%s` is forbidden" +msgstr "El comando `%s` está prohibido" + +#. lang.T +#: pkg/proxy/parser.go:503 +msgid "have no permission to download file" +msgstr "No tiene permiso para descargar archivos" + +#. lang.T +#: pkg/proxy/parser.go:572 +msgid "" +"Please waiting for the reviewers to confirm command `%s`, cancel by CTRL+C " +"or CTRL+D." +msgstr "" +"Por favor espere a que los revisores confirmen el comando `%s`; cancele con " +"CTRL+C o CTRL+D." + +#. lang.T +#: pkg/proxy/parser.go:582 +msgid "" +"Need ticket confirm to execute command, already send email to the reviewers" +msgstr "" +"Se requiere confirmación de ticket para ejecutar el comando; ya se ha " +"enviado un correo a los revisores" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:583 pkg/proxy/parser.go:584 pkg/proxy/server.go:58 +#: pkg/proxy/server.go:62 +msgid "" +"HandleTask does not support protocol %s, please use web terminal to access" +msgstr "" +"HandleTask no admite el protocolo %s; por favor use la terminal web para " +"acceder." + +#. lang.T +#: pkg/proxy/server.go:70 +msgid "Account <%s> and asset <%s> protocol are inconsistent." +msgstr "El protocolo de la cuenta <%s> y del activo <%s> no coincide." + +#. lang.T +#: pkg/proxy/server.go:102 +msgid "You don't have permission login %s" +msgstr "No tiene permiso para iniciar sesión en %s" + +#. lang.T +#: pkg/proxy/server.go:337 +msgid "You get auth token failed" +msgstr "Error al obtener el token de autenticación" + +#. lang.T +#: pkg/proxy/server.go:348 +msgid "Get auth password failed" +msgstr "Falló la obtención de la contraseña de autenticación" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:364 pkg/proxy/server.go:421 +msgid "Reuse SSH connections (%s@%s) [Number of connections: %d]" +msgstr "Reutilizar conexiones SSH (%s@%s) [Número de conexiones: %d]" + +#. lang.T +#: pkg/proxy/server.go:689 +msgid "Switched to %s" +msgstr "Cambiado a %s" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:791 pkg/proxy/server.go:936 +msgid "Connect with api server failed" +msgstr "Fallo al conectar con el servidor API" + +#. lang.T +#: pkg/proxy/server.go:997 +msgid "Start domain gateway failed %s" +msgstr "Error al iniciar la puerta de enlace de dominio %s" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:1005 pkg/proxy/server_options.go:108 +msgid "Manual" +msgstr "Manual" + +#. lang.T +#: pkg/proxy/server_options.go:110 +msgid "Dynamic" +msgstr "Dinámico" + +#. lang.T +#: pkg/proxy/server_options.go:113 +msgid "Connecting to %s@%s" +msgstr "Conectando a %s@%s" + +#. lang.T +#: pkg/proxy/server_options.go:117 +msgid "Connecting to Database %s" +msgstr "Conectando a la base de datos %s" + +#. lang.T +#: pkg/proxy/server_options.go:119 +msgid "Connecting to Kubernetes %s" +msgstr "Conectando a Kubernetes %s" + +#. lang.T +#: pkg/proxy/server_options.go:121 +msgid "Connecting to Kubernetes %s container %s" +msgstr "Conectando al contenedor %s de Kubernetes %s" + +#. lang.T +#: pkg/proxy/switch.go:315 +msgid "Session max time reached, disconnect" +msgstr "Tiempo máximo de sesión alcanzado, desconectando" + +#. lang.T +#. lang.T +#: pkg/proxy/switch.go:324 pkg/proxy/switch.go:331 +msgid "Permission has expired, disconnect" +msgstr "El permiso ha expirado, desconectando" + +#. lang.T +#: pkg/proxy/switch.go:341 +msgid "Terminated by admin %s" +msgstr "Terminado por el administrador %s" + +#. lang.T +#: pkg/proxy/tools.go:28 +msgid "Authentication failed" +msgstr "Autenticación fallida" + +#. lang.T +#: pkg/proxy/tools.go:31 +msgid "Connection refused" +msgstr "Conexión rechazada" + +#. lang.T +#: pkg/proxy/tools.go:34 +msgid "i/o timeout" +msgstr "Tiempo de espera de E/S agotado" + +#. lang.T +#: pkg/proxy/tools.go:37 +msgid "No route to host" +msgstr "No hay ruta al host" + +#. lang.T +#: pkg/proxy/tools.go:40 +msgid "network is unreachable" +msgstr "Red inalcanzable" diff --git a/locale/ja/LC_MESSAGES/koko.mo b/locale/ja/LC_MESSAGES/koko.mo new file mode 100644 index 000000000..30d85bd99 Binary files /dev/null and b/locale/ja/LC_MESSAGES/koko.mo differ diff --git a/locale/ja/LC_MESSAGES/koko.po b/locale/ja/LC_MESSAGES/koko.po new file mode 100644 index 000000000..da2c5c443 --- /dev/null +++ b/locale/ja/LC_MESSAGES/koko.po @@ -0,0 +1,602 @@ +msgid "" +msgstr "" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: xgotext\n" + +#. lang.T +#: pkg/handler/app_database.go:11 +msgid "No Databases" +msgstr "データベースなし" + +#. lang.T +#: pkg/handler/app_k8s.go:15 +msgid "No kubernetes" +msgstr "いいえ kubernetes" + +#. lang.T +#: pkg/handler/app_k8s.go:31 +msgid "Page: %d, Count: %d, Total Page: %d, Total Count: %d" +msgstr "ページ番号:%d,ページあたりの行数:%d,合計ページ数:%d,合計数量:%d" + +#. lang.T +#: pkg/handler/app_k8s.go:45 +msgid "" +"Enter ID number directly login, multiple search use // + field, such as: //16" +msgstr "" +"ヒント:資産IDを入力して直接ログインする,二次検索の使用 // + フィールド,の" +"ように://192" + +#. lang.T +#: pkg/handler/app_k8s.go:46 +msgid "Page up: b\tPage down: n" +msgstr "前のページ:b 次のページ:n" + +#. lang.T +#: pkg/handler/asset.go:45 +msgid "No Assets" +msgstr "資産なし" + +#. lang.T +#: pkg/handler/asset.go:57 +msgid "ID" +msgstr "ID" + +#. lang.T +#: pkg/handler/asset.go:58 +msgid "Name" +msgstr "名前" + +#. lang.T +#: pkg/handler/asset.go:59 +msgid "Address" +msgstr "アドレス" + +#. lang.T +#: pkg/handler/asset.go:60 +msgid "Platform" +msgstr "プラットホーム" + +#. lang.T +#: pkg/handler/asset.go:61 +msgid "Organization" +msgstr "おるがないず" + +#. lang.T +#: pkg/handler/asset.go:62 +msgid "Comment" +msgstr "コメント" + +#. lang.T +#: pkg/handler/asset.go:176 +msgid "%s protocol client not installed." +msgstr "%s プロトコルクライアントがインストールされていません。" + +#. lang.T +#: pkg/handler/asset.go:179 +msgid "" +"Terminal does not support protocol %s, please use web terminal to access" +msgstr "" +"この端末は %s プロトコルをサポートしていませんので、web端末を使用してログイン" +"してください" + +#. lang.T +#: pkg/handler/asset.go:214 +msgid "Core API failed" +msgstr "Core API エラー発生" + +#. lang.T +#: pkg/handler/asset.go:220 +msgid "ACL reject" +msgstr "は拒否されました" + +#. lang.T +#: pkg/handler/asset.go:226 +msgid "" +"Face ACL is not supported yet. Please use the WebTerminal to connect the " +"asset." +msgstr "" +"該端末は顔認証アクセスルールをサポートしていません。資産に接続するには" +"WebTerminalをご利用ください" + +#. lang.T +#. lang.T +#: pkg/handler/asset.go:241 pkg/handler/asset.go:250 +msgid "Unknown error code: %s, detail: %s" +msgstr "不明なエラーコード: %s, 詳細: %s" + +#. lang.T +#: pkg/handler/asset.go:261 +msgid "get connect token err" +msgstr "接続トークン取得エラー" + +#. lang.T +#: pkg/handler/asset_node.go:22 +msgid "%s node has no assets" +msgstr "%sノードに資産がありません" + +#. lang.T +#: pkg/handler/banner.go:30 +msgid "Welcome to use JumpServer open source fortress system" +msgstr "JumpServerオープンソース要塞システムを使用することを歓迎します" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "part IP, Hostname, Comment" +msgstr "パートIP,ホスト名,コメント" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "to search login if unique" +msgstr "検索ログイン(一意の場合)" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "/ + IP, Hostname, Comment" +msgstr "/ + IP、ホスト名、コメント" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "to search, such as: /192.168" +msgstr "検索, のように /192.168" + +#. lang.T +#: pkg/handler/banner.go:34 +msgid "display the assets you have permission" +msgstr "権限を持っているホストを表示する" + +#. lang.T +#: pkg/handler/banner.go:35 +msgid "display the node that you have permission" +msgstr "権限を持つノードを表示する" + +#. lang.T +#: pkg/handler/banner.go:36 +msgid "display the hosts that you have permission" +msgstr "権限を持つデータベースを表示します" + +#. lang.T +#: pkg/handler/banner.go:37 +msgid "display the databases that you have permission" +msgstr "権限を持つデータベースを表示する" + +#. lang.T +#: pkg/handler/banner.go:38 +msgid "display the kubernetes that you have permission" +msgstr "権限を持つkubernetesを表示する" + +#. lang.T +#: pkg/handler/banner.go:39 +msgid "refresh your assets and nodes" +msgstr "アセットとノードを更新する" + +#. lang.T +#: pkg/handler/banner.go:40 +msgid "language switch" +msgstr "言語切り替え" + +#. lang.T +#: pkg/handler/banner.go:41 +msgid "print help" +msgstr "印刷ヘルプ" + +#. lang.T +#: pkg/handler/banner.go:42 +msgid "exit" +msgstr "終了" + +#. lang.T +#: pkg/handler/banner.go:60 +msgid "\t%2d) Enter {{.GreenBoldColor}}%s{{.ColorEnd}} to %s.%s" +msgstr "\t%d) 入力 {{.GreenBoldColor}}%s{{.ColorEnd}} 進行%s.%s" + +#. lang.T +#: pkg/handler/banner.go:85 +msgid "Announcement: " +msgstr "お知らせ: " + +#. lang.T +#: pkg/handler/direct_handler.go:272 +msgid "No Account found." +msgstr "アカウントが見つかりません" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:282 pkg/handler/direct_handler.go:283 +#: pkg/handler/direct_handler.go:284 +msgid "Username" +msgstr "ユーザー名" + +#. lang.T +#: pkg/handler/direct_handler.go:313 +msgid "Tips: Enter asset[%s] account ID" +msgstr "ヒント アセット[%s] アカウントIDを入力" + +#. lang.T +#: pkg/handler/direct_handler.go:314 +msgid "Back: B/b" +msgstr "戻る: B/b" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:349 pkg/handler/direct_handler.go:350 +msgid "Hostname" +msgstr "ホスト名" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:351 pkg/handler/direct_handler.go:352 +#: pkg/handler/direct_handler.go:381 +msgid "select one asset to login" +msgstr "ログインするアセットを1つ選択" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:395 pkg/handler/direct_handler.go:400 +msgid "not found matched username %s" +msgstr "一致したユーザー名 %s が見つかりません" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:433 pkg/handler/direct_handler.go:439 +#: pkg/handler/direct_handler.go:445 +msgid "" +"Face verification is not supported yet. Please use the WebTerminal to " +"connect the asset." +msgstr "" +"顔認証はまだサポートされていません。資産に接続するにはWebTerminalをご利用くだ" +"さい" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:460 pkg/handler/direct_handler.go:476 +#: pkg/handler/dispatch.go:127 pkg/handler/dispatch.go:128 +#: pkg/handler/dispatch.go:153 +#, fuzzy +msgid "Tips: switch language by ID (Current session only)" +msgstr "ヒント: IDで言語を切り替えます" + +#. lang.T +#: pkg/handler/dispatch.go:154 +msgid "" +"Tips: To set a default language, go to Personal Settings → Preferences on Web" +msgstr "ヒント: デフォルトの言語を設定するには、Web 上の「個人設定 → 環境設定」に移動してください。" + +#. lang.T +#. lang.T +#: pkg/handler/dispatch.go:155 pkg/handler/dispatch.go:183 +msgid "Invalid ID" +msgstr "無効なID" + +#. lang.T +#: pkg/handler/dispatch.go:189 +msgid "Switch language successfully" +msgstr "言語切り替え成功" + +#. lang.T +#: pkg/handler/dispatch.go:199 +msgid "Node: [ ID.Name(Asset amount) ]" +msgstr "ノード: [ID.Name(アセット量)]" + +#. lang.T +#: pkg/handler/dispatch.go:201 +msgid "Tips: Enter g+NodeID to display the host under the node, such as g1" +msgstr "ヒント: g+ノードIDを入力してノードの下のホストを表示します。例えば: g1" + +#. lang.T +#: pkg/handler/interactive.go:64 +msgid "Connect idle more than %d minutes, disconnect" +msgstr "%d 分以上のアイドル状態に接続し、切断します" + +#. lang.T +#: pkg/handler/interactive.go:198 +msgid "No account found." +msgstr "アカウントが見つかりません" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:208 pkg/handler/interactive.go:209 +#: pkg/handler/interactive.go:210 pkg/handler/interactive.go:240 +#: pkg/handler/interactive.go:241 pkg/handler/interactive.go:267 +msgid "Select account exceed max retry times." +msgstr "アカウントを選択する回数が最大値を超えました" + +#. lang.T +#: pkg/handler/interactive.go:278 +msgid "No protocol found." +msgstr "プロトコルが見つかりません" + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:287 pkg/handler/interactive.go:288 +msgid "Protocol" +msgstr "プロトコル" + +#. lang.T +#: pkg/handler/interactive.go:315 +msgid "Tips: Enter protocol ID" +msgstr "ヒント: プロトコル ID を入力してください" + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:316 pkg/handler/interactive.go:342 +msgid "Select protocol exceed max retry times." +msgstr "プロトコルを選択する回数が最大値を超えました" + +#. lang.T +#: pkg/handler/interactive.go:379 +msgid "Refresh done" +msgstr "更新完了" + +#. lang.T +#: pkg/handler/login_confirm.go:38 +msgid "Need ACL review, continue? (y/n): " +msgstr "ACLレビューが必要です。続行しますか? (y/n): " + +#. lang.T +#: pkg/handler/login_confirm.go:53 +msgid "Cancel to login asset or max 3 retry" +msgstr "アセットのログインをキャンセルするか、最大3回リトライします" + +#. lang.T +#. lang.T +#: pkg/handler/login_confirm.go:67 pkg/handler/login_confirm.go:98 +msgid "Need ticket confirm to login, already send email to the reviewers" +msgstr "工単登録の再確認が必要で、すでにメールで審査者に通知した。" + +#. lang.T +#: pkg/handler/login_confirm.go:99 +msgid "Ticket Reviewers: %s" +msgstr "チケットレビュー: %s" + +#. lang.T +#: pkg/handler/login_confirm.go:100 +msgid "Could copy website URL to notify reviewers: %s" +msgstr "レビュー・アドレスのコピー、レビュー担当者への通知: %s" + +#. lang.T +#: pkg/handler/login_confirm.go:101 +msgid "Please waiting for the reviewers to confirm, enter q to exit. " +msgstr "レビューアが確認するのを待って、q を入力して終了してください。" + +#. lang.T +#: pkg/handler/login_confirm.go:130 +msgid "Unknown status" +msgstr "不明なステータス" + +#. lang.T +#: pkg/handler/login_confirm.go:134 +msgid "%s approved" +msgstr "%s が承認されました" + +#. lang.T +#: pkg/handler/login_confirm.go:139 +msgid "%s rejected" +msgstr "%s は拒否されました" + +#. lang.T +#: pkg/handler/login_confirm.go:143 +msgid "Cancel confirm" +msgstr "確認をキャンセル" + +#. lang.T +#: pkg/handler/select_handler.go:208 +msgid "Search: %s" +msgstr "検索: %s" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:223 +msgid "Must be unique asset for %s" +msgstr "%s の一意のアセットでなければなりません" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:230 pkg/handler/server_ssh.go:252 +msgid "Must be unique account for %s" +msgstr "%s の一意のアセットでなければなりません" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:260 +msgid "Must be auto login account for %s" +msgstr "%s の一意のアセットでなければなりません" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:682 pkg/handler/server_ssh.go:686 +msgid "No found asset" +msgstr "一致したアセット %s が見つかりません" + +#. i18nLang.T +#. i18nLang.T +#. i18nLang.T +#. lang.T +#: pkg/handler/server_ssh.go:716 pkg/handler/server_ssh.go:721 +#: pkg/handler/server_ssh.go:739 pkg/httpd/userwebsocket.go:110 +msgid "Create k8s client err: %s" +msgstr "K8S クライアント エラーの作成: %s" + +#. lang.T +#: pkg/proxy/parser.go:265 +msgid "have no permission to upload file" +msgstr "ファイルをアップロードする権限がない" + +#. lang.T +#: pkg/proxy/parser.go:301 +msgid "" +"The command you executed is risky and an alert notification will be sent to " +"the administrator. Do you want to continue?[Y/N]" +msgstr "" +"接続に失敗しました。データベース接続設定が正しいか確認してください[Y/N]" + +#. lang.T +#: pkg/proxy/parser.go:318 +msgid "The command '%s' requires review. Continue or not [Y/n]?" +msgstr "レビューアが確認します。継続するかどうか [Y/n]" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:338 pkg/proxy/parser.go:345 pkg/proxy/parser.go:436 +msgid "Command `%s` is forbidden" +msgstr "命令 `%s` は禁止されています" + +#. lang.T +#: pkg/proxy/parser.go:503 +msgid "have no permission to download file" +msgstr "ファイルをダウンロードする権限がない" + +#. lang.T +#: pkg/proxy/parser.go:572 +msgid "" +"Please waiting for the reviewers to confirm command `%s`, cancel by CTRL+C " +"or CTRL+D." +msgstr "" +"レビュー担当者がコマンド `%s` をレビューするまでお待ちください。キャンセルす" +"るには、CTRL+C または CTRL+D を押してください。" + +#. lang.T +#: pkg/proxy/parser.go:582 +msgid "" +"Need ticket confirm to execute command, already send email to the reviewers" +msgstr "" +"コマンドを実行するにはチケットの確認が必要です。すでにレビューアにメールを送" +"信しています" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:583 pkg/proxy/parser.go:584 pkg/proxy/server.go:58 +#: pkg/proxy/server.go:62 +msgid "" +"HandleTask does not support protocol %s, please use web terminal to access" +msgstr "" +"この端末は %s プロトコルをサポートしていませんので、web端末を使用してログイン" +"してください" + +#. lang.T +#: pkg/proxy/server.go:70 +msgid "Account <%s> and asset <%s> protocol are inconsistent." +msgstr "システムユーザ <%s> と資産 <%s> のプロトコルが一致しない" + +#. lang.T +#: pkg/proxy/server.go:102 +msgid "You don't have permission login %s" +msgstr "ログイン権限がありません %s" + +#. lang.T +#: pkg/proxy/server.go:337 +msgid "You get auth token failed" +msgstr "認証トークンの取得に失敗しました" + +#. lang.T +#: pkg/proxy/server.go:348 +msgid "Get auth password failed" +msgstr "認証トークンの取得に失敗しました" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:364 pkg/proxy/server.go:421 +msgid "Reuse SSH connections (%s@%s) [Number of connections: %d]" +msgstr "SSH接続の再利用 (%s@%s) [接続数: %d]" + +#. lang.T +#: pkg/proxy/server.go:689 +msgid "Switched to %s" +msgstr "%s に切り替え" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:791 pkg/proxy/server.go:936 +msgid "Connect with api server failed" +msgstr "APIサーバーとの接続に失敗しました" + +#. lang.T +#: pkg/proxy/server.go:997 +msgid "Start domain gateway failed %s" +msgstr "ドメインゲートウェイの開始に失敗した %s" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:1005 pkg/proxy/server_options.go:108 +msgid "Manual" +msgstr "マニュアル" + +#. lang.T +#: pkg/proxy/server_options.go:110 +msgid "Dynamic" +msgstr "ダイナミック" + +#. lang.T +#: pkg/proxy/server_options.go:113 +msgid "Connecting to %s@%s" +msgstr "接続開始 %s@%s" + +#. lang.T +#: pkg/proxy/server_options.go:117 +msgid "Connecting to Database %s" +msgstr "データベース接続の開始 %s" + +#. lang.T +#: pkg/proxy/server_options.go:119 +msgid "Connecting to Kubernetes %s" +msgstr "Kubernetes %s への接続" + +#. lang.T +#: pkg/proxy/server_options.go:121 +msgid "Connecting to Kubernetes %s container %s" +msgstr "Kubernetes %s コンテナー %s への接続" + +#. lang.T +#: pkg/proxy/switch.go:315 +msgid "Session max time reached, disconnect" +msgstr "セッションの最大時間に達しました。" + +#. lang.T +#. lang.T +#: pkg/proxy/switch.go:324 pkg/proxy/switch.go:331 +msgid "Permission has expired, disconnect" +msgstr "権限の有効期限が切れました。" + +#. lang.T +#: pkg/proxy/switch.go:341 +msgid "Terminated by admin %s" +msgstr "%s管理者が接続を終了" + +#. lang.T +#: pkg/proxy/tools.go:28 +msgid "Authentication failed" +msgstr "認証に失敗しました(ユーザー名またはパスワードエラー)" + +#. lang.T +#: pkg/proxy/tools.go:31 +msgid "Connection refused" +msgstr "ネットワーク不通(接続拒否)" + +#. lang.T +#: pkg/proxy/tools.go:34 +msgid "i/o timeout" +msgstr "ネットワーク不通(接続タイムアウト)" + +#. lang.T +#: pkg/proxy/tools.go:37 +msgid "No route to host" +msgstr "ネットワーク不通(ルーティング不通)" + +#. lang.T +#: pkg/proxy/tools.go:40 +msgid "network is unreachable" +msgstr "ネットワーク不通(ネットワーク不可)" diff --git a/locale/ja_JP/LC_MESSAGES/koko.mo b/locale/ja_JP/LC_MESSAGES/koko.mo deleted file mode 100644 index 22b25c4cb..000000000 Binary files a/locale/ja_JP/LC_MESSAGES/koko.mo and /dev/null differ diff --git a/locale/ja_JP/LC_MESSAGES/koko.po b/locale/ja_JP/LC_MESSAGES/koko.po deleted file mode 100644 index c10d9db33..000000000 --- a/locale/ja_JP/LC_MESSAGES/koko.po +++ /dev/null @@ -1,491 +0,0 @@ -msgid "" -msgstr "" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: xgotext\n" - -#. lang.T -#: pkg/handler/app_k8s.go:55 -msgid "No kubernetes" -msgstr "いいえ kubernetes" - -#. lang.T -#: pkg/handler/app_k8s.go:68 -msgid "ID" -msgstr "ID" - -#. lang.T -#: pkg/handler/app_k8s.go:69 -msgid "Name" -msgstr "名前" - -#. lang.T -#: pkg/handler/app_k8s.go:70 -msgid "Cluster" -msgstr "クラスター" - -#. lang.T -#: pkg/handler/app_k8s.go:71 -msgid "Comment" -msgstr "コメント" - -#. lang.T -#: pkg/handler/app_k8s.go:90 -msgid "Page: %d, Count: %d, Total Page: %d, Total Count: %d" -msgstr "ページ番号:%d,ページあたりの行数:%d,合計ページ数:%d,合計数量:%d" - -#. lang.T -#: pkg/handler/app_k8s.go:110 -msgid "" -"Enter ID number directly login the kubernetes, multiple search use // + " -"field, such as: //16" -msgstr "ヒント:KubernetesのIDを入力して直接ログインする,二次検索の使用 // + フィールド,のように://192" - -#. lang.T -#: pkg/handler/app_k8s.go:111 -msgid "Page up: b\tPage down: n" -msgstr "前のページ:b 次のページ:n" - -#. lang.T -#: pkg/handler/app_mysql.go:56 -msgid "No Databases" -msgstr "データベースなし" - -#. lang.T -#. lang.T -#. lang.T -#: pkg/handler/app_mysql.go:69 pkg/handler/app_mysql.go:70 -#: pkg/handler/app_mysql.go:71 -msgid "IP" -msgstr "IP" - -#. lang.T -#: pkg/handler/app_mysql.go:72 -msgid "DBType" -msgstr "データベースのタイプ" - -#. lang.T -#: pkg/handler/app_mysql.go:73 -msgid "DB Name" -msgstr "データベース名" - -#. lang.T -#. lang.T -#. lang.T -#: pkg/handler/app_mysql.go:74 pkg/handler/app_mysql.go:96 -#: pkg/handler/app_mysql.go:117 -msgid "" -"Enter ID number directly login the database, multiple search use // + field, " -"such as: //16" -msgstr "ヒント:データベースIDを入力して直接ログインする,二次検索の使用 // + フィールド,のように://192" - -#. lang.T -#. lang.T -#: pkg/handler/app_mysql.go:118 pkg/handler/asset.go:56 -msgid "No Assets" -msgstr "資産なし" - -#. lang.T -#. lang.T -#: pkg/handler/asset.go:85 pkg/handler/asset.go:86 -msgid "Hostname" -msgstr "ホスト名" - -#. lang.T -#. lang.T -#. lang.T -#. lang.T -#: pkg/handler/asset.go:87 pkg/handler/asset.go:88 pkg/handler/asset.go:106 -#: pkg/handler/asset.go:125 -msgid "" -"Enter ID number directly login the asset, multiple search use // + field, " -"such as: //16" -msgstr "ヒント:資産IDを入力して直接ログインする,二次検索の使用 // + フィールド,のように://192" - -#. lang.T -#. lang.T -#: pkg/handler/asset.go:126 pkg/handler/asset_node.go:24 -msgid "%s node has no assets" -msgstr "%sノードに資産がありません" - -#. lang.T -#: pkg/handler/banner.go:29 -msgid "Welcome to use JumpServer open source fortress system" -msgstr "JumpServerオープンソース要塞システムを使用することを歓迎します" - -#. lang.T -#: pkg/handler/banner.go:31 -msgid "part IP, Hostname, Comment" -msgstr "パートIP,ホスト名,コメント" - -#. lang.T -#: pkg/handler/banner.go:31 -msgid "to search login if unique" -msgstr "検索ログイン(一意の場合)" - -#. lang.T -#: pkg/handler/banner.go:32 -msgid "/ + IP, Hostname, Comment" -msgstr "/ + IP、ホスト名、コメント" - -#. lang.T -#: pkg/handler/banner.go:32 -msgid "to search, such as: /192.168" -msgstr "検索, のように /192.168" - -#. lang.T -#: pkg/handler/banner.go:33 -msgid "display the host you have permission" -msgstr "権限を持っているホストを表示する" - -#. lang.T -#: pkg/handler/banner.go:34 -msgid "display the node that you have permission" -msgstr "権限を持つノードを表示する" - -#. lang.T -#: pkg/handler/banner.go:35 -msgid "display the databases that you have permission" -msgstr "権限を持つデータベースを表示する" - -#. lang.T -#: pkg/handler/banner.go:36 -msgid "display the kubernetes that you have permission" -msgstr "権限を持つkubernetesを表示する" - -#. lang.T -#: pkg/handler/banner.go:37 -msgid "refresh your assets and nodes" -msgstr "アセットとノードを更新する" - -#. lang.T -#: pkg/handler/banner.go:38 -msgid "Chinese-English-Japanese switch" -msgstr "中国語と日本語の切り替え" - -#. lang.T -#: pkg/handler/banner.go:39 -msgid "print help" -msgstr "印刷ヘルプ" - -#. lang.T -#: pkg/handler/banner.go:40 -msgid "exit" -msgstr "終了" - -#. lang.T -#: pkg/handler/banner.go:58 -msgid "\t%d) Enter {{.GreenBoldColor}}%s{{.ColorEnd}} to %s.%s" -msgstr "\t%d) 入力 {{.GreenBoldColor}}%s{{.ColorEnd}} 進行%s.%s" - -#. i18n.T -#: pkg/handler/direct_handler.go:110 -msgid "Core API failed" -msgstr "Core API エラー発生" - -#. i18n.T -#: pkg/handler/direct_handler.go:114 -msgid "not found matched asset %s" -msgstr "一致したアセット %s が見つかりません" - -#. i18n.T -#: pkg/handler/direct_handler.go:200 -msgid "No system user found." -msgstr "システムユーザなし" - -#. i18n.T -#. i18n.T -#. i18n.T -#: pkg/handler/direct_handler.go:212 pkg/handler/direct_handler.go:213 -#: pkg/handler/direct_handler.go:214 -msgid "Username" -msgstr "ユーザー名" - -#. i18n.T -#: pkg/handler/direct_handler.go:243 -msgid "Tips: Enter system user ID and directly login" -msgstr "ヒント:システムユーザーIDを入力して直接ログインする" - -#. i18n.T -#: pkg/handler/direct_handler.go:244 -msgid "Back: B/b" -msgstr "戻る: B/b" - -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#: pkg/handler/direct_handler.go:274 pkg/handler/direct_handler.go:275 -#: pkg/handler/direct_handler.go:276 pkg/handler/direct_handler.go:277 -#: pkg/handler/direct_handler.go:306 -msgid "select one asset to login" -msgstr "ログインするアセットを1つ選択" - -#. i18n.T -#: pkg/handler/direct_handler.go:319 -msgid "not found matched username %s" -msgstr "一致したユーザー名 %s が見つかりません" - -#. i18n.T -#. i18n.T -#. lang.T -#: pkg/handler/direct_handler.go:352 pkg/handler/direct_handler.go:360 -#: pkg/handler/dispatch.go:128 -msgid "Node: [ ID.Name(Asset amount) ]" -msgstr "ノード: [ID.Name(アセット量)]" - -#. lang.T -#: pkg/handler/dispatch.go:130 -msgid "Tips: Enter g+NodeID to display the host under the node, such as g1" -msgstr "ヒント: g+ノードIDを入力してノードの下のホストを表示します。例えば: g1" - -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#: pkg/handler/interactive.go:145 pkg/handler/interactive.go:157 -#: pkg/handler/interactive.go:158 pkg/handler/interactive.go:159 -#: pkg/handler/interactive.go:188 pkg/handler/interactive.go:189 -#: pkg/handler/interactive.go:242 -msgid "Refresh done" -msgstr "更新完了" - -#. lang.T -#: pkg/handler/select_handler.go:199 -msgid "Search: %s" -msgstr "検索: %s" - -#. lang.T -#: pkg/handler/select_handler.go:226 -msgid "The asset is inactive" -msgstr "アセットは非アクティブです" - -#. i18n.T -#: pkg/koko/server_ssh.go:195 -msgid "Must be unique asset for %s" -msgstr "%s の一意のアセットでなければなりません" - -#. i18n.T -#: pkg/koko/server_ssh.go:207 -msgid "Must be unique system user for %s" -msgstr "唯一のシステムユーザーでなければなりません %s" - -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. lang.T -#: pkg/koko/server_ssh.go:349 pkg/koko/server_ssh.go:356 -#: pkg/koko/server_ssh.go:367 pkg/koko/server_ssh.go:374 -#: pkg/proxy/login_confirm.go:21 -msgid "validate Login confirm err: Core Api failed" -msgstr "検証ログオン再確認失敗:Core API異常" - -#. lang.T -#: pkg/proxy/login_confirm.go:51 -msgid "Need ticket confirm to login, already send email to the reviewers" -msgstr "工単登録の再確認が必要で、すでにメールで審査者に通知した。" - -#. lang.T -#: pkg/proxy/login_confirm.go:52 -msgid "Ticket Reviewers: %s" -msgstr "チケットレビュー: %s" - -#. lang.T -#: pkg/proxy/login_confirm.go:53 -msgid "Could copy website URL to notify reviewers: %s" -msgstr "レビュー・アドレスのコピー、レビュー担当者への通知: %s" - -#. lang.T -#: pkg/proxy/login_confirm.go:54 -msgid "Please waiting for the reviewers to confirm, enter q to exit. " -msgstr "レビューアが確認するのを待って、q を入力して終了してください。" - -#. lang.T -#: pkg/proxy/login_confirm.go:81 -msgid "Unknown status" -msgstr "不明なステータス" - -#. lang.T -#: pkg/proxy/login_confirm.go:85 -msgid "%s approved" -msgstr "%s が承認されました" - -#. lang.T -#: pkg/proxy/login_confirm.go:90 -msgid "%s rejected" -msgstr "%s は拒否されました" - -#. lang.T -#: pkg/proxy/login_confirm.go:94 -msgid "Cancel confirm" -msgstr "確認をキャンセル" - -#. lang.T -#: pkg/proxy/parser.go:172 -msgid "have no permission to upload file" -msgstr "ファイルをアップロードする権限がない" - -#. lang.T -#: pkg/proxy/parser.go:207 -msgid "the reviewers will confirm. continue or not [Y/n]" -msgstr "レビューアが確認します。継続するかどうか [Y/n]" - -#. lang.T -#. lang.T -#. lang.T -#: pkg/proxy/parser.go:226 pkg/proxy/parser.go:232 pkg/proxy/parser.go:271 -msgid "Command review is not currently supported" -msgstr "コマンドレビューは現在サポートされていません" - -#. lang.T -#: pkg/proxy/parser.go:312 -msgid "Command `%s` is forbidden" -msgstr "命令 `%s` は禁止されています" - -#. lang.T -#: pkg/proxy/parser.go:379 -msgid "have no permission to download file" -msgstr "ファイルをダウンロードする権限がない" - -#. lang.T -#: pkg/proxy/parser.go:440 -msgid "" -"Please waiting for the reviewers to confirm command `%s`, cancel by CTRL+C." -msgstr "レビューアがコマンド `%s` を確認するのを待ってください。CTRL + Cでキャンセルしてください。" - -#. lang.T -#: pkg/proxy/parser.go:448 -msgid "" -"Need ticket confirm to execute command, already send email to the reviewers" -msgstr "コマンドを実行するにはチケットの確認が必要です。すでにレビューアにメールを送信しています" - -#. lang.T -#. lang.T -#. lang.T -#: pkg/proxy/parser.go:449 pkg/proxy/parser.go:450 pkg/proxy/server.go:143 -msgid "Connecting to %s@%s" -msgstr "接続開始 %s@%s" - -#. lang.T -#: pkg/proxy/server.go:146 -msgid "Connecting to Database %s" -msgstr "データベース接続の開始 %s" - -#. lang.T -#: pkg/proxy/server.go:148 -msgid "Connecting to Kubernetes %s" -msgstr "Kubernetes %s への接続" - -#. lang.T -#: pkg/proxy/server.go:150 -msgid "Connecting to Kubernetes %s container %s" -msgstr "Kubernetes %s コンテナー %s への接続" - -#. lang.T -#: pkg/proxy/server.go:197 -msgid "%s protocol client not installed." -msgstr "%s プロトコルクライアントがインストールされていません。" - -#. lang.T -#: pkg/proxy/server.go:201 -msgid "" -"Terminal does not support protocol %s, please use web terminal to access" -msgstr "この端末は %s プロトコルをサポートしていませんので、web端末を使用してログインしてください" - -#. lang.T -#: pkg/proxy/server.go:283 -msgid "System user <%s> and asset <%s> protocol are inconsistent." -msgstr "システムユーザ <%s> と資産 <%s> のプロトコルが一致しない" - -#. lang.T -#: pkg/proxy/server.go:348 -msgid "You don't have permission login %s" -msgstr "ログイン権限がありません %s" - -#. lang.T -#: pkg/proxy/server.go:601 -msgid "You get auth token failed" -msgstr "認証トークンの取得に失敗しました" - -#. lang.T -#: pkg/proxy/server.go:608 -msgid "Get auth username failed" -msgstr "認証トークンの取得に失敗しました" - -#. lang.T -#: pkg/proxy/server.go:613 -msgid "Get auth password failed" -msgstr "認証トークンの取得に失敗しました" - -#. lang.T -#. lang.T -#. lang.T -#. lang.T -#: pkg/proxy/server.go:619 pkg/proxy/server.go:625 pkg/proxy/server.go:640 -#: pkg/proxy/server.go:690 -msgid "Reuse SSH connections (%s@%s) [Number of connections: %d]" -msgstr "SSH接続の再利用 (%s@%s) [接続数: %d]" - -#. lang.T -#: pkg/proxy/server.go:959 -msgid "Switched to %s" -msgstr "%s に切り替え" - -#. lang.T -#: pkg/proxy/server.go:1143 -msgid "Connect with api server failed" -msgstr "APIサーバーとの接続に失敗しました" - -#. lang.T -#: pkg/proxy/server.go:1172 -msgid "Start domain gateway failed %s" -msgstr "ドメインゲートウェイの開始に失敗した %s" - -#. lang.T -#. lang.T -#: pkg/proxy/server.go:1180 pkg/proxy/switch.go:238 -msgid "Connect idle more than %d minutes, disconnect" -msgstr "%d 分以上のアイドル状態に接続し、切断します" - -#. lang.T -#: pkg/proxy/switch.go:246 -msgid "Permission has expired, disconnect" -msgstr "権限の有効期限が切れました。" - -#. lang.T -#: pkg/proxy/switch.go:257 -msgid "Terminated by admin %s" -msgstr "%s管理者が接続を終了" - -#. lang.T -#: pkg/proxy/tools.go:28 -msgid "Authentication failed" -msgstr "認証に失敗しました(ユーザー名またはパスワードエラー)" - -#. lang.T -#: pkg/proxy/tools.go:31 -msgid "Connection refused" -msgstr "ネットワーク不通(接続拒否)" - -#. lang.T -#: pkg/proxy/tools.go:34 -msgid "i/o timeout" -msgstr "ネットワーク不通(接続タイムアウト)" - -#. lang.T -#: pkg/proxy/tools.go:37 -msgid "No route to host" -msgstr "ネットワーク不通(ルーティング不通)" - -#. lang.T -#: pkg/proxy/tools.go:40 -msgid "network is unreachable" -msgstr "ネットワーク不通(ネットワーク不可)" diff --git a/locale/ko/LC_MESSAGES/koko.mo b/locale/ko/LC_MESSAGES/koko.mo new file mode 100644 index 000000000..ae2c2aeab Binary files /dev/null and b/locale/ko/LC_MESSAGES/koko.mo differ diff --git a/locale/ko/LC_MESSAGES/koko.po b/locale/ko/LC_MESSAGES/koko.po new file mode 100644 index 000000000..86c605593 --- /dev/null +++ b/locale/ko/LC_MESSAGES/koko.po @@ -0,0 +1,606 @@ +msgid "" +msgstr "" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: xgotext\n" + +#. lang.T +#: pkg/handler/app_database.go:11 +msgid "No Databases" +msgstr "데이터베이스가 없습니다" + +#. lang.T +#: pkg/handler/app_k8s.go:15 +msgid "No kubernetes" +msgstr "쿠버네티스 없음" + +#. lang.T +#: pkg/handler/app_k8s.go:31 +msgid "Page: %d, Count: %d, Total Page: %d, Total Count: %d" +msgstr "페이지 번호: %d, 페이지당 줄 수: %d, 총 페이지 수: %d, 총 개수: %d" + +#. lang.T +#: pkg/handler/app_k8s.go:45 +msgid "" +"Enter ID number directly login, multiple search use // + field, such as: //16" +msgstr "" +"팁: 자산 ID를 입력하여 직접 로그인하고, // + 필드를 사용하여 보조 검색을 실행" +"하세요(예: //192)" + +#. lang.T +#: pkg/handler/app_k8s.go:46 +msgid "Page up: b\tPage down: n" +msgstr "이전 페이지: b 다음 페이지: n" + +#. lang.T +#: pkg/handler/asset.go:45 +msgid "No Assets" +msgstr "자산 없음" + +#. lang.T +#: pkg/handler/asset.go:57 +msgid "ID" +msgstr "ID" + +#. lang.T +#: pkg/handler/asset.go:58 +msgid "Name" +msgstr "이름" + +#. lang.T +#: pkg/handler/asset.go:59 +msgid "Address" +msgstr "주소" + +#. lang.T +#: pkg/handler/asset.go:60 +msgid "Platform" +msgstr "플랫폼" + +#. lang.T +#: pkg/handler/asset.go:61 +msgid "Organization" +msgstr "조직" + +#. lang.T +#: pkg/handler/asset.go:62 +msgid "Comment" +msgstr "비고" + +#. lang.T +#: pkg/handler/asset.go:176 +msgid "%s protocol client not installed." +msgstr "%s 프로토콜에 대한 클라이언트가 설치되지 않았습니다" + +#. lang.T +#: pkg/handler/asset.go:179 +msgid "" +"Terminal does not support protocol %s, please use web terminal to access" +msgstr "" +"이 터미널은 %s 프로토콜을 지원하지 않습니다. 웹 터미널을 사용하여 로그인하세" +"요." + +#. lang.T +#: pkg/handler/asset.go:214 +msgid "Core API failed" +msgstr "핵심 API 오류" + +#. lang.T +#: pkg/handler/asset.go:220 +msgid "ACL reject" +msgstr "액세스 제어 정책 제한으로 인해 이 로그인이 거부되었습니다." + +#. lang.T +#: pkg/handler/asset.go:226 +msgid "" +"Face ACL is not supported yet. Please use the WebTerminal to connect the " +"asset." +msgstr "" +"이 터미널은 얼굴 인식 접근 규칙을 지원하지 않습니다. 웹 터미널을 사용하여 로" +"그인하세요." + +#. lang.T +#. lang.T +#: pkg/handler/asset.go:241 pkg/handler/asset.go:250 +msgid "Unknown error code: %s, detail: %s" +msgstr "알 수 없는 오류 코드: %s, 세부 정보: %s" + +#. lang.T +#: pkg/handler/asset.go:261 +msgid "get connect token err" +msgstr "연결 토큰을 가져오는 중 오류가 발생했습니다" + +#. lang.T +#: pkg/handler/asset_node.go:22 +msgid "%s node has no assets" +msgstr "%s 노드에 자산이 없습니다" + +#. lang.T +#: pkg/handler/banner.go:30 +msgid "Welcome to use JumpServer open source fortress system" +msgstr "JumpServer 오픈 소스 요새 호스트 시스템에 오신 것을 환영합니다" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "part IP, Hostname, Comment" +msgstr "부분 IP, 호스트 이름, 주석" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "to search login if unique" +msgstr "로그인 검색(고유한 경우)" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "/ + IP, Hostname, Comment" +msgstr "/ + IP, 호스트 이름, 주석" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "to search, such as: /192.168" +msgstr "검색, 예: /192.168" + +#. lang.T +#: pkg/handler/banner.go:34 +msgid "display the assets you have permission" +msgstr "권한이 있는 자산 표시" + +#. lang.T +#: pkg/handler/banner.go:35 +msgid "display the node that you have permission" +msgstr "권한이 있는 노드 표시" + +#. lang.T +#: pkg/handler/banner.go:36 +msgid "display the hosts that you have permission" +msgstr "권한이 있는 호스트 표시" + +#. lang.T +#: pkg/handler/banner.go:37 +msgid "display the databases that you have permission" +msgstr "권한이 있는 데이터베이스 표시" + +#. lang.T +#: pkg/handler/banner.go:38 +msgid "display the kubernetes that you have permission" +msgstr "Kubernetes에 대한 권한이 있음을 보여주세요" + +#. lang.T +#: pkg/handler/banner.go:39 +msgid "refresh your assets and nodes" +msgstr "최신 머신 및 노드 정보 새로 고침" + +#. lang.T +#: pkg/handler/banner.go:40 +msgid "language switch" +msgstr "언어 전환" + +#. lang.T +#: pkg/handler/banner.go:41 +msgid "print help" +msgstr "도움말 표시" + +#. lang.T +#: pkg/handler/banner.go:42 +msgid "exit" +msgstr "종료" + +#. lang.T +#: pkg/handler/banner.go:60 +msgid "\t%2d) Enter {{.GreenBoldColor}}%s{{.ColorEnd}} to %s.%s" +msgstr "\t%d) %s.%s에 대해 {{.GreenBoldColor}}%s{{.ColorEnd}}를 입력하세요" + +#. lang.T +#: pkg/handler/banner.go:85 +msgid "Announcement: " +msgstr "공지사항:" + +#. lang.T +#: pkg/handler/direct_handler.go:272 +msgid "No Account found." +msgstr "계정을 찾을 수 없습니다" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:282 pkg/handler/direct_handler.go:283 +#: pkg/handler/direct_handler.go:284 +msgid "Username" +msgstr "사용자 이름" + +#. lang.T +#: pkg/handler/direct_handler.go:313 +msgid "Tips: Enter asset[%s] account ID" +msgstr "힌트: 자산 [%s]의 계정 ID를 입력하세요" + +#. lang.T +#: pkg/handler/direct_handler.go:314 +msgid "Back: B/b" +msgstr "반환: B/b" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:349 pkg/handler/direct_handler.go:350 +msgid "Hostname" +msgstr "호스트 이름" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:351 pkg/handler/direct_handler.go:352 +#: pkg/handler/direct_handler.go:381 +msgid "select one asset to login" +msgstr "로그인할 자산을 선택하세요" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:395 pkg/handler/direct_handler.go:400 +msgid "not found matched username %s" +msgstr "%s에 맞는 사용자 이름을 찾을 수 없습니다" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:433 pkg/handler/direct_handler.go:439 +#: pkg/handler/direct_handler.go:445 +msgid "" +"Face verification is not supported yet. Please use the WebTerminal to " +"connect the asset." +msgstr "" +"이 터미널은 얼굴 인식 인증을 지원하지 않습니다. 웹 터미널을 사용하여 로그인하" +"세요." + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:460 pkg/handler/direct_handler.go:476 +#: pkg/handler/dispatch.go:127 pkg/handler/dispatch.go:128 +#: pkg/handler/dispatch.go:153 +#, fuzzy +msgid "Tips: switch language by ID (Current session only)" +msgstr "팁: 언어를 전환하려면 ID를 입력하세요" + +#. lang.T +#: pkg/handler/dispatch.go:154 +msgid "" +"Tips: To set a default language, go to Personal Settings → Preferences on Web" +msgstr "팁: 기본 언어를 설정하려면 웹에서 "개인 설정 → 환경 설정"으로 이동하세요." + +#. lang.T +#. lang.T +#: pkg/handler/dispatch.go:155 pkg/handler/dispatch.go:183 +msgid "Invalid ID" +msgstr "잘못된 ID입니다" + +#. lang.T +#: pkg/handler/dispatch.go:189 +msgid "Switch language successfully" +msgstr "언어 전환이 성공했습니다" + +#. lang.T +#: pkg/handler/dispatch.go:199 +msgid "Node: [ ID.Name(Asset amount) ]" +msgstr "노드: [ID.Name(자산 수)]" + +#. lang.T +#: pkg/handler/dispatch.go:201 +msgid "Tips: Enter g+NodeID to display the host under the node, such as g1" +msgstr "" +"팁: 노드 아래에 있는 호스트를 표시하려면 g+노드 ID를 입력하세요(예: g1)" + +#. lang.T +#: pkg/handler/interactive.go:64 +msgid "Connect idle more than %d minutes, disconnect" +msgstr "유휴 시간이 %d분을 초과하여 연결이 끊어집니다." + +#. lang.T +#: pkg/handler/interactive.go:198 +msgid "No account found." +msgstr "계정을 찾을 수 없습니다" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:208 pkg/handler/interactive.go:209 +#: pkg/handler/interactive.go:210 pkg/handler/interactive.go:240 +#: pkg/handler/interactive.go:241 pkg/handler/interactive.go:267 +msgid "Select account exceed max retry times." +msgstr "계정 선택에 대한 최대 재시도 횟수를 초과했습니다." + +#. lang.T +#: pkg/handler/interactive.go:278 +msgid "No protocol found." +msgstr "프로토콜 없음" + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:287 pkg/handler/interactive.go:288 +msgid "Protocol" +msgstr "프로토콜" + +#. lang.T +#: pkg/handler/interactive.go:315 +msgid "Tips: Enter protocol ID" +msgstr "힌트: 프로토콜 ID를 입력하세요" + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:316 pkg/handler/interactive.go:342 +msgid "Select protocol exceed max retry times." +msgstr "선택한 프로토콜이 최대 재시도 횟수를 초과했습니다" + +#. lang.T +#: pkg/handler/interactive.go:379 +msgid "Refresh done" +msgstr "새로 고침 완료" + +#. lang.T +#: pkg/handler/login_confirm.go:38 +msgid "Need ACL review, continue? (y/n): " +msgstr "검토가 필요합니다. 계속하시겠습니까? (y/n):" + +#. lang.T +#: pkg/handler/login_confirm.go:53 +msgid "Cancel to login asset or max 3 retry" +msgstr "로그인 취소 또는 3번 재시도" + +#. lang.T +#. lang.T +#: pkg/handler/login_confirm.go:67 pkg/handler/login_confirm.go:98 +msgid "Need ticket confirm to login, already send email to the reviewers" +msgstr "" +"작업 지시를 검토하려면 로그인해야 합니다. 검토자에게 알리는 이메일이 전송되었" +"습니다." + +#. lang.T +#: pkg/handler/login_confirm.go:99 +msgid "Ticket Reviewers: %s" +msgstr "티켓 검토자: %s" + +#. lang.T +#: pkg/handler/login_confirm.go:100 +msgid "Could copy website URL to notify reviewers: %s" +msgstr "리뷰 주소를 복사하여 리뷰어에게 알릴 수 있습니다: %s" + +#. lang.T +#: pkg/handler/login_confirm.go:101 +msgid "Please waiting for the reviewers to confirm, enter q to exit. " +msgstr "리뷰어의 확인을 기다리는 중입니다. q를 눌러 로그인을 취소하세요." + +#. lang.T +#: pkg/handler/login_confirm.go:130 +msgid "Unknown status" +msgstr "알 수 없는 상태" + +#. lang.T +#: pkg/handler/login_confirm.go:134 +msgid "%s approved" +msgstr "%s 승인됨" + +#. lang.T +#: pkg/handler/login_confirm.go:139 +msgid "%s rejected" +msgstr "%s 리뷰가 거부되었습니다" + +#. lang.T +#: pkg/handler/login_confirm.go:143 +msgid "Cancel confirm" +msgstr "로그인 검토 취소" + +#. lang.T +#: pkg/handler/select_handler.go:208 +msgid "Search: %s" +msgstr "검색: %s" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:223 +msgid "Must be unique asset for %s" +msgstr "자산 %s에 대해 고유해야 합니다" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:230 pkg/handler/server_ssh.go:252 +msgid "Must be unique account for %s" +msgstr "고유한 계정이어야 합니다 %s" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:260 +msgid "Must be auto login account for %s" +msgstr "자동 로그인 계정 %s이어야 합니다" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:682 pkg/handler/server_ssh.go:686 +msgid "No found asset" +msgstr "%s에 맞는 에셋을 찾을 수 없습니다" + +#. i18nLang.T +#. i18nLang.T +#. i18nLang.T +#. lang.T +#: pkg/handler/server_ssh.go:716 pkg/handler/server_ssh.go:721 +#: pkg/handler/server_ssh.go:739 pkg/httpd/userwebsocket.go:110 +msgid "Create k8s client err: %s" +msgstr "k8s 클라이언트 생성 오류: %s" + +#. lang.T +#: pkg/proxy/parser.go:265 +msgid "have no permission to upload file" +msgstr "파일 업로드 권한이 없습니다" + +#. lang.T +#: pkg/proxy/parser.go:301 +msgid "" +"The command you executed is risky and an alert notification will be sent to " +"the administrator. Do you want to continue?[Y/N]" +msgstr "" +"실행한 명령은 위험합니다. 관리자에게 경고 알림이 전송됩니다. 계속하시겠습니" +"까? [Y/N]" + +#. lang.T +#: pkg/proxy/parser.go:318 +msgid "The command '%s' requires review. Continue or not [Y/n]?" +msgstr "명령 %s를 검토해야 합니다. 계속하시겠습니까? [Y/N]" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:338 pkg/proxy/parser.go:345 pkg/proxy/parser.go:436 +msgid "Command `%s` is forbidden" +msgstr "명령 `%s`는 금지되어 있습니다..." + +#. lang.T +#: pkg/proxy/parser.go:503 +msgid "have no permission to download file" +msgstr "파일을 다운로드할 권한이 없습니다" + +#. lang.T +#: pkg/proxy/parser.go:572 +msgid "" +"Please waiting for the reviewers to confirm command `%s`, cancel by CTRL+C " +"or CTRL+D." +msgstr "" +"검토자가 명령 `%s`를 검토할 때까지 기다려 주세요. 취소하려면 CTRL+C 또는 " +"CTRL+D를 누르세요." + +#. lang.T +#: pkg/proxy/parser.go:582 +msgid "" +"Need ticket confirm to execute command, already send email to the reviewers" +msgstr "" +"작업 지시 명령 실행 검토가 필요하며 검토자에게 알리는 이메일이 전송되었습니" +"다." + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:583 pkg/proxy/parser.go:584 pkg/proxy/server.go:58 +#: pkg/proxy/server.go:62 +msgid "" +"HandleTask does not support protocol %s, please use web terminal to access" +msgstr "" +"이 터미널은 %s 프로토콜을 지원하지 않습니다. 웹 터미널을 사용하여 로그인하세" +"요." + +#. lang.T +#: pkg/proxy/server.go:70 +msgid "Account <%s> and asset <%s> protocol are inconsistent." +msgstr "시스템 사용자 <%s>와 자산 <%s>의 프로토콜이 일치하지 않습니다." + +#. lang.T +#: pkg/proxy/server.go:102 +msgid "You don't have permission login %s" +msgstr "%s에 로그인할 권한이 없습니다" + +#. lang.T +#: pkg/proxy/server.go:337 +msgid "You get auth token failed" +msgstr "인증 토큰을 얻지 못했습니다." + +#. lang.T +#: pkg/proxy/server.go:348 +msgid "Get auth password failed" +msgstr "인증 토큰을 얻지 못했습니다." + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:364 pkg/proxy/server.go:421 +msgid "Reuse SSH connections (%s@%s) [Number of connections: %d]" +msgstr "SSH 연결 재사용(%s@%s) [연결 수: %d]" + +#. lang.T +#: pkg/proxy/server.go:689 +msgid "Switched to %s" +msgstr "%s로 전환됨" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:791 pkg/proxy/server.go:936 +msgid "Connect with api server failed" +msgstr "API 서버에 연결하지 못했습니다" + +#. lang.T +#: pkg/proxy/server.go:997 +msgid "Start domain gateway failed %s" +msgstr "데이터베이스 게이트웨이 %s를 시작하지 못했습니다" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:1005 pkg/proxy/server_options.go:108 +msgid "Manual" +msgstr "수동 계정" + +#. lang.T +#: pkg/proxy/server_options.go:110 +msgid "Dynamic" +msgstr "동적 계정" + +#. lang.T +#: pkg/proxy/server_options.go:113 +msgid "Connecting to %s@%s" +msgstr "%s@%s에 연결을 시작합니다" + +#. lang.T +#: pkg/proxy/server_options.go:117 +msgid "Connecting to Database %s" +msgstr "데이터베이스 %s에 연결을 시작합니다" + +#. lang.T +#: pkg/proxy/server_options.go:119 +msgid "Connecting to Kubernetes %s" +msgstr "Kubernetes %s에 연결을 시작합니다" + +#. lang.T +#: pkg/proxy/server_options.go:121 +msgid "Connecting to Kubernetes %s container %s" +msgstr "Kubernetes %s 컨테이너 %s에 대한 연결을 시작합니다" + +#. lang.T +#: pkg/proxy/switch.go:315 +msgid "Session max time reached, disconnect" +msgstr "세션이 최대 연결 시간을 초과하여 연결을 끊습니다." + +#. lang.T +#. lang.T +#: pkg/proxy/switch.go:324 pkg/proxy/switch.go:331 +msgid "Permission has expired, disconnect" +msgstr "권한이 만료되어 연결이 끊어졌습니다." + +#. lang.T +#: pkg/proxy/switch.go:341 +msgid "Terminated by admin %s" +msgstr "%s 관리자 연결이 끊겼습니다" + +#. lang.T +#: pkg/proxy/tools.go:28 +msgid "Authentication failed" +msgstr "인증에 실패했습니다(잘못된 사용자 이름 또는 비밀번호)" + +#. lang.T +#: pkg/proxy/tools.go:31 +msgid "Connection refused" +msgstr "네트워크에 접근할 수 없습니다(연결이 거부되었습니다)" + +#. lang.T +#: pkg/proxy/tools.go:34 +msgid "i/o timeout" +msgstr "네트워크를 사용할 수 없습니다(연결 시간이 초과되었습니다)" + +#. lang.T +#: pkg/proxy/tools.go:37 +msgid "No route to host" +msgstr "네트워크가 다운되었습니다(라우팅이 다운되었습니다)" + +#. lang.T +#: pkg/proxy/tools.go:40 +msgid "network is unreachable" +msgstr "네트워크 다운(네트워크에 접근할 수 없음)" diff --git a/locale/pt_BR/LC_MESSAGES/koko.mo b/locale/pt_BR/LC_MESSAGES/koko.mo new file mode 100644 index 000000000..dacc4fa50 Binary files /dev/null and b/locale/pt_BR/LC_MESSAGES/koko.mo differ diff --git a/locale/pt_BR/LC_MESSAGES/koko.po b/locale/pt_BR/LC_MESSAGES/koko.po new file mode 100644 index 000000000..277661bcc --- /dev/null +++ b/locale/pt_BR/LC_MESSAGES/koko.po @@ -0,0 +1,606 @@ +msgid "" +msgstr "" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: xgotext\n" + +#. lang.T +#: pkg/handler/app_database.go:11 +msgid "No Databases" +msgstr "Sem bancos de dados" + +#. lang.T +#: pkg/handler/app_k8s.go:15 +msgid "No kubernetes" +msgstr "Sem kubernetes" + +#. lang.T +#: pkg/handler/app_k8s.go:31 +msgid "Page: %d, Count: %d, Total Page: %d, Total Count: %d" +msgstr "Página: %d, Contagem: %d, Total de Páginas: %d, Total de Itens: %d" + +#. lang.T +#: pkg/handler/app_k8s.go:45 +msgid "" +"Enter ID number directly login, multiple search use // + field, such as: //16" +msgstr "" +"Dica: digite o número do ID para login direto, para busca múltipla use // + " +"campo, como: //192" + +#. lang.T +#: pkg/handler/app_k8s.go:46 +msgid "Page up: b\tPage down: n" +msgstr "Página anterior: b\tPágina seguinte: n" + +#. lang.T +#: pkg/handler/asset.go:45 +msgid "No Assets" +msgstr "Sem ativos" + +#. lang.T +#: pkg/handler/asset.go:57 +msgid "ID" +msgstr "ID" + +#. lang.T +#: pkg/handler/asset.go:58 +msgid "Name" +msgstr "Nome" + +#. lang.T +#: pkg/handler/asset.go:59 +msgid "Address" +msgstr "Endereço" + +#. lang.T +#: pkg/handler/asset.go:60 +msgid "Platform" +msgstr "Plataforma" + +#. lang.T +#: pkg/handler/asset.go:61 +msgid "Organization" +msgstr "Organização" + +#. lang.T +#: pkg/handler/asset.go:62 +msgid "Comment" +msgstr "Comentário" + +#. lang.T +#: pkg/handler/asset.go:176 +msgid "%s protocol client not installed." +msgstr "Cliente do protocolo %s não instalado." + +#. lang.T +#: pkg/handler/asset.go:179 +msgid "" +"Terminal does not support protocol %s, please use web terminal to access" +msgstr "" +"Este terminal não suporta o protocolo %s, por favor, use o terminal web para " +"acessar." + +#. lang.T +#: pkg/handler/asset.go:214 +msgid "Core API failed" +msgstr "Falha na Core API" + +#. lang.T +#: pkg/handler/asset.go:220 +msgid "ACL reject" +msgstr "" +"Login rejeitado devido às restrições da política de controle de acesso." + +#. lang.T +#: pkg/handler/asset.go:226 +msgid "" +"Face ACL is not supported yet. Please use the WebTerminal to connect the " +"asset." +msgstr "" +"As regras de acesso facial ainda não são suportadas. Por favor, use o " +"WebTerminal para conectar ao ativo." + +#. lang.T +#. lang.T +#: pkg/handler/asset.go:241 pkg/handler/asset.go:250 +msgid "Unknown error code: %s, detail: %s" +msgstr "Código de erro desconhecido: %s, detalhe: %s" + +#. lang.T +#: pkg/handler/asset.go:261 +msgid "get connect token err" +msgstr "Erro ao obter o token de conexão" + +#. lang.T +#: pkg/handler/asset_node.go:22 +msgid "%s node has no assets" +msgstr "O nó %s não possui ativos" + +#. lang.T +#: pkg/handler/banner.go:30 +msgid "Welcome to use JumpServer open source fortress system" +msgstr "Bem-vindo a usar o sistema de fortaleza JumpServer de código aberto" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "part IP, Hostname, Comment" +msgstr "parte IP, Nome do Host, Comentário" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "to search login if unique" +msgstr "pesquisar login (se único)" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "/ + IP, Hostname, Comment" +msgstr "/ + IP, Nome do Host, Comentário" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "to search, such as: /192.168" +msgstr "pesquisar, como: /192.168" + +#. lang.T +#: pkg/handler/banner.go:34 +msgid "display the assets you have permission" +msgstr "exibir os ativos aos quais você tem permissão" + +#. lang.T +#: pkg/handler/banner.go:35 +msgid "display the node that you have permission" +msgstr "exibir o nó ao qual você tem permissão" + +#. lang.T +#: pkg/handler/banner.go:36 +msgid "display the hosts that you have permission" +msgstr "exibir os hosts aos quais você tem permissão" + +#. lang.T +#: pkg/handler/banner.go:37 +msgid "display the databases that you have permission" +msgstr "exibir os bancos de dados aos quais você tem permissão" + +#. lang.T +#: pkg/handler/banner.go:38 +msgid "display the kubernetes that you have permission" +msgstr "exibir os Kubernetes aos quais você tem permissão" + +#. lang.T +#: pkg/handler/banner.go:39 +msgid "refresh your assets and nodes" +msgstr "atualizar seus ativos e nós" + +#. lang.T +#: pkg/handler/banner.go:40 +msgid "language switch" +msgstr "Mudança de idioma" + +#. lang.T +#: pkg/handler/banner.go:41 +msgid "print help" +msgstr "imprimir ajuda" + +#. lang.T +#: pkg/handler/banner.go:42 +msgid "exit" +msgstr "sair" + +#. lang.T +#: pkg/handler/banner.go:60 +msgid "\t%2d) Enter {{.GreenBoldColor}}%s{{.ColorEnd}} to %s.%s" +msgstr "\t%d) Digite {{.GreenBoldColor}}%s{{.ColorEnd}} para %s.%s" + +#. lang.T +#: pkg/handler/banner.go:85 +msgid "Announcement: " +msgstr "Anúncio:" + +#. lang.T +#: pkg/handler/direct_handler.go:272 +msgid "No Account found." +msgstr "Nenhuma conta encontrada." + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:282 pkg/handler/direct_handler.go:283 +#: pkg/handler/direct_handler.go:284 +msgid "Username" +msgstr "Nome de Usuário" + +#. lang.T +#: pkg/handler/direct_handler.go:313 +msgid "Tips: Enter asset[%s] account ID" +msgstr "Dica: Digite o ID da conta do ativo[%s]" + +#. lang.T +#: pkg/handler/direct_handler.go:314 +msgid "Back: B/b" +msgstr "Voltar: B/b" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:349 pkg/handler/direct_handler.go:350 +msgid "Hostname" +msgstr "Nome do Host" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:351 pkg/handler/direct_handler.go:352 +#: pkg/handler/direct_handler.go:381 +msgid "select one asset to login" +msgstr "selecione um ativo para fazer login" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:395 pkg/handler/direct_handler.go:400 +msgid "not found matched username %s" +msgstr "não foi encontrado um nome de usuário correspondente %s" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:433 pkg/handler/direct_handler.go:439 +#: pkg/handler/direct_handler.go:445 +msgid "" +"Face verification is not supported yet. Please use the WebTerminal to " +"connect the asset." +msgstr "" +"A verificação facial ainda não é suportada. Por favor, use o WebTerminal " +"para conectar ao ativo." + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:460 pkg/handler/direct_handler.go:476 +#: pkg/handler/dispatch.go:127 pkg/handler/dispatch.go:128 +#: pkg/handler/dispatch.go:153 +#, fuzzy +msgid "Tips: switch language by ID (Current session only)" +msgstr "Dica: mude o idioma pelo ID" + +#. lang.T +#: pkg/handler/dispatch.go:154 +msgid "" +"Tips: To set a default language, go to Personal Settings → Preferences on Web" +msgstr "Dica: Para definir o idioma padrão, acesse "Configurações pessoais → Preferências" na web." + +#. lang.T +#. lang.T +#: pkg/handler/dispatch.go:155 pkg/handler/dispatch.go:183 +msgid "Invalid ID" +msgstr "ID inválido" + +#. lang.T +#: pkg/handler/dispatch.go:189 +msgid "Switch language successfully" +msgstr "Mudança de idioma bem-sucedida" + +#. lang.T +#: pkg/handler/dispatch.go:199 +msgid "Node: [ ID.Name(Asset amount) ]" +msgstr "Nó: [ ID.Nome(Quantidade de Ativos) ]" + +#. lang.T +#: pkg/handler/dispatch.go:201 +msgid "Tips: Enter g+NodeID to display the host under the node, such as g1" +msgstr "Dica: Digite g+NodeID para exibir o host sob o nó, como g1" + +#. lang.T +#: pkg/handler/interactive.go:64 +msgid "Connect idle more than %d minutes, disconnect" +msgstr "Conexão ociosa por mais de %d minutos, desconectar" + +#. lang.T +#: pkg/handler/interactive.go:198 +msgid "No account found." +msgstr "Nenhuma conta encontrada." + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:208 pkg/handler/interactive.go:209 +#: pkg/handler/interactive.go:210 pkg/handler/interactive.go:240 +#: pkg/handler/interactive.go:241 pkg/handler/interactive.go:267 +msgid "Select account exceed max retry times." +msgstr "Seleção de conta excedeu o número máximo de tentativas." + +#. lang.T +#: pkg/handler/interactive.go:278 +msgid "No protocol found." +msgstr "Nenhum protocolo encontrado." + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:287 pkg/handler/interactive.go:288 +msgid "Protocol" +msgstr "Protocolo" + +#. lang.T +#: pkg/handler/interactive.go:315 +msgid "Tips: Enter protocol ID" +msgstr "Dica: Digite o ID do protocolo" + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:316 pkg/handler/interactive.go:342 +msgid "Select protocol exceed max retry times." +msgstr "Seleção de protocolo excedeu o número máximo de tentativas." + +#. lang.T +#: pkg/handler/interactive.go:379 +msgid "Refresh done" +msgstr "Atualização concluída" + +#. lang.T +#: pkg/handler/login_confirm.go:38 +msgid "Need ACL review, continue? (y/n): " +msgstr "Requere revisão de ACL, continuar? (y/n): " + +#. lang.T +#: pkg/handler/login_confirm.go:53 +msgid "Cancel to login asset or max 3 retry" +msgstr "Cancelar o login do ativo ou atingir 3 tentativas" + +#. lang.T +#. lang.T +#: pkg/handler/login_confirm.go:67 pkg/handler/login_confirm.go:98 +msgid "Need ticket confirm to login, already send email to the reviewers" +msgstr "" +"É necessário confirmar o ticket para login, já enviamos um e-mail aos " +"revisores." + +#. lang.T +#: pkg/handler/login_confirm.go:99 +msgid "Ticket Reviewers: %s" +msgstr "Revisores do Ticket: %s" + +#. lang.T +#: pkg/handler/login_confirm.go:100 +msgid "Could copy website URL to notify reviewers: %s" +msgstr "Você pode copiar o endereço de revisão para notificar os revisores: %s" + +#. lang.T +#: pkg/handler/login_confirm.go:101 +msgid "Please waiting for the reviewers to confirm, enter q to exit. " +msgstr "Aguarde a confirmação dos revisores, digite q para sair." + +#. lang.T +#: pkg/handler/login_confirm.go:130 +msgid "Unknown status" +msgstr "Status desconhecido" + +#. lang.T +#: pkg/handler/login_confirm.go:134 +msgid "%s approved" +msgstr "%s aprovado" + +#. lang.T +#: pkg/handler/login_confirm.go:139 +msgid "%s rejected" +msgstr "%s rejeitado" + +#. lang.T +#: pkg/handler/login_confirm.go:143 +msgid "Cancel confirm" +msgstr "Cancelar confirmação" + +#. lang.T +#: pkg/handler/select_handler.go:208 +msgid "Search: %s" +msgstr "Buscar: %s" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:223 +msgid "Must be unique asset for %s" +msgstr "Deve ser um ativo único para %s" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:230 pkg/handler/server_ssh.go:252 +msgid "Must be unique account for %s" +msgstr "Deve ser uma conta única para %s" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:260 +msgid "Must be auto login account for %s" +msgstr "Deve ser uma conta de login automático para %s" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:682 pkg/handler/server_ssh.go:686 +msgid "No found asset" +msgstr "Nenhum ativo encontrado" + +#. i18nLang.T +#. i18nLang.T +#. i18nLang.T +#. lang.T +#: pkg/handler/server_ssh.go:716 pkg/handler/server_ssh.go:721 +#: pkg/handler/server_ssh.go:739 pkg/httpd/userwebsocket.go:110 +msgid "Create k8s client err: %s" +msgstr "Erro ao criar cliente k8s: %s" + +#. lang.T +#: pkg/proxy/parser.go:265 +msgid "have no permission to upload file" +msgstr "Sem permissão para enviar arquivo" + +#. lang.T +#: pkg/proxy/parser.go:301 +msgid "" +"The command you executed is risky and an alert notification will be sent to " +"the administrator. Do you want to continue?[Y/N]" +msgstr "" +"O comando que você executou é arriscado e uma notificação de alerta será " +"enviada ao administrador. Deseja continuar?[Y/N]" + +#. lang.T +#: pkg/proxy/parser.go:318 +msgid "The command '%s' requires review. Continue or not [Y/n]?" +msgstr "O comando %s precisa de revisão, deseja continuar? [Y/N]" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:338 pkg/proxy/parser.go:345 pkg/proxy/parser.go:436 +msgid "Command `%s` is forbidden" +msgstr "O comando %s está proibido..." + +#. lang.T +#: pkg/proxy/parser.go:503 +msgid "have no permission to download file" +msgstr "Sem permissão para baixar arquivo" + +#. lang.T +#: pkg/proxy/parser.go:572 +msgid "" +"Please waiting for the reviewers to confirm command `%s`, cancel by CTRL+C " +"or CTRL+D." +msgstr "" +"Aguarde os revisores confirmarem o comando %s, cancele pressionando CTRL+C " +"ou CTRL+D." + +#. lang.T +#: pkg/proxy/parser.go:582 +msgid "" +"Need ticket confirm to execute command, already send email to the reviewers" +msgstr "" +"É necessário confirmar o bilhete para executar o comando, já enviamos um e-" +"mail aos revisores." + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:583 pkg/proxy/parser.go:584 pkg/proxy/server.go:58 +#: pkg/proxy/server.go:62 +msgid "" +"HandleTask does not support protocol %s, please use web terminal to access" +msgstr "" +"HandleTask não suporta o protocolo %s, por favor utilize o terminal web para " +"acessar." + +#. lang.T +#: pkg/proxy/server.go:70 +msgid "Account <%s> and asset <%s> protocol are inconsistent." +msgstr "A conta <%s> e o ativo <%s> apresentam um protocolo inconsistente." + +#. lang.T +#: pkg/proxy/server.go:102 +msgid "You don't have permission login %s" +msgstr "Você não tem permissão para fazer login em %s." + +#. lang.T +#: pkg/proxy/server.go:337 +msgid "You get auth token failed" +msgstr "Falha ao obter o token de autenticação." + +#. lang.T +#: pkg/proxy/server.go:348 +msgid "Get auth password failed" +msgstr "Falha ao obter a senha de autenticação." + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:364 pkg/proxy/server.go:421 +msgid "Reuse SSH connections (%s@%s) [Number of connections: %d]" +msgstr "Reutilizando conexões SSH (%s@%s) [Número de conexões: %d]." + +#. lang.T +#: pkg/proxy/server.go:689 +msgid "Switched to %s" +msgstr "Trocado para %s." + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:791 pkg/proxy/server.go:936 +msgid "Connect with api server failed" +msgstr "Falha ao conectar com o servidor API." + +#. lang.T +#: pkg/proxy/server.go:997 +msgid "Start domain gateway failed %s" +msgstr "Falha ao iniciar o gateway do domínio %s." + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:1005 pkg/proxy/server_options.go:108 +msgid "Manual" +msgstr "Manual" + +#. lang.T +#: pkg/proxy/server_options.go:110 +msgid "Dynamic" +msgstr "Dinâmico" + +#. lang.T +#: pkg/proxy/server_options.go:113 +msgid "Connecting to %s@%s" +msgstr "Conectando a %s@%s." + +#. lang.T +#: pkg/proxy/server_options.go:117 +msgid "Connecting to Database %s" +msgstr "Conectando ao banco de dados %s." + +#. lang.T +#: pkg/proxy/server_options.go:119 +msgid "Connecting to Kubernetes %s" +msgstr "Conectando ao Kubernetes %s." + +#. lang.T +#: pkg/proxy/server_options.go:121 +msgid "Connecting to Kubernetes %s container %s" +msgstr "Conectando ao contêiner %s do Kubernetes %s." + +#. lang.T +#: pkg/proxy/switch.go:315 +msgid "Session max time reached, disconnect" +msgstr "Tempo máximo da sessão alcançado, desconectando." + +#. lang.T +#. lang.T +#: pkg/proxy/switch.go:324 pkg/proxy/switch.go:331 +msgid "Permission has expired, disconnect" +msgstr "A permissão expirou, desconecte-se" + +#. lang.T +#: pkg/proxy/switch.go:341 +msgid "Terminated by admin %s" +msgstr "Conexão terminada pelo administrador %s" + +#. lang.T +#: pkg/proxy/tools.go:28 +msgid "Authentication failed" +msgstr "Falha na autenticação (nome de usuário ou senha incorretos)" + +#. lang.T +#: pkg/proxy/tools.go:31 +msgid "Connection refused" +msgstr "Rede indisponível (conexão recusada)" + +#. lang.T +#: pkg/proxy/tools.go:34 +msgid "i/o timeout" +msgstr "Rede indisponível (tempo limite de conexão)" + +#. lang.T +#: pkg/proxy/tools.go:37 +msgid "No route to host" +msgstr "Rede indisponível (roteamento indisponível)" + +#. lang.T +#: pkg/proxy/tools.go:40 +msgid "network is unreachable" +msgstr "Rede indisponível (rede inacessível)" diff --git a/locale/ru/LC_MESSAGES/koko.mo b/locale/ru/LC_MESSAGES/koko.mo new file mode 100644 index 000000000..06b95d8a6 Binary files /dev/null and b/locale/ru/LC_MESSAGES/koko.mo differ diff --git a/locale/ru/LC_MESSAGES/koko.po b/locale/ru/LC_MESSAGES/koko.po new file mode 100644 index 000000000..bdb0508ae --- /dev/null +++ b/locale/ru/LC_MESSAGES/koko.po @@ -0,0 +1,577 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : " +"2);\n" +"X-Generator: Poedit 3.5\n" + +#. lang.T +#: pkg/handler/app_database.go:11 +msgid "No Databases" +msgstr "База данных отсутствует" + +#. lang.T +#: pkg/handler/app_k8s.go:15 +msgid "No kubernetes" +msgstr "Kubernetes не найден" + +#. lang.T +#: pkg/handler/app_k8s.go:31 +msgid "Page: %d, Count: %d, Total Page: %d, Total Count: %d" +msgstr "Страница: %d, строк на странице: %d, всего страниц: %d, всего записей: %d" + +#. lang.T +#: pkg/handler/app_k8s.go:45 +msgid "Enter ID number directly login, multiple search use // + field, such as: //16" +msgstr "" +"Подсказка: введите ID ресурса для прямого входа, для расширенного поиска используйте // + значение, например: //192" + +#. lang.T +#: pkg/handler/app_k8s.go:46 +msgid "Page up: b\tPage down: n" +msgstr "Предыдущая страница: b Следующая страница: n" + +#. lang.T +#: pkg/handler/asset.go:45 +msgid "No Assets" +msgstr "Нет активов" + +#. lang.T +#: pkg/handler/asset.go:57 +msgid "ID" +msgstr "ID" + +#. lang.T +#: pkg/handler/asset.go:58 +msgid "Name" +msgstr "Имя" + +#. lang.T +#: pkg/handler/asset.go:59 +msgid "Address" +msgstr "Адрес" + +#. lang.T +#: pkg/handler/asset.go:60 +msgid "Platform" +msgstr "Платформа" + +#. lang.T +#: pkg/handler/asset.go:61 +msgid "Organization" +msgstr "Организация" + +#. lang.T +#: pkg/handler/asset.go:62 +msgid "Comment" +msgstr "Примечание" + +#. lang.T +#: pkg/handler/asset.go:176 +msgid "%s protocol client not installed." +msgstr "Клиент протокола %s не установлен" + +#. lang.T +#: pkg/handler/asset.go:179 +msgid "Terminal does not support protocol %s, please use web terminal to access" +msgstr "Этот терминал не поддерживает протокол %s, пожалуйста, войдите через веб-терминал" + +#. lang.T +#: pkg/handler/asset.go:214 +msgid "Core API failed" +msgstr "Ошибка Core API" + +#. lang.T +#: pkg/handler/asset.go:220 +msgid "ACL reject" +msgstr "Вход запрещен: ограничение политики контроля доступа" + +#. lang.T +#: pkg/handler/asset.go:226 +msgid "Face ACL is not supported yet. Please use the WebTerminal to connect the asset." +msgstr "Этот терминал пока не поддерживает правила доступа по лицу, пожалуйста, войдите через веб-терминал" + +#. lang.T +#. lang.T +#: pkg/handler/asset.go:241 pkg/handler/asset.go:250 +msgid "Unknown error code: %s, detail: %s" +msgstr "Неизвестный код ошибки: %s, подробности: %s" + +#. lang.T +#: pkg/handler/asset.go:261 +msgid "get connect token err" +msgstr "Ошибка получения токена подключения" + +#. lang.T +#: pkg/handler/asset_node.go:22 +msgid "%s node has no assets" +msgstr "В папке %s нет активов" + +#. lang.T +#: pkg/handler/banner.go:30 +msgid "Welcome to use JumpServer open source fortress system" +msgstr "Добро пожаловать в JumpServer" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "part IP, Hostname, Comment" +msgstr "часть IP, имя хоста или примечание" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "to search login if unique" +msgstr "найти подключение (если результат уникальный)" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "/ + IP, Hostname, Comment" +msgstr "/ + IP, имя хоста или примечание" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "to search, such as: /192.168" +msgstr "выполнить поиск, например: /192.168" + +#. lang.T +#: pkg/handler/banner.go:34 +msgid "display the assets you have permission" +msgstr "посмотреть активы, к которым у вас есть доступ" + +#. lang.T +#: pkg/handler/banner.go:35 +msgid "display the node that you have permission" +msgstr "посмотреть папки, к которым у вас есть доступ" + +#. lang.T +#: pkg/handler/banner.go:36 +msgid "display the hosts that you have permission" +msgstr "посмотреть хосты, к которым у вас есть доступ" + +#. lang.T +#: pkg/handler/banner.go:37 +msgid "display the databases that you have permission" +msgstr "посмотреть базы данных, к которым у вас есть доступ" + +#. lang.T +#: pkg/handler/banner.go:38 +msgid "display the kubernetes that you have permission" +msgstr "посмотреть доступные вам Kubernetes" + +#. lang.T +#: pkg/handler/banner.go:39 +msgid "refresh your assets and nodes" +msgstr "обновить информацию об активах и папках" + +#. lang.T +#: pkg/handler/banner.go:40 +msgid "language switch" +msgstr "сменить язык" + +#. lang.T +#: pkg/handler/banner.go:41 +msgid "print help" +msgstr "посмотреть помощь" + +#. lang.T +#: pkg/handler/banner.go:42 +msgid "exit" +msgstr "выйти" + +#. lang.T +#: pkg/handler/banner.go:60 +msgid "\t%2d) Enter {{.GreenBoldColor}}%s{{.ColorEnd}} to %s.%s" +msgstr "\t%2d) Введите {{.GreenBoldColor}}%s{{.ColorEnd}} чтобы %s.%s" + +#. lang.T +#: pkg/handler/banner.go:85 +msgid "Announcement: " +msgstr "Объявление: " + +#. lang.T +#: pkg/handler/direct_handler.go:272 +msgid "No Account found." +msgstr "Учетная запись не найдена" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:282 pkg/handler/direct_handler.go:283 pkg/handler/direct_handler.go:284 +msgid "Username" +msgstr "Имя пользователя" + +#. lang.T +#: pkg/handler/direct_handler.go:313 +msgid "Tips: Enter asset[%s] account ID" +msgstr "Подсказка: Введите ID учетной записи актива [%s]" + +#. lang.T +#: pkg/handler/direct_handler.go:314 +msgid "Back: B/b" +msgstr "Назад: B/b" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:349 pkg/handler/direct_handler.go:350 +msgid "Hostname" +msgstr "Имя хоста" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:351 pkg/handler/direct_handler.go:352 pkg/handler/direct_handler.go:381 +msgid "select one asset to login" +msgstr "выберите один из активов для входа" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:395 pkg/handler/direct_handler.go:400 +msgid "not found matched username %s" +msgstr "совпадений для УЗ %s не найдено" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:433 pkg/handler/direct_handler.go:439 pkg/handler/direct_handler.go:445 +msgid "Face verification is not supported yet. Please use the WebTerminal to connect the asset." +msgstr "Этот терминал не поддерживает аутентификацию по лицу, пожалуйста, войдите через веб-терминал" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:460 pkg/handler/direct_handler.go:476 pkg/handler/dispatch.go:127 +#: pkg/handler/dispatch.go:128 pkg/handler/dispatch.go:153 +msgid "Tips: switch language by ID (Current session only)" +msgstr "Подсказка: введите ID, чтобы переключить язык (только для этой сессии)" + +#. lang.T +#: pkg/handler/dispatch.go:154 +msgid "Tips: To set a default language, go to Personal Settings → Preferences on Web" +msgstr "" +"Подсказка: если хотите установить язык по умолчанию, перейдите в веб-версию в «Личные настройки → Предпочтения»" + +#. lang.T +#. lang.T +#: pkg/handler/dispatch.go:155 pkg/handler/dispatch.go:183 +msgid "Invalid ID" +msgstr "Неверный ID" + +#. lang.T +#: pkg/handler/dispatch.go:189 +msgid "Switch language successfully" +msgstr "Смена языка успешно выполнена" + +#. lang.T +#: pkg/handler/dispatch.go:199 +msgid "Node: [ ID.Name(Asset amount) ]" +msgstr "Папка: [ID.Название(кол-во активов)]" + +#. lang.T +#: pkg/handler/dispatch.go:201 +msgid "Tips: Enter g+NodeID to display the host under the node, such as g1" +msgstr "Подсказка: введите g+ID папки, чтобы показать хосты внутри, например: g1" + +#. lang.T +#: pkg/handler/interactive.go:64 +msgid "Connect idle more than %d minutes, disconnect" +msgstr "Превышено время простоя (%d минут). Соединение разорвано" + +#. lang.T +#: pkg/handler/interactive.go:198 +msgid "No account found." +msgstr "Учетная запись не найдена" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:208 pkg/handler/interactive.go:209 pkg/handler/interactive.go:210 +#: pkg/handler/interactive.go:240 pkg/handler/interactive.go:241 pkg/handler/interactive.go:267 +msgid "Select account exceed max retry times." +msgstr "Превышено максимальное число попыток выбора УЗ" + +#. lang.T +#: pkg/handler/interactive.go:278 +msgid "No protocol found." +msgstr "Нет протокола" + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:287 pkg/handler/interactive.go:288 +msgid "Protocol" +msgstr "Протокол" + +#. lang.T +#: pkg/handler/interactive.go:315 +msgid "Tips: Enter protocol ID" +msgstr "Подсказка: введите ID протокола" + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:316 pkg/handler/interactive.go:342 +msgid "Select protocol exceed max retry times." +msgstr "Превышено максимальное количество попыток выбора протокола" + +#. lang.T +#: pkg/handler/interactive.go:379 +msgid "Refresh done" +msgstr "Обновлено" + +#. lang.T +#: pkg/handler/login_confirm.go:38 +msgid "Need ACL review, continue? (y/n): " +msgstr "Требуется проверка правил доступа. Продолжить? (y/n): " + +#. lang.T +#: pkg/handler/login_confirm.go:53 +msgid "Cancel to login asset or max 3 retry" +msgstr "Вход на актив отменён или превышен лимит 3 попыток" + +#. lang.T +#. lang.T +#: pkg/handler/login_confirm.go:67 pkg/handler/login_confirm.go:98 +msgid "Need ticket confirm to login, already send email to the reviewers" +msgstr "Требуется вход в систему через заявку, уведомление отправлено утверждающему" + +#. lang.T +#: pkg/handler/login_confirm.go:99 +msgid "Ticket Reviewers: %s" +msgstr "Утверждающий заявки: %s" + +#. lang.T +#: pkg/handler/login_confirm.go:100 +msgid "Could copy website URL to notify reviewers: %s" +msgstr "Можно скопировать URL для проверки и уведомить проверяющего: %s" + +#. lang.T +#: pkg/handler/login_confirm.go:101 +msgid "Please waiting for the reviewers to confirm, enter q to exit. " +msgstr "Ждём подтверждения проверяющего. q + Enter — отмена входа." + +#. lang.T +#: pkg/handler/login_confirm.go:130 +msgid "Unknown status" +msgstr "Неизвестное состояние" + +#. lang.T +#: pkg/handler/login_confirm.go:134 +msgid "%s approved" +msgstr "%s одобрено" + +#. lang.T +#: pkg/handler/login_confirm.go:139 +msgid "%s rejected" +msgstr "%s отклонено" + +#. lang.T +#: pkg/handler/login_confirm.go:143 +msgid "Cancel confirm" +msgstr "Отмена подтверждения входа" + +#. lang.T +#: pkg/handler/select_handler.go:208 +msgid "Search: %s" +msgstr "Поиск: %s" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:223 +msgid "Must be unique asset for %s" +msgstr "Актив %s должен быть уникальным" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:230 pkg/handler/server_ssh.go:252 +msgid "Must be unique account for %s" +msgstr "УЗ %s должна быть уникальной" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:260 +msgid "Must be auto login account for %s" +msgstr "УЗ %s должна быть учётной записью с автоматическим входом" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:682 pkg/handler/server_ssh.go:686 +msgid "No found asset" +msgstr "Совпадений для актива %s не найдено" + +#. i18nLang.T +#. i18nLang.T +#. i18nLang.T +#. lang.T +#: pkg/handler/server_ssh.go:716 pkg/handler/server_ssh.go:721 pkg/handler/server_ssh.go:739 +#: pkg/httpd/userwebsocket.go:110 +msgid "Create k8s client err: %s" +msgstr "Ошибка создания клиента k8s: %s" + +#. lang.T +#: pkg/proxy/parser.go:265 +msgid "have no permission to upload file" +msgstr "Нет прав на загрузку файлов" + +#. lang.T +#: pkg/proxy/parser.go:301 +msgid "" +"The command you executed is risky and an alert notification will be sent to the administrator. Do you want to " +"continue?[Y/N]" +msgstr "Выполняемая вами команда несёт риск, уведомление будет отправлено администратору. Продолжить? [Y/N]" + +#. lang.T +#: pkg/proxy/parser.go:318 +msgid "The command '%s' requires review. Continue or not [Y/n]?" +msgstr "Команда %s требует проверки. Продолжить? [Y/N]" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:338 pkg/proxy/parser.go:345 pkg/proxy/parser.go:436 +msgid "Command `%s` is forbidden" +msgstr "Команда %s запрещена" + +#. lang.T +#: pkg/proxy/parser.go:503 +msgid "have no permission to download file" +msgstr "Нет прав на скачивание файлов" + +#. lang.T +#: pkg/proxy/parser.go:572 +msgid "Please waiting for the reviewers to confirm command `%s`, cancel by CTRL+C or CTRL+D." +msgstr "Пожалуйста, дождитесь проверки команды %s утверждающим. Для отмены нажмите CTRL+C или CTRL+D." + +#. lang.T +#: pkg/proxy/parser.go:582 +msgid "Need ticket confirm to execute command, already send email to the reviewers" +msgstr "Выполнение команды требует проверки по заявке. Проверяющему отправлено письмо" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:583 pkg/proxy/parser.go:584 pkg/proxy/server.go:58 pkg/proxy/server.go:62 +msgid "HandleTask does not support protocol %s, please use web terminal to access" +msgstr "Протокол %s не поддерживается этим терминалом. Используйте веб-терминал" + +#. lang.T +#: pkg/proxy/server.go:70 +msgid "Account <%s> and asset <%s> protocol are inconsistent." +msgstr "Протокол системного пользователя <%s> и актива <%s> не совпадают." + +#. lang.T +#: pkg/proxy/server.go:102 +msgid "You don't have permission login %s" +msgstr "У вас нет прав на вход в %s" + +#. lang.T +#: pkg/proxy/server.go:337 +msgid "You get auth token failed" +msgstr "Не удалось получить токен аутентификации" + +#. lang.T +#: pkg/proxy/server.go:348 +msgid "Get auth password failed" +msgstr "Не удалось получить пароль аутентификации" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:364 pkg/proxy/server.go:421 +msgid "Reuse SSH connections (%s@%s) [Number of connections: %d]" +msgstr "Повторное использование SSH соединения (%s@%s) [Количество соединений: %d]" + +#. lang.T +#: pkg/proxy/server.go:689 +msgid "Switched to %s" +msgstr "Переключено на %s" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:791 pkg/proxy/server.go:936 +msgid "Connect with api server failed" +msgstr "Не удалось подключиться к API-серверу" + +#. lang.T +#: pkg/proxy/server.go:997 +msgid "Start domain gateway failed %s" +msgstr "Не удалось запустить шлюз базы данных %s" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:1005 pkg/proxy/server_options.go:108 +msgid "Manual" +msgstr "Ручной ввод" + +#. lang.T +#: pkg/proxy/server_options.go:110 +msgid "Dynamic" +msgstr "Своя учетная запись" + +#. lang.T +#: pkg/proxy/server_options.go:113 +msgid "Connecting to %s@%s" +msgstr "Подключение к %s@%s" + +#. lang.T +#: pkg/proxy/server_options.go:117 +msgid "Connecting to Database %s" +msgstr "Подключение к базе данных %s" + +#. lang.T +#: pkg/proxy/server_options.go:119 +msgid "Connecting to Kubernetes %s" +msgstr "Подключение к Kubernetes %s" + +#. lang.T +#: pkg/proxy/server_options.go:121 +msgid "Connecting to Kubernetes %s container %s" +msgstr "Подключение к Kubernetes %s, контейнер %s" + +#. lang.T +#: pkg/proxy/switch.go:315 +msgid "Session max time reached, disconnect" +msgstr "Превышено максимальное время сессии. Соединение разорвано" + +#. lang.T +#. lang.T +#: pkg/proxy/switch.go:324 pkg/proxy/switch.go:331 +msgid "Permission has expired, disconnect" +msgstr "Срок авторизации истёк. Соединение разорвано." + +#. lang.T +#: pkg/proxy/switch.go:341 +msgid "Terminated by admin %s" +msgstr "Администратор %s прервал соединение" + +#. lang.T +#: pkg/proxy/tools.go:28 +msgid "Authentication failed" +msgstr "Ошибка аутентификации: неверное имя пользователя или пароль" + +#. lang.T +#: pkg/proxy/tools.go:31 +msgid "Connection refused" +msgstr "Нет соединения (соединение отклонено)" + +#. lang.T +#: pkg/proxy/tools.go:34 +msgid "i/o timeout" +msgstr "Нет соединения (тайм-аут соединения)" + +#. lang.T +#: pkg/proxy/tools.go:37 +msgid "No route to host" +msgstr "Нет соединения (маршрут недоступен)" + +#. lang.T +#: pkg/proxy/tools.go:40 +msgid "network is unreachable" +msgstr "Нет соединения (сеть недоступна)" diff --git a/locale/zh/LC_MESSAGES/koko.mo b/locale/zh/LC_MESSAGES/koko.mo new file mode 100644 index 000000000..edf3996c4 Binary files /dev/null and b/locale/zh/LC_MESSAGES/koko.mo differ diff --git a/locale/zh/LC_MESSAGES/koko.po b/locale/zh/LC_MESSAGES/koko.po new file mode 100644 index 000000000..5c7e5aa5c --- /dev/null +++ b/locale/zh/LC_MESSAGES/koko.po @@ -0,0 +1,587 @@ +msgid "" +msgstr "" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: xgotext\n" + +#. lang.T +#: pkg/handler/app_database.go:11 +msgid "No Databases" +msgstr "无数据库" + +#. lang.T +#: pkg/handler/app_k8s.go:15 +msgid "No kubernetes" +msgstr "没有kubernetes" + +#. lang.T +#: pkg/handler/app_k8s.go:31 +msgid "Page: %d, Count: %d, Total Page: %d, Total Count: %d" +msgstr "页码:%d,每页行数:%d,总页数:%d,总数量:%d" + +#. lang.T +#: pkg/handler/app_k8s.go:45 +msgid "" +"Enter ID number directly login, multiple search use // + field, such as: //16" +msgstr "提示:输入资产ID直接登录,二级搜索使用 // + 字段,如://192" + +#. lang.T +#: pkg/handler/app_k8s.go:46 +msgid "Page up: b\tPage down: n" +msgstr "上一页:b 下一页:n" + +#. lang.T +#: pkg/handler/asset.go:45 +msgid "No Assets" +msgstr "没有资产" + +#. lang.T +#: pkg/handler/asset.go:57 +msgid "ID" +msgstr "ID" + +#. lang.T +#: pkg/handler/asset.go:58 +msgid "Name" +msgstr "名称" + +#. lang.T +#: pkg/handler/asset.go:59 +msgid "Address" +msgstr "地址" + +#. lang.T +#: pkg/handler/asset.go:60 +msgid "Platform" +msgstr "平台" + +#. lang.T +#: pkg/handler/asset.go:61 +msgid "Organization" +msgstr "组织" + +#. lang.T +#: pkg/handler/asset.go:62 +msgid "Comment" +msgstr "备注" + +#. lang.T +#: pkg/handler/asset.go:176 +msgid "%s protocol client not installed." +msgstr "%s 协议的客户端未安装" + +#. lang.T +#: pkg/handler/asset.go:179 +msgid "" +"Terminal does not support protocol %s, please use web terminal to access" +msgstr "该终端不支持 %s 协议,请使用web终端登录" + +#. lang.T +#: pkg/handler/asset.go:214 +msgid "Core API failed" +msgstr "Core API 发生错误" + +#. lang.T +#: pkg/handler/asset.go:220 +msgid "ACL reject" +msgstr "本次登录已拒绝,原因是访问控制策略的限制" + +#. lang.T +#: pkg/handler/asset.go:226 +msgid "" +"Face ACL is not supported yet. Please use the WebTerminal to connect the " +"asset." +msgstr "该终端不支持人脸访问规则,请使用web终端登录" + +#. lang.T +#. lang.T +#: pkg/handler/asset.go:241 pkg/handler/asset.go:250 +msgid "Unknown error code: %s, detail: %s" +msgstr "未知错误代码:%s,详情:%s" + +#. lang.T +#: pkg/handler/asset.go:261 +msgid "get connect token err" +msgstr "获取 connect token 错误" + +#. lang.T +#: pkg/handler/asset_node.go:22 +msgid "%s node has no assets" +msgstr "%s节点没有资产" + +#. lang.T +#: pkg/handler/banner.go:30 +msgid "Welcome to use JumpServer open source fortress system" +msgstr "欢迎使用JumpServer开源堡垒机系统" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "part IP, Hostname, Comment" +msgstr "部分IP,主机名,备注" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "to search login if unique" +msgstr "搜索登录(如果唯一)" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "/ + IP, Hostname, Comment" +msgstr "/ + IP,主机名,备注" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "to search, such as: /192.168" +msgstr "搜索,如:/192.168" + +#. lang.T +#: pkg/handler/banner.go:34 +msgid "display the assets you have permission" +msgstr "显示您有权限的资产" + +#. lang.T +#: pkg/handler/banner.go:35 +msgid "display the node that you have permission" +msgstr "显示您有权限的节点" + +#. lang.T +#: pkg/handler/banner.go:36 +msgid "display the hosts that you have permission" +msgstr "显示您有权限的主机" + +#. lang.T +#: pkg/handler/banner.go:37 +msgid "display the databases that you have permission" +msgstr "显示您有权限的数据库" + +#. lang.T +#: pkg/handler/banner.go:38 +msgid "display the kubernetes that you have permission" +msgstr "显示您有权限的Kubernetes" + +#. lang.T +#: pkg/handler/banner.go:39 +msgid "refresh your assets and nodes" +msgstr "刷新最新的机器和节点信息" + +#. lang.T +#: pkg/handler/banner.go:40 +msgid "language switch" +msgstr "语言切换" + +#. lang.T +#: pkg/handler/banner.go:41 +msgid "print help" +msgstr "显示帮助" + +#. lang.T +#: pkg/handler/banner.go:42 +msgid "exit" +msgstr "退出" + +#. lang.T +#: pkg/handler/banner.go:60 +msgid "\t%2d) Enter {{.GreenBoldColor}}%s{{.ColorEnd}} to %s.%s" +msgstr "\t%d) 输入 {{.GreenBoldColor}}%s{{.ColorEnd}} 进行%s.%s" + +#. lang.T +#: pkg/handler/banner.go:85 +msgid "Announcement: " +msgstr "公告:" + +#. lang.T +#: pkg/handler/direct_handler.go:272 +msgid "No Account found." +msgstr "未发现账号" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:282 pkg/handler/direct_handler.go:283 +#: pkg/handler/direct_handler.go:284 +msgid "Username" +msgstr "用户名" + +#. lang.T +#: pkg/handler/direct_handler.go:313 +msgid "Tips: Enter asset[%s] account ID" +msgstr "提示:输入资产[%s]的账号ID" + +#. lang.T +#: pkg/handler/direct_handler.go:314 +msgid "Back: B/b" +msgstr "返回:B/b" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:349 pkg/handler/direct_handler.go:350 +msgid "Hostname" +msgstr "主机名" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:351 pkg/handler/direct_handler.go:352 +#: pkg/handler/direct_handler.go:381 +msgid "select one asset to login" +msgstr "选择其中一个资产登录" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:395 pkg/handler/direct_handler.go:400 +msgid "not found matched username %s" +msgstr "未发现匹配的用户名 %s" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:433 pkg/handler/direct_handler.go:439 +#: pkg/handler/direct_handler.go:445 +msgid "" +"Face verification is not supported yet. Please use the WebTerminal to " +"connect the asset." +msgstr "该终端不支持人脸识别认证,请使用web终端登录" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:460 pkg/handler/direct_handler.go:476 +#: pkg/handler/dispatch.go:127 pkg/handler/dispatch.go:128 +#: pkg/handler/dispatch.go:153 +#, fuzzy +msgid "Tips: switch language by ID (Current session only)" +msgstr "提示:输入ID切换语言" + +#. lang.T +#: pkg/handler/dispatch.go:154 +msgid "" +"Tips: To set a default language, go to Personal Settings → Preferences on Web" +msgstr "提示:如需设置默认语言,请前往 Web 端「个人设置 → 偏好设置」" + +#. lang.T +#. lang.T +#: pkg/handler/dispatch.go:155 pkg/handler/dispatch.go:183 +msgid "Invalid ID" +msgstr "无效 ID" + +#. lang.T +#: pkg/handler/dispatch.go:189 +msgid "Switch language successfully" +msgstr "切换语言成功" + +#. lang.T +#: pkg/handler/dispatch.go:199 +msgid "Node: [ ID.Name(Asset amount) ]" +msgstr "节点:[ ID.名称(资产数量) ]" + +#. lang.T +#: pkg/handler/dispatch.go:201 +msgid "Tips: Enter g+NodeID to display the host under the node, such as g1" +msgstr "提示:输入 g+节点ID 显示节点下主机,如: g1" + +#. lang.T +#: pkg/handler/interactive.go:64 +msgid "Connect idle more than %d minutes, disconnect" +msgstr "空闲时间超过%d分钟,断开连接" + +#. lang.T +#: pkg/handler/interactive.go:198 +msgid "No account found." +msgstr "未发现账号" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:208 pkg/handler/interactive.go:209 +#: pkg/handler/interactive.go:210 pkg/handler/interactive.go:240 +#: pkg/handler/interactive.go:241 pkg/handler/interactive.go:267 +msgid "Select account exceed max retry times." +msgstr "选择账号超过最大重试次数" + +#. lang.T +#: pkg/handler/interactive.go:278 +msgid "No protocol found." +msgstr "无协议" + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:287 pkg/handler/interactive.go:288 +msgid "Protocol" +msgstr "协议" + +#. lang.T +#: pkg/handler/interactive.go:315 +msgid "Tips: Enter protocol ID" +msgstr "提示:输入协议ID" + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:316 pkg/handler/interactive.go:342 +msgid "Select protocol exceed max retry times." +msgstr "选择协议超过最大重试次数" + +#. lang.T +#: pkg/handler/interactive.go:379 +msgid "Refresh done" +msgstr "刷新完成" + +#. lang.T +#: pkg/handler/login_confirm.go:38 +msgid "Need ACL review, continue? (y/n): " +msgstr "需要审核,继续?(y/n): " + +#. lang.T +#: pkg/handler/login_confirm.go:53 +msgid "Cancel to login asset or max 3 retry" +msgstr "取消登录资产或达到3次重试" + +#. lang.T +#. lang.T +#: pkg/handler/login_confirm.go:67 pkg/handler/login_confirm.go:98 +msgid "Need ticket confirm to login, already send email to the reviewers" +msgstr "需要工单登录复核,已发邮件通知审核人" + +#. lang.T +#: pkg/handler/login_confirm.go:99 +msgid "Ticket Reviewers: %s" +msgstr "工单审核人:%s " + +#. lang.T +#: pkg/handler/login_confirm.go:100 +msgid "Could copy website URL to notify reviewers: %s" +msgstr "可复制审核地址,通知审核人:%s" + +#. lang.T +#: pkg/handler/login_confirm.go:101 +msgid "Please waiting for the reviewers to confirm, enter q to exit. " +msgstr "等待审核人复核确认,按 q 回车取消登录。" + +#. lang.T +#: pkg/handler/login_confirm.go:130 +msgid "Unknown status" +msgstr "未知状态" + +#. lang.T +#: pkg/handler/login_confirm.go:134 +msgid "%s approved" +msgstr "%s 审核通过" + +#. lang.T +#: pkg/handler/login_confirm.go:139 +msgid "%s rejected" +msgstr "%s 审核拒绝" + +#. lang.T +#: pkg/handler/login_confirm.go:143 +msgid "Cancel confirm" +msgstr "取消登录复核" + +#. lang.T +#: pkg/handler/select_handler.go:208 +msgid "Search: %s" +msgstr "搜索:%s" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:223 +msgid "Must be unique asset for %s" +msgstr "必须是唯一的资产 %s" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:230 pkg/handler/server_ssh.go:252 +msgid "Must be unique account for %s" +msgstr "必须是唯一的账号 %s" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:260 +msgid "Must be auto login account for %s" +msgstr "必须是自动登录账号 %s" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:682 pkg/handler/server_ssh.go:686 +msgid "No found asset" +msgstr "未发现匹配的资产 %s" + +#. i18nLang.T +#. i18nLang.T +#. i18nLang.T +#. lang.T +#: pkg/handler/server_ssh.go:716 pkg/handler/server_ssh.go:721 +#: pkg/handler/server_ssh.go:739 pkg/httpd/userwebsocket.go:110 +msgid "Create k8s client err: %s" +msgstr "创建 k8s 客户端错误:%s" + +#. lang.T +#: pkg/proxy/parser.go:265 +msgid "have no permission to upload file" +msgstr "无权限上传文件" + +#. lang.T +#: pkg/proxy/parser.go:301 +msgid "" +"The command you executed is risky and an alert notification will be sent to " +"the administrator. Do you want to continue?[Y/N]" +msgstr "您执行的命令存在风险,告警通知将发送给管理员。是否继续?[Y/N]" + +#. lang.T +#: pkg/proxy/parser.go:318 +msgid "The command '%s' requires review. Continue or not [Y/n]?" +msgstr "命令 %s 需要复核,是否继续?[Y/N]" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:338 pkg/proxy/parser.go:345 pkg/proxy/parser.go:436 +msgid "Command `%s` is forbidden" +msgstr "命令 `%s` 是被禁止的 ..." + +#. lang.T +#: pkg/proxy/parser.go:503 +msgid "have no permission to download file" +msgstr "无权限下载文件" + +#. lang.T +#: pkg/proxy/parser.go:572 +msgid "" +"Please waiting for the reviewers to confirm command `%s`, cancel by CTRL+C " +"or CTRL+D." +msgstr "请等待审核人复核命令 `%s`,取消按 CTRL+C 或 CTRL+D。" + +#. lang.T +#: pkg/proxy/parser.go:582 +msgid "" +"Need ticket confirm to execute command, already send email to the reviewers" +msgstr "需要工单命令执行复核,已发邮件通知审核人" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:583 pkg/proxy/parser.go:584 pkg/proxy/server.go:58 +#: pkg/proxy/server.go:62 +msgid "" +"HandleTask does not support protocol %s, please use web terminal to access" +msgstr "该终端不支持 %s 协议,请使用web终端登录" + +#. lang.T +#: pkg/proxy/server.go:70 +msgid "Account <%s> and asset <%s> protocol are inconsistent." +msgstr "系统用户<%s>和资产<%s>协议不一致" + +#. lang.T +#: pkg/proxy/server.go:102 +msgid "You don't have permission login %s" +msgstr "你无权限登陆%s" + +#. lang.T +#: pkg/proxy/server.go:337 +msgid "You get auth token failed" +msgstr "你获取认证令牌失败" + +#. lang.T +#: pkg/proxy/server.go:348 +msgid "Get auth password failed" +msgstr "你获取认证令牌失败" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:364 pkg/proxy/server.go:421 +msgid "Reuse SSH connections (%s@%s) [Number of connections: %d]" +msgstr "复用SSH连接(%s@%s)[连接数量: %d]" + +#. lang.T +#: pkg/proxy/server.go:689 +msgid "Switched to %s" +msgstr "已切换至%s" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:791 pkg/proxy/server.go:936 +msgid "Connect with api server failed" +msgstr "连接API服务失败" + +#. lang.T +#: pkg/proxy/server.go:997 +msgid "Start domain gateway failed %s" +msgstr "启动数据库网关失败%s" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:1005 pkg/proxy/server_options.go:108 +msgid "Manual" +msgstr "手动账号" + +#. lang.T +#: pkg/proxy/server_options.go:110 +msgid "Dynamic" +msgstr "动态账号" + +#. lang.T +#: pkg/proxy/server_options.go:113 +msgid "Connecting to %s@%s" +msgstr "开始连接到 %s@%s" + +#. lang.T +#: pkg/proxy/server_options.go:117 +msgid "Connecting to Database %s" +msgstr "开始连接数据库 %s" + +#. lang.T +#: pkg/proxy/server_options.go:119 +msgid "Connecting to Kubernetes %s" +msgstr "开始连接Kubernetes %s" + +#. lang.T +#: pkg/proxy/server_options.go:121 +msgid "Connecting to Kubernetes %s container %s" +msgstr "开始连接Kubernetes %s 容器 %s" + +#. lang.T +#: pkg/proxy/switch.go:315 +msgid "Session max time reached, disconnect" +msgstr "会话超过最大连接时间,断开连接" + +#. lang.T +#. lang.T +#: pkg/proxy/switch.go:324 pkg/proxy/switch.go:331 +msgid "Permission has expired, disconnect" +msgstr "授权已过期,断开连接" + +#. lang.T +#: pkg/proxy/switch.go:341 +msgid "Terminated by admin %s" +msgstr "%s 管理员终断连接" + +#. lang.T +#: pkg/proxy/tools.go:28 +msgid "Authentication failed" +msgstr "认证失败(用户名或密码错误)" + +#. lang.T +#: pkg/proxy/tools.go:31 +msgid "Connection refused" +msgstr "网络不通(连接拒绝)" + +#. lang.T +#: pkg/proxy/tools.go:34 +msgid "i/o timeout" +msgstr "网络不通(连接超时)" + +#. lang.T +#: pkg/proxy/tools.go:37 +msgid "No route to host" +msgstr "网络不通(路由不通)" + +#. lang.T +#: pkg/proxy/tools.go:40 +msgid "network is unreachable" +msgstr "网络不通(网络不可达)" diff --git a/locale/zh_CN/LC_MESSAGES/koko.mo b/locale/zh_CN/LC_MESSAGES/koko.mo deleted file mode 100644 index 6f402296c..000000000 Binary files a/locale/zh_CN/LC_MESSAGES/koko.mo and /dev/null differ diff --git a/locale/zh_CN/LC_MESSAGES/koko.po b/locale/zh_CN/LC_MESSAGES/koko.po deleted file mode 100644 index 71d74c0cb..000000000 --- a/locale/zh_CN/LC_MESSAGES/koko.po +++ /dev/null @@ -1,565 +0,0 @@ -msgid "" -msgstr "" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: xgotext\n" - -#. lang.T -#: pkg/handler/app_k8s.go:55 -msgid "No kubernetes" -msgstr "没有kubernetes" - -#. lang.T -#: pkg/handler/app_k8s.go:68 -msgid "ID" -msgstr "ID" - -#. lang.T -#: pkg/handler/app_k8s.go:69 -msgid "Name" -msgstr "名称" - -#. lang.T -#: pkg/handler/app_k8s.go:70 -msgid "Cluster" -msgstr "集群" - -#. lang.T -#: pkg/handler/app_k8s.go:71 -#, fuzzy -msgid "Comment" -msgstr "备注" - -#. lang.T -#: pkg/handler/app_k8s.go:90 -msgid "Page: %d, Count: %d, Total Page: %d, Total Count: %d" -msgstr "页码:%d,每页行数:%d,总页数:%d,总数量:%d" - -#. lang.T -#: pkg/handler/app_k8s.go:110 -#, fuzzy -msgid "" -"Enter ID number directly login the kubernetes, multiple search use // + " -"field, such as: //16" -msgstr "提示:输入Kubernetes的ID直接登录,二级搜索使用 // + 字段,如://192" - -#. lang.T -#: pkg/handler/app_k8s.go:111 -#, fuzzy -msgid "Page up: b\tPage down: n" -msgstr "上一页:b 下一页:n" - -#. lang.T -#: pkg/handler/app_mysql.go:56 -msgid "No Databases" -msgstr "无数据库" - -#. lang.T -#. lang.T -#. lang.T -#: pkg/handler/app_mysql.go:69 pkg/handler/app_mysql.go:70 -#: pkg/handler/app_mysql.go:71 -msgid "IP" -msgstr "IP" - -#. lang.T -#: pkg/handler/app_mysql.go:72 -msgid "DBType" -msgstr "数据库类型" - -#. lang.T -#: pkg/handler/app_mysql.go:73 -#, fuzzy -msgid "DB Name" -msgstr "数据库名称" - -#. lang.T -#. lang.T -#. lang.T -#: pkg/handler/app_mysql.go:74 pkg/handler/app_mysql.go:96 -#: pkg/handler/app_mysql.go:117 -#, fuzzy -msgid "" -"Enter ID number directly login the database, multiple search use // + field, " -"such as: //16" -msgstr "提示:输入数据库ID直接登录,二级搜索使用 // + 字段,如://192" - -#. lang.T -#. lang.T -#: pkg/handler/app_mysql.go:118 pkg/handler/asset.go:56 -msgid "No Assets" -msgstr "没有资产" - -#. lang.T -#. lang.T -#: pkg/handler/asset.go:85 pkg/handler/asset.go:86 -#, fuzzy -msgid "Hostname" -msgstr "主机名" - -#. lang.T -#. lang.T -#. lang.T -#. lang.T -#: pkg/handler/asset.go:87 pkg/handler/asset.go:88 pkg/handler/asset.go:106 -#: pkg/handler/asset.go:125 -#, fuzzy -msgid "" -"Enter ID number directly login the asset, multiple search use // + field, " -"such as: //16" -msgstr "提示:输入资产ID直接登录,二级搜索使用 // + 字段,如://192" - -#. lang.T -#. lang.T -#: pkg/handler/asset.go:126 pkg/handler/asset_node.go:24 -msgid "%s node has no assets" -msgstr "%s节点没有资产" - -#. lang.T -#: pkg/handler/banner.go:29 -#, fuzzy -msgid "Welcome to use JumpServer open source fortress system" -msgstr "欢迎使用JumpServer开源堡垒机系统" - -#. lang.T -#: pkg/handler/banner.go:31 -msgid "part IP, Hostname, Comment" -msgstr "部分IP,主机名,备注" - -#. lang.T -#: pkg/handler/banner.go:31 -#, fuzzy -msgid "to search login if unique" -msgstr "搜索登录(如果唯一)" - -#. lang.T -#: pkg/handler/banner.go:32 -msgid "/ + IP, Hostname, Comment" -msgstr "/ + IP,主机名,备注" - -#. lang.T -#: pkg/handler/banner.go:32 -#, fuzzy -msgid "to search, such as: /192.168" -msgstr "搜索,如:/192.168" - -#. lang.T -#: pkg/handler/banner.go:33 -msgid "display the host you have permission" -msgstr "显示您有权限的主机" - -#. lang.T -#: pkg/handler/banner.go:34 -msgid "display the node that you have permission" -msgstr "显示您有权限的节点" - -#. lang.T -#: pkg/handler/banner.go:35 -#, fuzzy -msgid "display the databases that you have permission" -msgstr "显示您有权限的数据库" - -#. lang.T -#: pkg/handler/banner.go:36 -#, fuzzy -msgid "display the kubernetes that you have permission" -msgstr "显示您有权限的Kubernetes" - -#. lang.T -#: pkg/handler/banner.go:37 -msgid "refresh your assets and nodes" -msgstr "刷新最新的机器和节点信息" - -#. lang.T -#: pkg/handler/banner.go:38 -msgid "Chinese-English-Japanese switch" -msgstr "中英日语言切换" - -#. lang.T -#: pkg/handler/banner.go:39 -msgid "print help" -msgstr "显示帮助" - -#. lang.T -#: pkg/handler/banner.go:40 -msgid "exit" -msgstr "退出" - -#. lang.T -#: pkg/handler/banner.go:58 -msgid "\t%d) Enter {{.GreenBoldColor}}%s{{.ColorEnd}} to %s.%s" -msgstr "\t%d) 输入 {{.GreenBoldColor}}%s{{.ColorEnd}} 进行%s.%s" - -#. i18n.T -#: pkg/handler/direct_handler.go:110 -#, fuzzy -msgid "Core API failed" -msgstr "Core API 发生错误" - -#. i18n.T -#: pkg/handler/direct_handler.go:114 -#, fuzzy -msgid "not found matched asset %s" -msgstr "未发现匹配的资产 %s" - -#. i18n.T -#: pkg/handler/direct_handler.go:200 -msgid "No system user found." -msgstr "没有系统用户" - -#. i18n.T -#. i18n.T -#. i18n.T -#: pkg/handler/direct_handler.go:212 pkg/handler/direct_handler.go:213 -#: pkg/handler/direct_handler.go:214 -#, fuzzy -msgid "Username" -msgstr "用户名" - -#. i18n.T -#: pkg/handler/direct_handler.go:243 -#, fuzzy -msgid "Tips: Enter system user ID and directly login" -msgstr "提示:输入系统用户ID直接登录" - -#. i18n.T -#: pkg/handler/direct_handler.go:244 -msgid "Back: B/b" -msgstr "返回:B/b" - -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#: pkg/handler/direct_handler.go:274 pkg/handler/direct_handler.go:275 -#: pkg/handler/direct_handler.go:276 pkg/handler/direct_handler.go:277 -#: pkg/handler/direct_handler.go:306 -msgid "select one asset to login" -msgstr "选择其中一个资产登录" - -#. i18n.T -#: pkg/handler/direct_handler.go:319 -msgid "not found matched username %s" -msgstr "未发现匹配的用户名 %s" - -#. i18n.T -#. i18n.T -#. lang.T -#: pkg/handler/direct_handler.go:352 pkg/handler/direct_handler.go:360 -#: pkg/handler/dispatch.go:128 -msgid "Node: [ ID.Name(Asset amount) ]" -msgstr "节点:[ ID.名称(资产数量) ]" - -#. lang.T -#: pkg/handler/dispatch.go:130 -msgid "Tips: Enter g+NodeID to display the host under the node, such as g1" -msgstr "提示:输入 g+节点ID 显示节点下主机,如: g1" - -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#: pkg/handler/interactive.go:145 pkg/handler/interactive.go:157 -#: pkg/handler/interactive.go:158 pkg/handler/interactive.go:159 -#: pkg/handler/interactive.go:188 pkg/handler/interactive.go:189 -#: pkg/handler/interactive.go:242 -msgid "Refresh done" -msgstr "刷新完成" - -#. lang.T -#: pkg/handler/select_handler.go:199 -#, fuzzy -msgid "Search: %s" -msgstr "搜索:%s" - -#. lang.T -#: pkg/handler/select_handler.go:226 -msgid "The asset is inactive" -msgstr "该资产已禁用" - -#. i18n.T -#: pkg/koko/server_ssh.go:195 -msgid "Must be unique asset for %s" -msgstr "必须是唯一的资产 %s" - -#. i18n.T -#: pkg/koko/server_ssh.go:207 -msgid "Must be unique system user for %s" -msgstr "必须是唯一的系统用户 %s" - -#. i18n.T -#. i18n.T -#. i18n.T -#. i18n.T -#. lang.T -#: pkg/koko/server_ssh.go:349 pkg/koko/server_ssh.go:356 -#: pkg/koko/server_ssh.go:367 pkg/koko/server_ssh.go:374 -#: pkg/proxy/login_confirm.go:21 -msgid "validate Login confirm err: Core Api failed" -msgstr "校验登录复核失败:Core API异常" - -#. lang.T -#: pkg/proxy/login_confirm.go:51 -#, fuzzy -msgid "Need ticket confirm to login, already send email to the reviewers" -msgstr "需要工单登录复核,已发邮件通知审核人" - -#. lang.T -#: pkg/proxy/login_confirm.go:52 -#, fuzzy -msgid "Ticket Reviewers: %s" -msgstr "工单审核人:%s " - -#. lang.T -#: pkg/proxy/login_confirm.go:53 -#, fuzzy -msgid "Could copy website URL to notify reviewers: %s" -msgstr "可复制审核地址,通知审核人:%s" - -#. lang.T -#: pkg/proxy/login_confirm.go:54 -#, fuzzy -msgid "Please waiting for the reviewers to confirm, enter q to exit. " -msgstr "等待审核人复核确认,按 q 回车取消登录。" - -#. lang.T -#: pkg/proxy/login_confirm.go:81 -msgid "Unknown status" -msgstr "未知状态" - -#. lang.T -#: pkg/proxy/login_confirm.go:85 -msgid "%s approved" -msgstr "%s 审核通过" - -#. lang.T -#: pkg/proxy/login_confirm.go:90 -msgid "%s rejected" -msgstr "%s 审核拒绝" - -#. lang.T -#: pkg/proxy/login_confirm.go:94 -#, fuzzy -msgid "Cancel confirm" -msgstr "取消登录复核" - -#. lang.Tzh -#: pkg/proxy/parser.go:172 -msgid "have no permission to upload file" -msgstr "无权限上传文件" - -#. lang.T -#: pkg/proxy/parser.go:207 -msgid "the reviewers will confirm. continue or not [Y/n]" -msgstr "需要审核人复核,是否继续 [Y/N]" - -#. lang.T -#. lang.T -#. lang.T -#: pkg/proxy/parser.go:226 pkg/proxy/parser.go:232 pkg/proxy/parser.go:271 -msgid "Command review is not currently supported" -msgstr "此命令复核暂不支持" - -#. lang.T -#: pkg/proxy/parser.go:312 -msgid "Command `%s` is forbidden" -msgstr "命令 `%s` 是被禁止的 ..." - -#. lang.T -#: pkg/proxy/parser.go:379 -msgid "have no permission to download file" -msgstr "无权限下载文件" - -#. lang.T -#: pkg/proxy/parser.go:440 -#, fuzzy -msgid "" -"Please waiting for the reviewers to confirm command `%s`, cancel by CTRL+C." -msgstr "请等待审核人复核命令 `%s`,取消按 CTRL+C。" - -#. lang.T -#: pkg/proxy/parser.go:448 -#, fuzzy -msgid "" -"Need ticket confirm to execute command, already send email to the reviewers" -msgstr "需要工单命令执行复核,已发邮件通知审核人" - -#. lang.T -#. lang.T -#. lang.T -#: pkg/proxy/parser.go:449 pkg/proxy/parser.go:450 pkg/proxy/server.go:143 -#, fuzzy -msgid "Connecting to %s@%s" -msgstr "开始连接到 %s@%s" - -#. lang.T -#: pkg/proxy/server.go:146 -#, fuzzy -msgid "Connecting to Database %s" -msgstr "开始连接数据库 %s" - -#. lang.T -#: pkg/proxy/server.go:148 -#, fuzzy -msgid "Connecting to Kubernetes %s" -msgstr "开始连接Kubernetes %s" - -#. lang.T -#: pkg/proxy/server.go:150 -#, fuzzy -msgid "Connecting to Kubernetes %s container %s" -msgstr "开始连接Kubernetes %s 容器 %s" - -#. lang.T -#: pkg/proxy/server.go:197 -#, fuzzy -msgid "%s protocol client not installed." -msgstr "%s 协议的客户端未安装" - -#. lang.T -#: pkg/proxy/server.go:201 -#, fuzzy -msgid "" -"Terminal does not support protocol %s, please use web terminal to access" -msgstr "该终端不支持 %s 协议,请使用web终端登录" - -#. lang.T -#: pkg/proxy/server.go:283 -msgid "System user <%s> and asset <%s> protocol are inconsistent." -msgstr "系统用户<%s>和资产<%s>协议不一致" - -#. lang.T -#: pkg/proxy/server.go:348 -msgid "You don't have permission login %s" -msgstr "你无权限登陆%s" - -#. lang.T -#: pkg/proxy/server.go:601 -msgid "You get auth token failed" -msgstr "你获取认证令牌失败" - -#. lang.T -#: pkg/proxy/server.go:608 -#, fuzzy -msgid "Get auth username failed" -msgstr "你获取认证令牌失败" - -#. lang.T -#: pkg/proxy/server.go:613 -#, fuzzy -msgid "Get auth password failed" -msgstr "你获取认证令牌失败" - -#. lang.T -#. lang.T -#. lang.T -#. lang.T -#: pkg/proxy/server.go:619 pkg/proxy/server.go:625 pkg/proxy/server.go:640 -#: pkg/proxy/server.go:690 -msgid "Reuse SSH connections (%s@%s) [Number of connections: %d]" -msgstr "复用SSH连接(%s@%s)[连接数量: %d]" - -#. lang.T -#: pkg/proxy/server.go:959 -msgid "Switched to %s" -msgstr "已切换至%s" - -#. lang.T -#: pkg/proxy/server.go:1143 -msgid "Connect with api server failed" -msgstr "连接API服务失败" - -#. lang.T -#: pkg/proxy/server.go:1172 -#, fuzzy -msgid "Start domain gateway failed %s" -msgstr "启动数据库网关失败%s" - -#. lang.T -#. lang.T -#: pkg/proxy/server.go:1180 pkg/proxy/switch.go:238 -msgid "Connect idle more than %d minutes, disconnect" -msgstr "空闲时间超过%d分钟,断开连接" - -#. lang.T -#: pkg/proxy/switch.go:246 -msgid "Permission has expired, disconnect" -msgstr "授权已过期,断开连接" - -#. lang.T -#: pkg/proxy/switch.go:257 -#, fuzzy -msgid "Terminated by admin %s" -msgstr "%s 管理员终断连接" - -#. lang.T -#: pkg/proxy/tools.go:28 -#, fuzzy -msgid "Authentication failed" -msgstr "认证失败(用户名或密码错误)" - -#. lang.T -#: pkg/proxy/tools.go:31 -msgid "Connection refused" -msgstr "网络不通(连接拒绝)" - -#. lang.T -#: pkg/proxy/tools.go:34 -msgid "i/o timeout" -msgstr "网络不通(连接超时)" - -#. lang.T -#: pkg/proxy/tools.go:37 -msgid "No route to host" -msgstr "网络不通(路由不通)" - -#. lang.T -#: pkg/proxy/tools.go:40 -msgid "network is unreachable" -msgstr "网络不通(网络不可达)" - -#~ msgid "Database %s protocol client not installed." -#~ msgstr "%s 协议的数据库客户端未安装" - -#, fuzzy -#~ msgid "System user <%s> and database <%s> protocol are inconsistent." -#~ msgstr "系统用户<%s>和资产<%s>协议不一致" - -#~ msgid "Create database session failed" -#~ msgstr "创建数据库会话失败" - -#~ msgid "Create DB domain gateway failed %s" -#~ msgstr "创建数据库网关失败%s" - -#, fuzzy -#~ msgid "System user <%s> and kubernetes <%s> protocol are inconsistent." -#~ msgstr "系统用户<%s>和kubernetes<%s>协议不一致" - -#, fuzzy -#~ msgid "Create k8s session failed" -#~ msgstr "创建Kubernetes会话失败" - -#, fuzzy -#~ msgid "Create k8s domain gateway failed %s" -#~ msgstr "创建Kubernetes网关失败%s" - -#~ msgid "Start k8s domain gateway failed %s" -#~ msgstr "启动kubernetes网关失败%s" - -#~ msgid "Connect asset %s error: %s" -#~ msgstr "连接资产 %s 发生错误:%s" - -#, fuzzy -#~ msgid "Database connect idle more than %d minutes, disconnect" -#~ msgstr "数据库连接空闲时间超过 %d 分钟,断开连接" - -#, fuzzy -#~ msgid "Database connection terminated by administrator" -#~ msgstr "管理员中断数据库连接" diff --git a/locale/zh_Hant/LC_MESSAGES/koko.mo b/locale/zh_Hant/LC_MESSAGES/koko.mo new file mode 100644 index 000000000..4b1a5f312 Binary files /dev/null and b/locale/zh_Hant/LC_MESSAGES/koko.mo differ diff --git a/locale/zh_Hant/LC_MESSAGES/koko.po b/locale/zh_Hant/LC_MESSAGES/koko.po new file mode 100644 index 000000000..da9889e81 --- /dev/null +++ b/locale/zh_Hant/LC_MESSAGES/koko.po @@ -0,0 +1,587 @@ +msgid "" +msgstr "" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: xgotext\n" + +#. lang.T +#: pkg/handler/app_database.go:11 +msgid "No Databases" +msgstr "無資料庫" + +#. lang.T +#: pkg/handler/app_k8s.go:15 +msgid "No kubernetes" +msgstr "沒有kubernetes" + +#. lang.T +#: pkg/handler/app_k8s.go:31 +msgid "Page: %d, Count: %d, Total Page: %d, Total Count: %d" +msgstr "頁碼:%d,每頁行數:%d,總頁數:%d,總數量:%d" + +#. lang.T +#: pkg/handler/app_k8s.go:45 +msgid "" +"Enter ID number directly login, multiple search use // + field, such as: //16" +msgstr "提示:輸入資產ID直接登入,二級搜索使用 // + 欄位,如://192" + +#. lang.T +#: pkg/handler/app_k8s.go:46 +msgid "Page up: b\tPage down: n" +msgstr "上一頁:b 下一頁:n" + +#. lang.T +#: pkg/handler/asset.go:45 +msgid "No Assets" +msgstr "沒有資產" + +#. lang.T +#: pkg/handler/asset.go:57 +msgid "ID" +msgstr "ID" + +#. lang.T +#: pkg/handler/asset.go:58 +msgid "Name" +msgstr "名稱" + +#. lang.T +#: pkg/handler/asset.go:59 +msgid "Address" +msgstr "地址" + +#. lang.T +#: pkg/handler/asset.go:60 +msgid "Platform" +msgstr "平台" + +#. lang.T +#: pkg/handler/asset.go:61 +msgid "Organization" +msgstr "組織" + +#. lang.T +#: pkg/handler/asset.go:62 +msgid "Comment" +msgstr "備註" + +#. lang.T +#: pkg/handler/asset.go:176 +msgid "%s protocol client not installed." +msgstr "%s 協議的用戶端未安裝" + +#. lang.T +#: pkg/handler/asset.go:179 +msgid "" +"Terminal does not support protocol %s, please use web terminal to access" +msgstr "該終端不支持 %s 協議,請使用web終端登入" + +#. lang.T +#: pkg/handler/asset.go:214 +msgid "Core API failed" +msgstr "Core API 發生錯誤" + +#. lang.T +#: pkg/handler/asset.go:220 +msgid "ACL reject" +msgstr "本次登入已拒絕,原因是訪問控制策略的限制" + +#. lang.T +#: pkg/handler/asset.go:226 +msgid "" +"Face ACL is not supported yet. Please use the WebTerminal to connect the " +"asset." +msgstr "該終端不支持人臉識別認證,請使用網頁終端登錄" + +#. lang.T +#. lang.T +#: pkg/handler/asset.go:241 pkg/handler/asset.go:250 +msgid "Unknown error code: %s, detail: %s" +msgstr "未知錯誤碼:%s,詳情:%s" + +#. lang.T +#: pkg/handler/asset.go:261 +msgid "get connect token err" +msgstr "獲取 connect token 錯誤" + +#. lang.T +#: pkg/handler/asset_node.go:22 +msgid "%s node has no assets" +msgstr "%s節點沒有資產" + +#. lang.T +#: pkg/handler/banner.go:30 +msgid "Welcome to use JumpServer open source fortress system" +msgstr "歡迎使用JumpServer開源堡壘機系統" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "part IP, Hostname, Comment" +msgstr "部分IP,主機名,備註" + +#. lang.T +#: pkg/handler/banner.go:32 +msgid "to search login if unique" +msgstr "搜索登入(如果唯一)" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "/ + IP, Hostname, Comment" +msgstr "/ + IP,主機名,備註" + +#. lang.T +#: pkg/handler/banner.go:33 +msgid "to search, such as: /192.168" +msgstr "搜索,如:/192.168" + +#. lang.T +#: pkg/handler/banner.go:34 +msgid "display the assets you have permission" +msgstr "顯示您有權限的資產" + +#. lang.T +#: pkg/handler/banner.go:35 +msgid "display the node that you have permission" +msgstr "顯示您有權限的節點" + +#. lang.T +#: pkg/handler/banner.go:36 +msgid "display the hosts that you have permission" +msgstr "顯示您有權限的主機" + +#. lang.T +#: pkg/handler/banner.go:37 +msgid "display the databases that you have permission" +msgstr "顯示您有權限的資料庫" + +#. lang.T +#: pkg/handler/banner.go:38 +msgid "display the kubernetes that you have permission" +msgstr "顯示您有權限的Kubernetes" + +#. lang.T +#: pkg/handler/banner.go:39 +msgid "refresh your assets and nodes" +msgstr "刷新最新的機器和節點資訊" + +#. lang.T +#: pkg/handler/banner.go:40 +msgid "language switch" +msgstr "" + +#. lang.T +#: pkg/handler/banner.go:41 +msgid "print help" +msgstr "顯示幫助" + +#. lang.T +#: pkg/handler/banner.go:42 +msgid "exit" +msgstr "退出" + +#. lang.T +#: pkg/handler/banner.go:60 +msgid "\t%2d) Enter {{.GreenBoldColor}}%s{{.ColorEnd}} to %s.%s" +msgstr "\t%d) 輸入 {{.GreenBoldColor}}%s{{.ColorEnd}} 進行%s.%s" + +#. lang.T +#: pkg/handler/banner.go:85 +msgid "Announcement: " +msgstr "公告:" + +#. lang.T +#: pkg/handler/direct_handler.go:272 +msgid "No Account found." +msgstr "未發現帳號" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:282 pkg/handler/direct_handler.go:283 +#: pkg/handler/direct_handler.go:284 +msgid "Username" +msgstr "使用者名稱" + +#. lang.T +#: pkg/handler/direct_handler.go:313 +msgid "Tips: Enter asset[%s] account ID" +msgstr "提示:輸入資產[%s]的帳號ID" + +#. lang.T +#: pkg/handler/direct_handler.go:314 +msgid "Back: B/b" +msgstr "返回:B/b" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:349 pkg/handler/direct_handler.go:350 +msgid "Hostname" +msgstr "主機名" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:351 pkg/handler/direct_handler.go:352 +#: pkg/handler/direct_handler.go:381 +msgid "select one asset to login" +msgstr "選擇其中一個資產登入" + +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:395 pkg/handler/direct_handler.go:400 +msgid "not found matched username %s" +msgstr "未發現匹配的使用者名稱 %s" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:433 pkg/handler/direct_handler.go:439 +#: pkg/handler/direct_handler.go:445 +msgid "" +"Face verification is not supported yet. Please use the WebTerminal to " +"connect the asset." +msgstr "該終端不支持人臉識別認證,請使用網頁終端登錄" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/direct_handler.go:460 pkg/handler/direct_handler.go:476 +#: pkg/handler/dispatch.go:127 pkg/handler/dispatch.go:128 +#: pkg/handler/dispatch.go:153 +#, fuzzy +msgid "Tips: switch language by ID (Current session only)" +msgstr "提示:輸入ID切換語言" + +#. lang.T +#: pkg/handler/dispatch.go:154 +msgid "" +"Tips: To set a default language, go to Personal Settings → Preferences on Web" +msgstr "提示:如需設定預設語言,請前往 Web 端「個人設定 → 偏好設定」" + +#. lang.T +#. lang.T +#: pkg/handler/dispatch.go:155 pkg/handler/dispatch.go:183 +msgid "Invalid ID" +msgstr "無效ID" + +#. lang.T +#: pkg/handler/dispatch.go:189 +msgid "Switch language successfully" +msgstr "切換語言成功" + +#. lang.T +#: pkg/handler/dispatch.go:199 +msgid "Node: [ ID.Name(Asset amount) ]" +msgstr "節點:[ ID.名稱(資產數量) ]" + +#. lang.T +#: pkg/handler/dispatch.go:201 +msgid "Tips: Enter g+NodeID to display the host under the node, such as g1" +msgstr "提示:輸入 g+節點ID 顯示節點下主機,如: g1" + +#. lang.T +#: pkg/handler/interactive.go:64 +msgid "Connect idle more than %d minutes, disconnect" +msgstr "空閒時間超過%d分鐘,斷開連接" + +#. lang.T +#: pkg/handler/interactive.go:198 +msgid "No account found." +msgstr "未發現帳號" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:208 pkg/handler/interactive.go:209 +#: pkg/handler/interactive.go:210 pkg/handler/interactive.go:240 +#: pkg/handler/interactive.go:241 pkg/handler/interactive.go:267 +msgid "Select account exceed max retry times." +msgstr "選擇帳號超過最大重試次數" + +#. lang.T +#: pkg/handler/interactive.go:278 +msgid "No protocol found." +msgstr "無協議" + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:287 pkg/handler/interactive.go:288 +msgid "Protocol" +msgstr "協議" + +#. lang.T +#: pkg/handler/interactive.go:315 +msgid "Tips: Enter protocol ID" +msgstr "提示:輸入協議ID" + +#. lang.T +#. lang.T +#: pkg/handler/interactive.go:316 pkg/handler/interactive.go:342 +msgid "Select protocol exceed max retry times." +msgstr "選擇協議超過最大重試次數" + +#. lang.T +#: pkg/handler/interactive.go:379 +msgid "Refresh done" +msgstr "刷新完成" + +#. lang.T +#: pkg/handler/login_confirm.go:38 +msgid "Need ACL review, continue? (y/n): " +msgstr "需要審核,繼續?(y/n): " + +#. lang.T +#: pkg/handler/login_confirm.go:53 +msgid "Cancel to login asset or max 3 retry" +msgstr "取消登入資產或達到3次重試" + +#. lang.T +#. lang.T +#: pkg/handler/login_confirm.go:67 pkg/handler/login_confirm.go:98 +msgid "Need ticket confirm to login, already send email to the reviewers" +msgstr "需要工單登入覆核,已發郵件通知審核人" + +#. lang.T +#: pkg/handler/login_confirm.go:99 +msgid "Ticket Reviewers: %s" +msgstr "工單審核人:%s " + +#. lang.T +#: pkg/handler/login_confirm.go:100 +msgid "Could copy website URL to notify reviewers: %s" +msgstr "可複製審核地址,通知審核人:%s" + +#. lang.T +#: pkg/handler/login_confirm.go:101 +msgid "Please waiting for the reviewers to confirm, enter q to exit. " +msgstr "等待審核人覆核確認,按 q 回車取消登入。" + +#. lang.T +#: pkg/handler/login_confirm.go:130 +msgid "Unknown status" +msgstr "未知狀態" + +#. lang.T +#: pkg/handler/login_confirm.go:134 +msgid "%s approved" +msgstr "%s 審核通過" + +#. lang.T +#: pkg/handler/login_confirm.go:139 +msgid "%s rejected" +msgstr "%s 審核拒絕" + +#. lang.T +#: pkg/handler/login_confirm.go:143 +msgid "Cancel confirm" +msgstr "取消登入覆核" + +#. lang.T +#: pkg/handler/select_handler.go:208 +msgid "Search: %s" +msgstr "搜索:%s" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:223 +msgid "Must be unique asset for %s" +msgstr "必須是唯一的資產 %s" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:230 pkg/handler/server_ssh.go:252 +msgid "Must be unique account for %s" +msgstr "必須是唯一的帳號 %s" + +#. i18nLang.T +#: pkg/handler/server_ssh.go:260 +msgid "Must be auto login account for %s" +msgstr "必須是自動登入帳號 %s" + +#. i18nLang.T +#. i18nLang.T +#: pkg/handler/server_ssh.go:682 pkg/handler/server_ssh.go:686 +msgid "No found asset" +msgstr "未發現匹配的資產 %s" + +#. i18nLang.T +#. i18nLang.T +#. i18nLang.T +#. lang.T +#: pkg/handler/server_ssh.go:716 pkg/handler/server_ssh.go:721 +#: pkg/handler/server_ssh.go:739 pkg/httpd/userwebsocket.go:110 +msgid "Create k8s client err: %s" +msgstr "建立 k8s 客戶端錯誤:%s" + +#. lang.T +#: pkg/proxy/parser.go:265 +msgid "have no permission to upload file" +msgstr "無權限上傳文件" + +#. lang.T +#: pkg/proxy/parser.go:301 +msgid "" +"The command you executed is risky and an alert notification will be sent to " +"the administrator. Do you want to continue?[Y/N]" +msgstr "您執行的指令存在風險,警告通知將發送給管理員。是否繼續?[Y/N]" + +#. lang.T +#: pkg/proxy/parser.go:318 +msgid "The command '%s' requires review. Continue or not [Y/n]?" +msgstr "命令 `%s` 需要複核,是否繼續?[Y/N]" + +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:338 pkg/proxy/parser.go:345 pkg/proxy/parser.go:436 +msgid "Command `%s` is forbidden" +msgstr "命令 `%s` 是被禁止的 ..." + +#. lang.T +#: pkg/proxy/parser.go:503 +msgid "have no permission to download file" +msgstr "無權限下載文件" + +#. lang.T +#: pkg/proxy/parser.go:572 +msgid "" +"Please waiting for the reviewers to confirm command `%s`, cancel by CTRL+C " +"or CTRL+D." +msgstr "請等待審核人覆核命令 `%s`,取消按 CTRL+C 或 CTRL+D。" + +#. lang.T +#: pkg/proxy/parser.go:582 +msgid "" +"Need ticket confirm to execute command, already send email to the reviewers" +msgstr "需要工單命令執行覆核,已發郵件通知審核人" + +#. lang.T +#. lang.T +#. lang.T +#. lang.T +#: pkg/proxy/parser.go:583 pkg/proxy/parser.go:584 pkg/proxy/server.go:58 +#: pkg/proxy/server.go:62 +msgid "" +"HandleTask does not support protocol %s, please use web terminal to access" +msgstr "該終端不支持 %s 協議,請使用web終端登入" + +#. lang.T +#: pkg/proxy/server.go:70 +msgid "Account <%s> and asset <%s> protocol are inconsistent." +msgstr "系統用戶<%s>和資產<%s>協議不一致" + +#. lang.T +#: pkg/proxy/server.go:102 +msgid "You don't have permission login %s" +msgstr "你無權限登入 %s" + +#. lang.T +#: pkg/proxy/server.go:337 +msgid "You get auth token failed" +msgstr "你獲取認證令牌失敗" + +#. lang.T +#: pkg/proxy/server.go:348 +msgid "Get auth password failed" +msgstr "你獲取認證令牌失敗" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:364 pkg/proxy/server.go:421 +msgid "Reuse SSH connections (%s@%s) [Number of connections: %d]" +msgstr "復用SSH連接(%s@%s)[連接數量: %d]" + +#. lang.T +#: pkg/proxy/server.go:689 +msgid "Switched to %s" +msgstr "已切換至%s" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:791 pkg/proxy/server.go:936 +msgid "Connect with api server failed" +msgstr "連接API服務失敗" + +#. lang.T +#: pkg/proxy/server.go:997 +msgid "Start domain gateway failed %s" +msgstr "啟動資料庫網關失敗%s" + +#. lang.T +#. lang.T +#: pkg/proxy/server.go:1005 pkg/proxy/server_options.go:108 +msgid "Manual" +msgstr "手動帳號" + +#. lang.T +#: pkg/proxy/server_options.go:110 +msgid "Dynamic" +msgstr "動態帳號" + +#. lang.T +#: pkg/proxy/server_options.go:113 +msgid "Connecting to %s@%s" +msgstr "開始連接到 %s@%s" + +#. lang.T +#: pkg/proxy/server_options.go:117 +msgid "Connecting to Database %s" +msgstr "開始連接資料庫 %s" + +#. lang.T +#: pkg/proxy/server_options.go:119 +msgid "Connecting to Kubernetes %s" +msgstr "開始連接Kubernetes %s" + +#. lang.T +#: pkg/proxy/server_options.go:121 +msgid "Connecting to Kubernetes %s container %s" +msgstr "開始連接Kubernetes %s 容器 %s" + +#. lang.T +#: pkg/proxy/switch.go:315 +msgid "Session max time reached, disconnect" +msgstr "會話超過最大連接時間,斷開連接" + +#. lang.T +#. lang.T +#: pkg/proxy/switch.go:324 pkg/proxy/switch.go:331 +msgid "Permission has expired, disconnect" +msgstr "授權已過期,斷開連接" + +#. lang.T +#: pkg/proxy/switch.go:341 +msgid "Terminated by admin %s" +msgstr "%s 管理員中斷連接" + +#. lang.T +#: pkg/proxy/tools.go:28 +msgid "Authentication failed" +msgstr "認證失敗(使用者名稱或密碼錯誤)" + +#. lang.T +#: pkg/proxy/tools.go:31 +msgid "Connection refused" +msgstr "網路不通(連接拒絕)" + +#. lang.T +#: pkg/proxy/tools.go:34 +msgid "i/o timeout" +msgstr "網路不通(連接超時)" + +#. lang.T +#: pkg/proxy/tools.go:37 +msgid "No route to host" +msgstr "網路不通(路由不通)" + +#. lang.T +#: pkg/proxy/tools.go:40 +msgid "network is unreachable" +msgstr "網路不通(網路不可達)" diff --git a/pkg/auth/http.go b/pkg/auth/http.go index 6c25562cf..b5e06a609 100644 --- a/pkg/auth/http.go +++ b/pkg/auth/http.go @@ -6,8 +6,8 @@ import ( "net/url" "github.com/gin-gonic/gin" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" "github.com/jumpserver/koko/pkg/logger" ) @@ -37,7 +37,7 @@ func HTTPMiddleSessionAuth(jmsService *service.JMService) gin.HandlerFunc { func HTTPMiddleDebugAuth() gin.HandlerFunc { return func(ctx *gin.Context) { switch ctx.ClientIP() { - case "127.0.0.1", "localhost": + case "127.0.0.1", "localhost", "::1": return default: _ = ctx.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid host %s", ctx.ClientIP())) diff --git a/pkg/auth/login_confirm.go b/pkg/auth/login_confirm.go index df29e0b78..7c830ea14 100644 --- a/pkg/auth/login_confirm.go +++ b/pkg/auth/login_confirm.go @@ -4,31 +4,35 @@ import ( "context" "time" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" "github.com/jumpserver/koko/pkg/logger" ) -type connectionConfirmOption struct { - user *model.User - systemUser *model.SystemUserAuthInfo - - targetType string - targetID string +type reviewOption struct { + user *model.User + Info *model.ConnectTokenInfo } -func NewLoginConfirm(jmsService *service.JMService, opts ...ConfirmOption) LoginConfirmService { - var option connectionConfirmOption +func NewLoginReview(jmsService *service.JMService, opts ...ReviewOption) LoginReviewService { + var option reviewOption for _, setter := range opts { setter(&option) } - return LoginConfirmService{option: &option, jmsService: jmsService} + ticketInfo := option.Info.TicketInfo + checkReqInfo := ticketInfo.CheckReq + cancelReqInfo := ticketInfo.CloseReq + ticketDetail := ticketInfo.TicketDetailUrl + reviewers := ticketInfo.Reviewers + return LoginReviewService{jmsService: jmsService, option: &option, + checkReqInfo: checkReqInfo, cancelReqInfo: cancelReqInfo, + ticketDetailUrl: ticketDetail, reviewers: reviewers} } -type LoginConfirmService struct { +type LoginReviewService struct { jmsService *service.JMService - option *connectionConfirmOption + option *reviewOption checkReqInfo model.ReqInfo cancelReqInfo model.ReqInfo @@ -36,55 +40,27 @@ type LoginConfirmService struct { ticketDetailUrl string processor string // 此审批的处理人 - ticketId string // 此工单 Id } -func (c *LoginConfirmService) CheckIsNeedLoginConfirm() (bool, error) { - userID := c.option.user.ID - systemUserID := c.option.systemUser.ID - systemUsername := c.option.systemUser.Username - targetID := c.option.targetID - switch c.option.targetType { - case model.AppType: - return c.jmsService.CheckIfNeedAppConnectionConfirm(userID, targetID, systemUserID) - default: - res, err := c.jmsService.CheckIfNeedAssetLoginConfirm(userID, targetID, - systemUserID, systemUsername) - if err != nil { - return false, err - } - c.ticketId = res.TicketId - c.reviewers = res.Reviewers - c.checkReqInfo = res.CheckReq - c.cancelReqInfo = res.CloseReq - c.ticketDetailUrl = res.TicketDetailUrl - return res.NeedConfirm, nil - } -} - -func (c *LoginConfirmService) WaitLoginConfirm(ctx context.Context) Status { +func (c *LoginReviewService) WaitLoginConfirm(ctx context.Context) Status { return c.waitConfirmFinish(ctx) } -func (c *LoginConfirmService) GetReviewers() []string { +func (c *LoginReviewService) GetReviewers() []string { reviewers := make([]string, len(c.reviewers)) copy(reviewers, c.reviewers) return reviewers } -func (c *LoginConfirmService) GetTicketUrl() string { +func (c *LoginReviewService) GetTicketUrl() string { return c.ticketDetailUrl } -func (c *LoginConfirmService) GetProcessor() string { +func (c *LoginReviewService) GetProcessor() string { return c.processor } -func (c *LoginConfirmService) GetTicketId() string { - return c.ticketId -} - -func (c *LoginConfirmService) waitConfirmFinish(ctx context.Context) Status { +func (c *LoginReviewService) waitConfirmFinish(ctx context.Context) Status { // 10s 请求一次 t := time.NewTicker(10 * time.Second) defer t.Stop() @@ -110,13 +86,13 @@ func (c *LoginConfirmService) waitConfirmFinish(ctx context.Context) Status { return StatusReject default: logger.Errorf("Receive unknown login confirm status %s", - statusRes.Status) + statusRes.State) } } } } -func (c *LoginConfirmService) cancelConfirm() { +func (c *LoginReviewService) cancelConfirm() { if err := c.jmsService.CancelConfirmByRequestInfo(c.cancelReqInfo); err != nil { logger.Errorf("Cancel confirm request err: %s", err.Error()) } @@ -130,28 +106,16 @@ const ( StatusCancel ) -type ConfirmOption func(*connectionConfirmOption) +type ReviewOption func(*reviewOption) -func ConfirmWithUser(user *model.User) ConfirmOption { - return func(option *connectionConfirmOption) { +func WithReviewUser(user *model.User) ReviewOption { + return func(option *reviewOption) { option.user = user } } -func ConfirmWithSystemUser(sysUser *model.SystemUserAuthInfo) ConfirmOption { - return func(option *connectionConfirmOption) { - option.systemUser = sysUser - } -} - -func ConfirmWithTargetType(targetType string) ConfirmOption { - return func(option *connectionConfirmOption) { - option.targetType = targetType - } -} - -func ConfirmWithTargetID(targetID string) ConfirmOption { - return func(option *connectionConfirmOption) { - option.targetID = targetID +func WithReviewTokenInfo(info *model.ConnectTokenInfo) ReviewOption { + return func(option *reviewOption) { + option.Info = info } } diff --git a/pkg/auth/mfa_option.go b/pkg/auth/mfa_option.go index c29361422..9730f8050 100644 --- a/pkg/auth/mfa_option.go +++ b/pkg/auth/mfa_option.go @@ -16,7 +16,7 @@ const ( actionPartialAccepted = "Partial accepted" ) -func CreateSelectOptionsQuestion(options []string) string{ +func CreateSelectOptionsQuestion(options []string) string { opts := make([]mfaOption, 0, len(options)) for i := range options { opts = append(opts, mfaOption{ @@ -39,7 +39,8 @@ type mfaOption struct { Value string } -var mfaOptions = `{{ range . }}{{ .Index }}. {{.Value}} +var mfaOptions = ` +{{ range . }}{{ .Index }}. {{.Value}} {{end}}Option> ` var ( diff --git a/pkg/auth/ssh.go b/pkg/auth/ssh.go index 5176c8ca2..a1b60a6ce 100644 --- a/pkg/auth/ssh.go +++ b/pkg/auth/ssh.go @@ -1,87 +1,159 @@ package auth import ( + "errors" "net" "strings" "github.com/gliderlabs/ssh" + "github.com/jumpserver/koko/pkg/cache" + "github.com/jumpserver/koko/pkg/config" gossh "golang.org/x/crypto/ssh" - "github.com/jumpserver/koko/pkg/common" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" "github.com/jumpserver/koko/pkg/logger" - "github.com/jumpserver/koko/pkg/sshd" ) -type SSHAuthFunc func(ctx ssh.Context, password, publicKey string) (res sshd.AuthStatus) +var authErr = errors.New("auth failed") + +type SSHAuthFunc func(ctx ssh.Context, password, publicKey string) error func SSHPasswordAndPublicKeyAuth(jmsService *service.JMService) SSHAuthFunc { - return func(ctx ssh.Context, password, publicKey string) (res sshd.AuthStatus) { - username := GetUsernameFromSSHCtx(ctx) + needPasswordAuthErr := &ssh.PartialSuccessError{Next: ssh.ServerAuthCallbacks{ + PasswordCallback: func(ctx1 ssh.Context, pwd string) error { + return SSHPasswordAndPublicKeyAuth(jmsService)(ctx1, pwd, "") + }, + }} + needPublicKeyAuthErr := &ssh.PartialSuccessError{Next: ssh.ServerAuthCallbacks{ + PublicKeyCallback: func(ctx1 ssh.Context, pub ssh.PublicKey) error { + key := string(gossh.MarshalAuthorizedKey(pub)) + return SSHPasswordAndPublicKeyAuth(jmsService)(ctx1, "", key) + }, + }} + return func(ctx ssh.Context, password, publicKey string) error { + if password == "" && publicKey == "" { + logger.Errorf("SSH conn[%s] no password and publickey", ctx.SessionID()) + return authErr + } + remoteAddr, _, _ := net.SplitHostPort(ctx.RemoteAddr().String()) + username := ctx.User() + if req, ok := parseDirectLoginReq(jmsService, ctx); ok { + if req.IsToken() { + if req.Authenticate(password) { + ctx.SetValue(ContextKeyUser, &req.ConnectToken.User) + logger.Infof("SSH conn[%s] %s for %s from %s", ctx.SessionID(), + actionAccepted, username, remoteAddr) + return nil + } else { + logger.Errorf("SSH conn[%s] token %s auth failed", ctx.SessionID(), req.ConnectToken.Id) + return authErr + } + } + username = req.User() + } authMethod := "publickey" action := actionAccepted - res = sshd.AuthFailed + var res error if password != "" { authMethod = "password" } - remoteAddr, _, _ := net.SplitHostPort(ctx.RemoteAddr().String()) - userAuthClient, ok := ctx.Value(ContextKeyClient).(*UserAuthClient) - if !ok { - newClient := jmsService.CloneClient() - - userClient := service.NewUserClient( - service.UserClientUsername(username), - service.UserClientRemoteAddr(remoteAddr), - service.UserClientLoginType("T"), - service.UserClientHttpClient(&newClient), - ) - userAuthClient = &UserAuthClient{ - UserClient: userClient, - authOptions: make(map[string]authOptions), + checkedUser := ctx.Value(ContextKeyCheckUsername) + if authMethod == "publickey" && checkedUser == nil { + // Only the first public key authentication is used to check whether the username has a public key. + ctx.SetValue(ContextKeyCheckUsername, true) + if pendingUser, err := jmsService.GetUserByUsername(username); err == nil { + if !pendingUser.HasPublicKeys { + logger.Infof("SSH conn[%s] user %s has no public keys", ctx.SessionID(), username) + return authErr + } + } else { + logger.Debugf("SSH conn[%s] user %s check public keys failed: %s", + ctx.SessionID(), username, err) } - ctx.SetValue(ContextKeyClient, userAuthClient) } - userAuthClient.SetOption(service.UserClientPassword(password), - service.UserClientPublicKey(publicKey)) + conf := config.GetConf() + authCount := 0 + currentNeedAuthMethod := "publickey" + if countVal := ctx.Value(ContextKeyAuthCount); countVal != nil { + authCount = countVal.(int) + } + if authVal := ctx.Value(ContextKeyCurrentAuth); authVal != nil { + currentNeedAuthMethod = authVal.(string) + } else { + ctx.SetValue(ContextKeyCurrentAuth, currentNeedAuthMethod) + } + if conf.ForceMultiAuth && authMethod != currentNeedAuthMethod { + logger.Errorf("SSH conn[%s] force multiauth current auth method %s mismatch %s", + ctx.SessionID(), currentNeedAuthMethod, authMethod) + return needPublicKeyAuthErr + } + newClient := jmsService.CloneClient() + var accessKey model.AccessKey + _ = accessKey.LoadFromFile(conf.AccessKeyFilePath) + userClient := service.NewUserClient( + service.UserClientUsername(username), + service.UserClientRemoteAddr(remoteAddr), + service.UserClientLoginType("T"), + service.UserClientHttpClient(&newClient), + service.UserClientSvcSignKey(accessKey), + service.UserClientPassword(password), + service.UserClientPublicKey(publicKey), + ) + userAuthClient := &UserAuthClient{ + UserClient: userClient, + authOptions: make(map[string]authOptions), + } + ctx.SetValue(ContextKeyClient, userAuthClient) logger.Infof("SSH conn[%s] authenticating user %s %s", ctx.SessionID(), username, authMethod) user, authStatus := userAuthClient.Authenticate(ctx) switch authStatus { - case authMFARequired: - action = actionPartialAccepted - res = sshd.AuthPartiallySuccessful - ctx.SetValue(ContextKeyAuthStatus, authMFARequired) case authSuccess: - res = sshd.AuthSuccessful ctx.SetValue(ContextKeyUser, &user) - case authConfirmRequired: + if conf.ForceMultiAuth && authMethod == "publickey" { + res = needPasswordAuthErr + ctx.SetValue(ContextKeyCurrentAuth, "password") + action = actionPartialAccepted + } + case authConfirmRequired, authMFARequired: action = actionPartialAccepted - res = sshd.AuthPartiallySuccessful - ctx.SetValue(ContextKeyAuthStatus, authConfirmRequired) + ctx.SetValue(ContextKeyAuthStatus, authStatus) + res = &ssh.PartialSuccessError{Next: ssh.ServerAuthCallbacks{ + KeyboardInteractiveCallback: SSHKeyboardInteractiveAuth, + }} default: action = actionFailed + res = authErr + if conf.ForceMultiAuth && authMethod == "publickey" { + authCount++ + if authCount < 3 { + ctx.SetValue(ContextKeyAuthCount, authCount) + res = needPublicKeyAuthErr + } + } } logger.Infof("SSH conn[%s] %s %s for %s from %s", ctx.SessionID(), action, authMethod, username, remoteAddr) - return + return res } } -func SSHKeyboardInteractiveAuth(ctx ssh.Context, challenger gossh.KeyboardInteractiveChallenge) (res sshd.AuthStatus) { +func SSHKeyboardInteractiveAuth(ctx ssh.Context, challenger gossh.KeyboardInteractiveChallenge) error { if value, ok := ctx.Value(ContextKeyAuthFailed).(*bool); ok && *value { - return sshd.AuthFailed + return authErr } + username := GetUsernameFromSSHCtx(ctx) - res = sshd.AuthFailed client, ok := ctx.Value(ContextKeyClient).(*UserAuthClient) if !ok { logger.Errorf("SSH conn[%s] user %s Mfa Auth failed: not found session client.", ctx.SessionID(), username) - return + return authErr } status, ok2 := ctx.Value(ContextKeyAuthStatus).(StatusAuth) if !ok2 { logger.Errorf("SSH conn[%s] user %s unknown auth", ctx.SessionID(), username) - return + return authErr } var checkAuth func(ssh.Context, gossh.KeyboardInteractiveChallenge) bool switch status { @@ -89,11 +161,13 @@ func SSHKeyboardInteractiveAuth(ctx ssh.Context, challenger gossh.KeyboardIntera checkAuth = client.CheckConfirmAuth case authMFARequired: checkAuth = client.CheckMFAAuth + default: + return authErr } if checkAuth != nil && checkAuth(ctx, challenger) { - res = sshd.AuthSuccessful + return nil } - return + return authErr } const ( @@ -105,37 +179,74 @@ const ( ContextKeyAuthFailed = "CONTEXT_AUTH_FAILED" ContextKeyDirectLoginFormat = "CONTEXT_DIRECT_LOGIN_FORMAT" + + ContextKeyCheckUsername = "CONTEXT_CHECK_USERNAME" + + ContextKeyCurrentAuth = "CONTEXT_CURRENT_AUTH" + + ContextKeyAuthCount = "CONTEXT_AUTH_COUNT" ) type DirectLoginAssetReq struct { - Username string - SysUserInfo string - AssetInfo string + Username string + Protocol string + AccountUsername string + AssetTarget string + ConnectToken *model.ConnectToken } -func (d *DirectLoginAssetReq) IsUUIDString() bool { - for _, item := range []string{d.SysUserInfo, d.AssetInfo} { - if !common.ValidUUIDString(item) { - return false - } +func (d *DirectLoginAssetReq) Authenticate(password string) bool { + return d.ConnectToken.Value == password +} + +func (d *DirectLoginAssetReq) IsToken() bool { + return d.ConnectToken != nil +} + +func (d *DirectLoginAssetReq) User() string { + if d.IsToken() && d.ConnectToken.User.ID != "" { + return d.ConnectToken.User.Username } - return true + return d.Username } const ( SeparatorATSign = "@" SeparatorHashMark = "#" + + /* + 格式为: JMS-{token} + + */ + tokenPrefix = "JMS-" +) + +const ( + sshProtocolLen = 3 + withProtocolLen = 4 ) func parseUserFormatBySeparator(s, Separator string) (DirectLoginAssetReq, bool) { authInfos := strings.Split(s, Separator) - if len(authInfos) != 3 { + var req DirectLoginAssetReq + switch len(authInfos) { + case sshProtocolLen: + req = DirectLoginAssetReq{ + Username: authInfos[0], + Protocol: model.ProtocolSSH, + AccountUsername: authInfos[1], + AssetTarget: authInfos[2], + } + case withProtocolLen: + req = DirectLoginAssetReq{ + Username: authInfos[0], + Protocol: authInfos[1], + AccountUsername: authInfos[2], + AssetTarget: authInfos[3], + } + default: return DirectLoginAssetReq{}, false - } - req := DirectLoginAssetReq{ - Username: authInfos[0], - SysUserInfo: authInfos[1], - AssetInfo: authInfos[2], + } return req, true } @@ -160,3 +271,45 @@ func GetUsernameFromSSHCtx(ctx ssh.Context) string { } return username } + +func parseDirectLoginReq(jmsService *service.JMService, ctx ssh.Context) (*DirectLoginAssetReq, bool) { + if req, ok := ctx.Value(ContextKeyDirectLoginFormat).(*DirectLoginAssetReq); ok { + return req, true + } + if req, ok := parseJMSTokenLoginReq(jmsService, ctx); ok { + ctx.SetValue(ContextKeyDirectLoginFormat, req) + return req, true + } + if req, ok := parseUsernameFormatReq(ctx); ok { + ctx.SetValue(ContextKeyDirectLoginFormat, req) + return req, true + } + return nil, false +} + +func parseJMSTokenLoginReq(jmsService *service.JMService, ctx ssh.Context) (*DirectLoginAssetReq, bool) { + if strings.HasPrefix(ctx.User(), tokenPrefix) { + token := strings.TrimPrefix(ctx.User(), tokenPrefix) + key := cache.CreateAddrCacheKey(ctx.RemoteAddr(), token) + if connectToken := cache.TokenCacheInstance.Get(key); connectToken != nil { + req := DirectLoginAssetReq{ConnectToken: connectToken, + Protocol: connectToken.Protocol} + return &req, true + } + if connectToken, err := jmsService.GetConnectTokenInfo(token, true); err == nil { + req := DirectLoginAssetReq{ConnectToken: &connectToken, + Protocol: connectToken.Protocol} + return &req, true + } else { + logger.Errorf("Check user token %s failed: %s", ctx.User(), err) + } + } + return nil, false +} + +func parseUsernameFormatReq(ctx ssh.Context) (*DirectLoginAssetReq, bool) { + if req, ok := ParseDirectUserFormat(ctx.User()); ok { + return &req, true + } + return nil, false +} diff --git a/pkg/auth/user_auth.go b/pkg/auth/user_auth.go index b09e65fb0..886be3cc6 100644 --- a/pkg/auth/user_auth.go +++ b/pkg/auth/user_auth.go @@ -11,8 +11,8 @@ import ( "github.com/gliderlabs/ssh" gossh "golang.org/x/crypto/ssh" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" "github.com/jumpserver/koko/pkg/logger" ) @@ -29,10 +29,6 @@ type UserAuthClient struct { mfaTypes []string } -func (u *UserAuthClient) SetOption(setters ...service.UserClientOption) { - u.UserClient.SetOption(setters...) -} - func (u *UserAuthClient) Authenticate(ctx context.Context) (user model.User, authStatus StatusAuth) { authStatus = authFailed resp, err := u.UserClient.GetAPIToken() @@ -40,6 +36,10 @@ func (u *UserAuthClient) Authenticate(ctx context.Context) (user model.User, aut logger.Errorf("User %s Authenticate err: %s", u.Opts.Username, err) return } + unsupportedMfaTypes := map[string]bool{ + "face": true, + "FACE": true, + } if resp.Err != "" { switch resp.Err { case ErrLoginConfirmWait: @@ -48,6 +48,12 @@ func (u *UserAuthClient) Authenticate(ctx context.Context) (user model.User, aut case ErrMFARequired: u.mfaTypes = nil for _, choiceType := range resp.Data.Choices { + if _, ok := unsupportedMfaTypes[choiceType]; ok { + logger.Infof("User %s login need MFA, skip %s as it not supported", u.Opts.Username, + choiceType) + continue + } + u.authOptions[choiceType] = authOptions{ MFAType: choiceType, Url: resp.Data.Url, @@ -55,6 +61,9 @@ func (u *UserAuthClient) Authenticate(ctx context.Context) (user model.User, aut u.mfaTypes = append(u.mfaTypes, choiceType) } logger.Infof("User %s login need MFA", u.Opts.Username) + if len(u.mfaTypes) == 0 { + logger.Warnf("User %s login need MFA, but no MFA options", u.Opts.Username) + } authStatus = authMFARequired default: logger.Errorf("User %s login err: %s", u.Opts.Username, resp.Err) @@ -109,8 +118,20 @@ func (u *UserAuthClient) CheckMFAAuth(ctx ssh.Context, challenger gossh.Keyboard username := u.Opts.Username remoteAddr, _, _ := net.SplitHostPort(ctx.RemoteAddr().String()) opts := u.mfaTypes + count := 0 var selectedMFAType string switch len(opts) { + case 0: + logger.Errorf("User %s has no MFA options", username) + warningMsg := "No MFA options, please visit website to update your Multi-factor authentication." + _, err := challenger(username, warningMsg, []string{"exit now"}, []bool{true}) + if err != nil { + logger.Errorf("user %s happened err: %s", username, err) + return + } + ctx.SetValue(ContextKeyAuthStatus, authFailed) + return false + case 1: // 仅有一个 option, 直接跳过选择界面 selectedMFAType = opts[0] @@ -118,11 +139,16 @@ func (u *UserAuthClient) CheckMFAAuth(ctx ssh.Context, challenger gossh.Keyboard question := CreateSelectOptionsQuestion(opts) loop: for { + if count > 3 { + logger.Errorf("user %s select MFA type failed", username) + return + } answers, err := challenger(username, mfaSelectInstruction, []string{question}, []bool{true}) if err != nil { logger.Errorf("user %s happened err: %s", username, err) return } + count++ if len(answers) == 1 { num, err2 := strconv.Atoi(answers[0]) if err2 != nil { @@ -215,6 +241,10 @@ loop: ctx.SetValue(ContextKeyUser, &user) logger.Infof("SSH conn[%s] checking user %s login confirm success", ctx.SessionID(), username) ok = true + default: + failed := true + ctx.SetValue(ContextKeyAuthFailed, &failed) + logger.Infof("SSH conn[%s] checking user %s login confirm failed", ctx.SessionID(), username) } return } @@ -222,9 +252,7 @@ loop: const ( ErrLoginConfirmWait = "login_confirm_wait" ErrLoginConfirmRejected = "login_confirm_rejected" - ErrLoginConfirmRequired = "login_confirm_required" ErrMFARequired = "mfa_required" - ErrPasswordFailed = "password_failed" ) func (u *UserAuthClient) CheckConfirm(ctx context.Context) (user model.User, authStatus StatusAuth) { diff --git a/pkg/cache/token_cache.go b/pkg/cache/token_cache.go new file mode 100644 index 000000000..988e00ec8 --- /dev/null +++ b/pkg/cache/token_cache.go @@ -0,0 +1,135 @@ +package cache + +import ( + "fmt" + "net" + "sync" + "time" + + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver/koko/pkg/logger" +) + +/* +缓存 ConnectToken: + token_id 和 addr 绑定创建 key + 1.根据 key 获取缓存的 ConnectToken + 2.新请求的 ConnectToken 加入缓存,已存在则引用计数加一 + 3.定时回收缓存中的 ConnectToken, 保证内存不会无限增长 + 清理条件: + 1.引用计数为 0 且超时五分钟 + 2.超过 1 小时缓存 +*/ + +var TokenCacheInstance = NewConnectTokenCache() + +func NewConnectTokenCache() *ConnectTokenCache { + cache := &ConnectTokenCache{ + data: make(map[string]*ConnectTokenItem), + } + go cache.run() + return cache +} + +type ConnectTokenCache struct { + lock sync.Mutex + data map[string]*ConnectTokenItem +} + +func (c *ConnectTokenCache) Get(key string) *model.ConnectToken { + c.lock.Lock() + defer c.lock.Unlock() + item, ok := c.data[key] + if !ok { + return nil + } + return item.token +} + +func (c *ConnectTokenCache) Save(key string, token *model.ConnectToken) { + c.lock.Lock() + defer c.lock.Unlock() + if item, ok := c.data[key]; ok { + item.refInc() + logger.Infof("Cache key %s ref: %d", key, item.Ref) + return + } + item := NewConnectTokenItem(token) + c.data[key] = item + logger.Infof("New cache key %s ref: %d", key, item.Ref) +} + +func (c *ConnectTokenCache) Recycle(key string) { + c.lock.Lock() + defer c.lock.Unlock() + if item, ok := c.data[key]; ok { + item.refDec() + logger.Infof("Recycle cache key %s ref: %d", key, item.Ref) + } else { + logger.Warnf("Recycle cache key %s not found", key) + } +} + +func (c *ConnectTokenCache) run() { + for { + c.GC() + time.Sleep(30 * time.Second) + } +} + +func (c *ConnectTokenCache) GC() { + readyDelete := make([]string, 0, len(c.data)) + c.lock.Lock() + now := time.Now() + for k, v := range c.data { + if v.IsExpired(now) { + readyDelete = append(readyDelete, k) + } + } + for _, k := range readyDelete { + delete(c.data, k) + logger.Infof("ConnectToken %s is expired, recycled", k) + } + c.lock.Unlock() +} + +func NewConnectTokenItem(token *model.ConnectToken) *ConnectTokenItem { + now := time.Now() + return &ConnectTokenItem{ + token: token, + Ref: 1, + expiredTime: now.Add(5 * time.Minute).Unix(), + maxExpiredTime: now.Add(time.Hour).Unix(), + } +} + +type ConnectTokenItem struct { + token *model.ConnectToken + Ref uint + expiredTime int64 + maxExpiredTime int64 +} + +func (c *ConnectTokenItem) IsExpired(now time.Time) bool { + if c.maxExpiredTime < now.Unix() { + return true + } + return c.Ref == 0 && c.expiredTime < now.Unix() +} + +func (c *ConnectTokenItem) refDec() { + c.Ref-- + c.expiredTime = time.Now().Add(5 * time.Minute).Unix() +} + +func (c *ConnectTokenItem) refInc() { + c.Ref++ + c.expiredTime = time.Now().Add(5 * time.Minute).Unix() +} + +// 绑定一个 addr 的 key + +func CreateAddrCacheKey(addr net.Addr, token string) string { + ip, _, _ := net.SplitHostPort(addr.String()) + return fmt.Sprintf("%s-%s", ip, token) +} diff --git a/pkg/common/charset_transformer.go b/pkg/common/charset_transformer.go index fc0271bd6..2304ff7ce 100644 --- a/pkg/common/charset_transformer.go +++ b/pkg/common/charset_transformer.go @@ -1,26 +1,35 @@ package common import ( + "golang.org/x/text/encoding/charmap" "golang.org/x/text/encoding/simplifiedchinese" "golang.org/x/text/transform" ) const ( - UTF8 = "utf8" - GBK = "gbk" + UTF8 = "utf8" + GBK = "gbk" + GB2312 = "gb2312" + ISOLatin1 = "ios-8859-1" ) func LookupCharsetDecode(charset string) transform.Transformer { switch charset { - case GBK: + case GBK, GB2312: return simplifiedchinese.GBK.NewDecoder() + case ISOLatin1: + return charmap.ISO8859_1.NewDecoder() + } return nil } func LookupCharsetEncode(charset string) transform.Transformer { switch charset { - case GBK: + case GBK, GB2312: return simplifiedchinese.GBK.NewEncoder() + case ISOLatin1: + return charmap.ISO8859_1.NewEncoder() + } return nil } diff --git a/pkg/common/client.go b/pkg/common/client.go index 4784aabce..8deb1b2cd 100644 --- a/pkg/common/client.go +++ b/pkg/common/client.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "mime/multipart" "net/http" neturl "net/url" @@ -158,7 +157,7 @@ func (c *Client) NewRequest(method, url string, body interface{}, params []map[s // Do wrapper http.Client Do() for using auth and error handle // params: -// 1. query string if set {"name": "ibuler"} +// 1. query string if set {"name": "ibuler"} func (c *Client) Do(method, url string, data, res interface{}, params ...map[string]string) (resp *http.Response, err error) { req, err := c.NewRequest(method, url, data, params) if err != nil { @@ -169,7 +168,7 @@ func (c *Client) Do(method, url string, data, res interface{}, params ...map[str return } defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { msg := fmt.Sprintf("%s %s failed, get code: %d, %s", req.Method, req.URL, resp.StatusCode, body) err = errors.New(msg) @@ -281,7 +280,7 @@ func (c *Client) UploadFile(reqUrl string, gFile string, res interface{}, params return err } defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { msg := fmt.Sprintf("%s %s failed, get code: %d, %s", req.Method, req.URL, resp.StatusCode, string(body)) err = errors.New(msg) diff --git a/pkg/common/httputil.go b/pkg/common/httputil.go index d958c2c05..c8b7ca18d 100644 --- a/pkg/common/httputil.go +++ b/pkg/common/httputil.go @@ -47,11 +47,11 @@ func MakeSureDirExit(filePath string) { func ConvertSizeToBytes(size string) int { defaultSize := 1024 * 1024 * 1024 - suffixs := []string{"M", "m", "g", "G"} - for i := 0; i < len(suffixs); i++ { - if strings.HasSuffix(size, suffixs[i]) { - num := strings.TrimSuffix(size, suffixs[i]) - switch strings.ToLower(suffixs[i]) { + suffixes := []string{"M", "m", "g", "G"} + for i := 0; i < len(suffixes); i++ { + if strings.HasSuffix(size, suffixes[i]) { + num := strings.TrimSuffix(size, suffixes[i]) + switch strings.ToLower(suffixes[i]) { case "m": if sizeNum, err := strconv.Atoi(num); err == nil { return sizeNum * 1024 * 1024 diff --git a/pkg/common/pagination.go b/pkg/common/pagination.go deleted file mode 100644 index 90e2043c3..000000000 --- a/pkg/common/pagination.go +++ /dev/null @@ -1,113 +0,0 @@ -package common - -import "sync" - -func NewPagination(data []interface{}, size int) *Pagination { - p := &Pagination{ - data: data, - lock: new(sync.RWMutex), - } - p.SetPageSize(size) - return p -} - -type Pagination struct { - data []interface{} - - currentPage int - pageSize int - totalPage int - lock *sync.RWMutex -} - -func (p *Pagination) GetNextPageData() []interface{} { - if !p.HasNext() { - return []interface{}{} - } - p.lock.Lock() - p.currentPage++ - p.lock.Unlock() - return p.GetPageData(p.currentPage) -} - -func (p *Pagination) GetPrevPageData() []interface{} { - if !p.HasPrev() { - return []interface{}{} - } - p.lock.Lock() - p.currentPage-- - p.lock.Unlock() - return p.GetPageData(p.currentPage) -} - -func (p *Pagination) GetPageData(pageIndex int) []interface{} { - p.lock.RLock() - defer p.lock.RUnlock() - var ( - endIndex int - startIndex int - ) - - endIndex = p.pageSize * pageIndex - startIndex = endIndex - p.pageSize - if endIndex > len(p.data) { - endIndex = len(p.data) - } - return p.data[startIndex:endIndex] -} - -func (p *Pagination) CurrentPage() int { - p.lock.RLock() - defer p.lock.RUnlock() - return p.currentPage -} - -func (p *Pagination) TotalCount() int { - p.lock.RLock() - defer p.lock.RUnlock() - return len(p.data) -} - -func (p *Pagination) TotalPage() int { - p.lock.RLock() - defer p.lock.RUnlock() - return p.totalPage -} - -func (p *Pagination) SetPageSize(size int) { - - if size <= 0 { - panic("Pagination size should be larger than zero") - } - p.lock.Lock() - defer p.lock.Unlock() - if p.pageSize == size { - return - } - p.pageSize = size - if len(p.data)%size == 0 { - p.totalPage = len(p.data) / size - } else { - p.totalPage = len(p.data)/size + 1 - } - p.currentPage = 1 - -} - -func (p *Pagination) PageSize() int { - p.lock.RLock() - defer p.lock.RUnlock() - return p.pageSize -} - -func (p *Pagination) HasNext() bool { - p.lock.RLock() - defer p.lock.RUnlock() - return p.currentPage < p.totalPage -} - -func (p *Pagination) HasPrev() bool { - p.lock.RLock() - defer p.lock.RUnlock() - return p.currentPage > 1 -} diff --git a/pkg/common/random.go b/pkg/common/random.go new file mode 100644 index 000000000..da3a85bda --- /dev/null +++ b/pkg/common/random.go @@ -0,0 +1,19 @@ +package common + +import ( + "math/rand" + "time" +) + +const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +var localRand = rand.New(rand.NewSource(time.Now().UnixNano())) + +func RandomStr(length int) string { + localRand.Seed(time.Now().UnixNano()) + b := make([]byte, length) + for i := range b { + b[i] = letters[localRand.Intn(len(letters))] + } + return string(b) +} diff --git a/pkg/common/sshutil.go b/pkg/common/sshutil.go index 4bd2276c1..33ce1eb3a 100644 --- a/pkg/common/sshutil.go +++ b/pkg/common/sshutil.go @@ -5,7 +5,7 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" - "io/ioutil" + "os" "golang.org/x/crypto/ssh" ) @@ -42,7 +42,7 @@ func EncodePrivateKeyToPEM(privateKey *rsa.PrivateKey) []byte { } func WriteKeyToFile(keyBytes []byte, saveFileTo string) error { - err := ioutil.WriteFile(saveFileTo, keyBytes, 0600) + err := os.WriteFile(saveFileTo, keyBytes, 0600) if err != nil { return err } @@ -50,7 +50,7 @@ func WriteKeyToFile(keyBytes []byte, saveFileTo string) error { } func GetPubKeyFromFile(keypath string) (ssh.Signer, error) { - buf, err := ioutil.ReadFile(keypath) + buf, err := os.ReadFile(keypath) if err != nil { return nil, err } diff --git a/pkg/common/utils.go b/pkg/common/utils.go index 6c2c10286..5488bb6f7 100644 --- a/pkg/common/utils.go +++ b/pkg/common/utils.go @@ -2,8 +2,11 @@ package common import ( "compress/gzip" + "fmt" "io" + "net/netip" "os" + "sync" "time" "unsafe" ) @@ -75,3 +78,73 @@ func CurrentUTCTime() string { func BytesToString(b []byte) string { return *(*string)(unsafe.Pointer(&b)) } + +func CompareString(a, b string) bool { + return a < b +} + +func CompareIP(ipA, ipB string) bool { + addrA, err := netip.ParseAddr(ipA) + if err != nil { + return false + } + addrB, err := netip.ParseAddr(ipB) + if err != nil { + return false + } + return addrA.Less(addrB) +} + +func ChunkedFileTransfer(fd io.WriterAt, readerAt io.ReaderAt, offset, fileSize int64) error { + chunkSize := int64(64 * 1024) + maxConcurrent := 200 + + var wg sync.WaitGroup + chunkCount := int(fileSize / chunkSize) + if fileSize%chunkSize != 0 { + chunkCount++ + } + + errChan := make(chan error, chunkCount) + sem := make(chan struct{}, maxConcurrent) + for i := 0; i < chunkCount; i++ { + wg.Add(1) + sem <- struct{}{} + + go func(chunkIndex int) { + defer wg.Done() + defer func() { <-sem }() + + start := int64(chunkIndex) * chunkSize + end := start + chunkSize + if end > fileSize { + end = fileSize + } + + buf := make([]byte, end-start) + _, err := readerAt.ReadAt(buf, start) + if err != nil && err != io.EOF { + errChan <- fmt.Errorf("failed to read chunk %d: %v", chunkIndex, err) + return + } + + _, err = fd.WriteAt(buf, offset+start) + if err != nil { + errChan <- fmt.Errorf("failed to write chunk %d: %v", chunkIndex, err) + return + } + + }(i) + } + + wg.Wait() + close(errChan) + + for err := range errChan { + if err != nil { + return err + } + } + + return nil +} diff --git a/pkg/common/uuid.go b/pkg/common/uuid.go index 61fd48c37..adc79c20a 100644 --- a/pkg/common/uuid.go +++ b/pkg/common/uuid.go @@ -1,12 +1,12 @@ package common -import "github.com/satori/go.uuid" +import "github.com/google/uuid" func UUID() string { - return uuid.NewV4().String() + return uuid.NewString() } func ValidUUIDString(sid string) bool { - _, err := uuid.FromString(sid) + _, err := uuid.Parse(sid) return err == nil } diff --git a/pkg/config/config.go b/pkg/config/config.go index 42adc8747..0cab3d835 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,6 +8,8 @@ import ( "strings" "github.com/spf13/viper" + + "github.com/jumpserver/koko/pkg/common" ) var ( @@ -17,19 +19,21 @@ var ( ) type Config struct { - Name string `mapstructure:"NAME"` - CoreHost string `mapstructure:"CORE_HOST"` - BootstrapToken string `mapstructure:"BOOTSTRAP_TOKEN"` - BindHost string `mapstructure:"BIND_HOST"` - SSHPort string `mapstructure:"SSHD_PORT"` - HTTPPort string `mapstructure:"HTTPD_PORT"` - SSHTimeout int `mapstructure:"SSH_TIMEOUT"` + Name string `mapstructure:"NAME"` + CoreHost string `mapstructure:"CORE_HOST"` + BootstrapToken string `mapstructure:"BOOTSTRAP_TOKEN"` + BindHost string `mapstructure:"BIND_HOST"` + SSHPort string `mapstructure:"SSHD_PORT"` + HTTPPort string `mapstructure:"HTTPD_PORT"` + SSHTimeout int `mapstructure:"SSH_TIMEOUT"` + HttpRequestTimeout int `mapstructure:"HTTP_REQUEST_TIMEOUT"` LogLevel string `mapstructure:"LOG_LEVEL"` Comment string `mapstructure:"COMMENT"` LanguageCode string `mapstructure:"LANGUAGE_CODE"` UploadFailedReplay bool `mapstructure:"UPLOAD_FAILED_REPLAY_ON_START"` + UploadFailedFTPFile bool `mapstructure:"UPLOAD_FAILED_FTP_FILE_ON_START"` AssetLoadPolicy string `mapstructure:"ASSET_LOAD_POLICY"` // all ZipMaxSize string `mapstructure:"ZIP_MAX_SIZE"` ZipTmpPath string `mapstructure:"ZIP_TMP_PATH"` @@ -45,23 +49,49 @@ type Config struct { RedisDBIndex int `mapstructure:"REDIS_DB_ROOM"` RedisClusters []string `mapstructure:"REDIS_CLUSTERS"` + RedisSentinelPassword string `mapstructure:"REDIS_SENTINEL_PASSWORD"` + RedisSentinelHosts string `mapstructure:"REDIS_SENTINEL_HOSTS"` + RedisUseSSL bool `mapstructure:"REDIS_USE_SSL"` + EnableLocalPortForward bool `mapstructure:"ENABLE_LOCAL_PORT_FORWARD"` EnableVscodeSupport bool `mapstructure:"ENABLE_VSCODE_SUPPORT"` + EnableReversePortForward bool `mapstructure:"ENABLE_REVERSE_PORT_FORWARD"` + + HiddenFields []string `mapstructure:"HIDDEN_FIELDS"` + + // 仅控制是否缓存 sftp 的 token 连接 + ConnectionTokenReusable bool `mapstructure:"CONNECTION_TOKEN_REUSABLE"` + + SshMaxSessions int `mapstructure:"SSH_MAX_SESSIONS"` + + DisableInputAsCommand bool `mapstructure:"DISABLE_INPUT_AS_COMMAND"` + + SecretEncryptKey string `mapstructure:"SECRET_ENCRYPT_KEY"` + + // Force both public key and password authentication (two-factor SSH login) + ForceMultiAuth bool `mapstructure:"FORCE_MULTI_AUTH"` + RootPath string DataFolderPath string LogDirPath string KeyFolderPath string AccessKeyFilePath string ReplayFolderPath string + FTPFileFolderPath string + CertsFolderPath string } func (c *Config) EnsureConfigValid() { if c.LanguageCode == "" { - c.LanguageCode = "zh" + c.LanguageCode = "en" } } +func (c *Config) UpdateRedisPassword(val string) { + c.RedisPassword = val +} + func GetConf() Config { if GlobalConfig == nil { return getDefaultConfig() @@ -85,34 +115,42 @@ func getDefaultConfig() Config { rootPath := getPwdDirPath() dataFolderPath := filepath.Join(rootPath, "data") replayFolderPath := filepath.Join(dataFolderPath, "replays") + ftpFileFolderPath := filepath.Join(dataFolderPath, "ftp_files") LogDirPath := filepath.Join(dataFolderPath, "logs") keyFolderPath := filepath.Join(dataFolderPath, "keys") + CertsFolderPath := filepath.Join(dataFolderPath, "certs") accessKeyFilePath := filepath.Join(keyFolderPath, ".access_key") - folders := []string{dataFolderPath, replayFolderPath, keyFolderPath, LogDirPath} + folders := []string{dataFolderPath, replayFolderPath, + keyFolderPath, LogDirPath, CertsFolderPath} for i := range folders { if err := EnsureDirExist(folders[i]); err != nil { log.Fatalf("Create folder failed: %s", err) } } return Config{ - Name: defaultName, - CoreHost: "http://localhost:8080", - BootstrapToken: "", - BindHost: "0.0.0.0", - SSHPort: "2222", - SSHTimeout: 15, - HTTPPort: "5000", - AccessKeyFilePath: accessKeyFilePath, - LogLevel: "INFO", - RootPath: rootPath, - DataFolderPath: dataFolderPath, - LogDirPath: LogDirPath, - KeyFolderPath: keyFolderPath, - ReplayFolderPath: replayFolderPath, + Name: defaultName, + CoreHost: "http://localhost:8080", + BootstrapToken: "", + BindHost: "0.0.0.0", + SSHPort: "2222", + SSHTimeout: 15, + HttpRequestTimeout: 30, + HTTPPort: "5000", + AccessKeyFilePath: accessKeyFilePath, + LogLevel: "INFO", + RootPath: rootPath, + DataFolderPath: dataFolderPath, + LogDirPath: LogDirPath, + KeyFolderPath: keyFolderPath, + ReplayFolderPath: replayFolderPath, + FTPFileFolderPath: ftpFileFolderPath, + CertsFolderPath: CertsFolderPath, + LanguageCode: "en", Comment: "KOKO", UploadFailedReplay: true, + UploadFailedFTPFile: true, ShowHiddenFile: false, ReuseConnection: true, AssetLoadPolicy: "", @@ -127,13 +165,14 @@ func getDefaultConfig() Config { EnableLocalPortForward: false, EnableVscodeSupport: false, + DisableInputAsCommand: true, } } func EnsureDirExist(path string) error { if !haveDir(path) { - if err := os.MkdirAll(path, os.ModePerm); err != nil { + if err := os.MkdirAll(path, 0700); err != nil { return err } } @@ -198,13 +237,14 @@ const ( /* SERVER_HOSTNAME: 环境变量名,可用于自定义默认注册名称的前缀 default name rule: -[Koko]-{SERVER_HOSTNAME}-{HOSTNAME} +[Koko]-{SERVER_HOSTNAME}-{HOSTNAME}-RandomStr or -[Koko]-{HOSTNAME} +[Koko]-{HOSTNAME}-RandomStr */ func getDefaultName() string { hostname, _ := os.Hostname() + hostname = fmt.Sprintf("%s-%s", hostname, common.RandomStr(7)) if serverHostname, ok := os.LookupEnv(hostEnvKey); ok { hostname = fmt.Sprintf("%s-%s", serverHostname, hostname) } diff --git a/pkg/exchange/initial.go b/pkg/exchange/initial.go index 08930f20d..fa2925e15 100644 --- a/pkg/exchange/initial.go +++ b/pkg/exchange/initial.go @@ -2,6 +2,8 @@ package exchange import ( "net" + "os" + "path/filepath" "strings" "github.com/jumpserver/koko/pkg/config" @@ -18,11 +20,27 @@ func Initial() { switch strings.ToLower(conf.ShareRoomType) { case "redis": + existFile := func(path string) string { + if info, err2 := os.Stat(path); err2 == nil && !info.IsDir() { + return path + } + return "" + } + sslCaPath := filepath.Join(conf.CertsFolderPath, "redis_ca.crt") + sslCertPath := filepath.Join(conf.CertsFolderPath, "redis_client.crt") + sslKeyPath := filepath.Join(conf.CertsFolderPath, "redis_client.key") manager, err = newRedisManager(Config{ Addr: net.JoinHostPort(conf.RedisHost, conf.RedisPort), Password: conf.RedisPassword, Clusters: conf.RedisClusters, DBIndex: conf.RedisDBIndex, + + SentinelPassword: conf.RedisSentinelPassword, + SentinelsHost: conf.RedisSentinelHosts, + UseSSL: conf.RedisUseSSL, + SSLCa: existFile(sslCaPath), + SSLCert: existFile(sslCertPath), + SSLKey: existFile(sslKeyPath), }) default: diff --git a/pkg/exchange/local.go b/pkg/exchange/local.go index 10f95f4f0..88cad15c8 100644 --- a/pkg/exchange/local.go +++ b/pkg/exchange/local.go @@ -49,15 +49,15 @@ func (l *localCache) run() { } } -func (l localCache) Add(s *Room) { +func (l *localCache) Add(s *Room) { l.storeChan <- s } -func (l localCache) Delete(s *Room) { +func (l *localCache) Delete(s *Room) { l.leaveChan <- s } -func (l localCache) Get(sid string) *Room { +func (l *localCache) Get(sid string) *Room { l.checkChan <- sid return <-l.resultChan } diff --git a/pkg/exchange/message.go b/pkg/exchange/message.go index 1164ce69d..2a5b05928 100644 --- a/pkg/exchange/message.go +++ b/pkg/exchange/message.go @@ -9,16 +9,20 @@ type RoomMessage struct { Meta MetaMessage `json:"meta"` // receive的信息必须携带Meta } +func (m *RoomMessage) Marshal() []byte { + p, _ := json.Marshal(m) + return p +} + type MetaMessage struct { UserId string `json:"user_id"` User string `json:"user"` Created string `json:"created"` RemoteAddr string `json:"remote_addr"` -} -func (m RoomMessage) Marshal() []byte { - p, _ := json.Marshal(m) - return p + TerminalId string `json:"terminal_id"` + Primary bool `json:"primary"` + Writable bool `json:"writable"` } const ( @@ -26,6 +30,9 @@ const ( DataEvent = "Data" WindowsEvent = "Windows" + PauseEvent = "Pause" + ResumeEvent = "Resume" + JoinEvent = "Join" LeaveEvent = "Leave" @@ -38,6 +45,11 @@ const ( ShareUsers = "Share_USERS" ActionEvent = "Action" + + PermExpiredEvent = "PermExpired" + PermValidEvent = "PermValid" + + ShareRemoveUser = "Share_REMOVE_USER" ) const ( diff --git a/pkg/exchange/redis.go b/pkg/exchange/redis.go index 94f7154e6..8cc4a3bc7 100644 --- a/pkg/exchange/redis.go +++ b/pkg/exchange/redis.go @@ -2,14 +2,18 @@ package exchange import ( "context" + "crypto/tls" + "crypto/x509" "encoding/json" "fmt" "math/rand" + "os" + "strings" "time" "github.com/mediocregopher/radix/v3" - uuid "github.com/satori/go.uuid" + "github.com/jumpserver/koko/pkg/common" "github.com/jumpserver/koko/pkg/logger" ) @@ -42,6 +46,13 @@ type Config struct { MaxActive int DBIndex int + + SentinelsHost string + SentinelPassword string + SSLCa string + SSLCert string + SSLKey string + UseSSL bool } func newRedisManager(cfg Config) (*redisRoomManager, error) { @@ -53,7 +64,7 @@ func newRedisManager(cfg Config) (*redisRoomManager, error) { cfg.Addr = "127.0.0.1:6379" } - if cfg.DialTimeout < 0 { + if cfg.DialTimeout <= 0 { cfg.DialTimeout = 30 * time.Second } @@ -75,6 +86,31 @@ func newRedisManager(cfg Config) (*redisRoomManager, error) { dialOptions = append(dialOptions, radix.DialSelectDB(int(cfg.DBIndex))) } + if cfg.UseSSL { + tlsCfg := tls.Config{} + if cfg.SSLCert != "" && cfg.SSLKey != "" { + cert, err := tls.LoadX509KeyPair(cfg.SSLCert, cfg.SSLKey) + if err != nil { + return nil, err + } + logger.Debugf("Load redis SSL cert: %s, key: %s", cfg.SSLCert, cfg.SSLKey) + tlsCfg.Certificates = []tls.Certificate{cert} + tlsCfg.InsecureSkipVerify = true + } + if cfg.SSLCa != "" { + certPool := x509.NewCertPool() + buf, err := os.ReadFile(cfg.SSLCa) + if err != nil { + return nil, err + } + logger.Debugf("Load redis SSL ca: %s", cfg.SSLCa) + certPool.AppendCertsFromPEM(buf) + tlsCfg.RootCAs = certPool + tlsCfg.InsecureSkipVerify = true + } + dialOptions = append(dialOptions, radix.DialUseTLS(&tlsCfg)) + } + var connFunc radix.ConnFunc if len(cfg.Clusters) > 0 { cluster, err := radix.NewCluster(cfg.Clusters) @@ -89,6 +125,46 @@ func newRedisManager(cfg Config) (*redisRoomManager, error) { node := topo[rand.Intn(len(topo))] return radix.Dial(cfg.Network, node.Addr, dialOptions...) } + } else if cfg.SentinelsHost != "" { + sentinels := strings.SplitN(cfg.SentinelsHost, "/", 2) + if len(sentinels) != 2 { + return nil, fmt.Errorf("invalid sentinel host: %s", cfg.SentinelsHost) + } + sentinelServiceName := sentinels[0] + sentinelHosts := strings.Split(sentinels[1], ",") + sentinelOpts := make([]radix.DialOpt, 0, len(dialOptions)+1) + sentinelOpts = append(sentinelOpts, dialOptions...) + if cfg.SentinelPassword != "" { + sentinelOpts = append(sentinelOpts, radix.DialAuthPass(cfg.SentinelPassword)) + } else { + sentinelOpts = append(sentinelOpts, radix.DialAuthUser("", "")) + } + sentinelConnFunc := func(network, addr string) (radix.Conn, error) { + conn, err := radix.Dial(network, addr, sentinelOpts...) + if err != nil { + logger.Errorf("Redis sentinelConnFunc dial err: %s", err) + return nil, err + } + return conn, nil + } + serverConnFunc := func(network, addr string) (radix.Conn, error) { + logger.Debugf("sentinel pool server addr: %s", addr) + return radix.Dial(network, addr, dialOptions...) + } + poolFunc := func(network, addr string) (radix.Client, error) { + return radix.NewPool(network, addr, 4, radix.PoolConnFunc(serverConnFunc)) + } + sentinelClient, err := radix.NewSentinel(sentinelServiceName, sentinelHosts, + radix.SentinelConnFunc(sentinelConnFunc), radix.SentinelPoolFunc(poolFunc)) + if err != nil { + logger.Errorf("Redis sentinel client err: %s", err) + return nil, err + } + connFunc = func(network, addr string) (radix.Conn, error) { + // 选择一个master + masterAddr, _ := sentinelClient.Addrs() + return radix.Dial(cfg.Network, masterAddr, dialOptions...) + } } else { connFunc = func(network, addr string) (radix.Conn, error) { return radix.Dial(cfg.Network, cfg.Addr, dialOptions...) @@ -98,6 +174,7 @@ func newRedisManager(cfg Config) (*redisRoomManager, error) { pubSub, err := radix.PersistentPubSubWithOpts("", "", radix.PersistentPubSubConnFunc(connFunc)) if err != nil { + logger.Errorf("Redis pubSub err: %s", err) return nil, err } redisMsgCh := make(chan radix.PubSubMessage) @@ -111,7 +188,7 @@ func newRedisManager(cfg Config) (*redisRoomManager, error) { } m := &redisRoomManager{ - Id: uuid.NewV4().String(), + Id: common.UUID(), pool: pool, connFunc: connFunc, localRoomCache: newLocalCache(), diff --git a/pkg/exchange/room.go b/pkg/exchange/room.go index ddd48cd54..bd4ecc594 100644 --- a/pkg/exchange/room.go +++ b/pkg/exchange/room.go @@ -104,9 +104,9 @@ func (r *Room) run() { delete(connMaps, con.Id) logger.Debugf("Room %s current connections count: %d", r.Id, len(connMaps)) case msg := <-r.broadcastChan: - userConns := make([]*Conn, 0, len(connMaps)) + userCones := make([]*Conn, 0, len(connMaps)) for k := range connMaps { - userConns = append(userConns, connMaps[k]) + userCones = append(userCones, connMaps[k]) } switch msg.Event { case DataEvent: @@ -128,7 +128,7 @@ func (r *Room) run() { ZMODEMStatus = false } } - r.broadcastMessage(userConns, msg) + r.broadcastMessage(userCones, msg) case <-r.exitSignal: for k := range connMaps { diff --git a/pkg/handler/app.go b/pkg/handler/app.go deleted file mode 100644 index a258f6c43..000000000 --- a/pkg/handler/app.go +++ /dev/null @@ -1,35 +0,0 @@ -package handler - -import ( - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/logger" - "github.com/jumpserver/koko/pkg/proxy" -) - -func (u *UserSelectHandler) proxyApp(app model.Application) { - - systemUsers, err := u.h.jmsService.GetUserApplicationSystemUsers(u.user.ID, app.ID) - if err != nil { - return - } - highestSystemUsers := selectHighestPrioritySystemUsers(systemUsers) - selectedSystemUser, ok := u.h.chooseSystemUser(highestSystemUsers) - if !ok { - logger.Infof("User %s don't select systemUser", u.user.Name) - return - } - i18nLang := u.h.i18nLang - srv, err := proxy.NewServer(u.h.sess, u.h.jmsService, - proxy.ConnectProtocolType(selectedSystemUser.Protocol), - proxy.ConnectI18nLang(i18nLang), - proxy.ConnectApp(&app), - proxy.ConnectSystemUser(&selectedSystemUser), - proxy.ConnectUser(u.user), - ) - if err != nil { - logger.Error(err) - } - srv.Proxy() - logger.Infof("Request %s: application %s proxy end", u.h.sess.Uuid, app.Name) - -} diff --git a/pkg/handler/app_database.go b/pkg/handler/app_database.go new file mode 100644 index 000000000..49e5b0536 --- /dev/null +++ b/pkg/handler/app_database.go @@ -0,0 +1,16 @@ +package handler + +import ( + "github.com/jumpserver/koko/pkg/i18n" +) + +func (u *UserSelectHandler) displayDatabaseResult(searchHeader string) { + currentResult := u.currentResult + lang := i18n.NewLang(u.h.i18nLang) + if len(currentResult) == 0 { + noDatabases := lang.T("No Databases") + u.displayNoResultMsg(searchHeader, noDatabases) + return + } + u.displayAssets(searchHeader) +} diff --git a/pkg/handler/app_k8s.go b/pkg/handler/app_k8s.go index 649663361..dd7f8ef6d 100644 --- a/pkg/handler/app_k8s.go +++ b/pkg/handler/app_k8s.go @@ -2,118 +2,61 @@ package handler import ( "fmt" - "strconv" "github.com/jumpserver/koko/pkg/common" "github.com/jumpserver/koko/pkg/i18n" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/logger" "github.com/jumpserver/koko/pkg/utils" ) -func (u *UserSelectHandler) retrieveRemoteK8s(reqParam model.PaginationParam) []map[string]interface{} { - res, err := u.h.jmsService.GetUserPermsK8s(u.user.ID, reqParam) - if err != nil { - logger.Errorf("Get user perm k8s failed: %s", err.Error()) - } - return u.updateRemotePageData(reqParam, res) -} - -func (u *UserSelectHandler) searchLocalK8s(searches ...string) []map[string]interface{} { - /* - { - "id": "0a318338-65ca-4e33-80ec-daf11d6d6c9a", - "name": "kube", - "domain": null, - "category": "cloud", - "type": "k8s", - "attrs": { - "cluster": "https://127.0.0.1:8443" - }, - "comment": "https://127.0.0.1:8443", - "org_id": "", - "category_display": "Cloud", - "type_display": "Kubernetes", - "org_name": "DEFAULT" - } - */ - //searchFields := []string{"name", "cluster", "comment"} - - fields := map[string]struct{}{ - "name": {}, - "cluster": {}, - "comment": {}, - } - return u.searchLocalFromFields(fields, searches...) -} - func (u *UserSelectHandler) displayK8sResult(searchHeader string) { - currentDBS := u.currentResult - term := u.h.term + currentResult := u.currentResult lang := i18n.NewLang(u.h.i18nLang) - if len(currentDBS) == 0 { + if len(currentResult) == 0 { noK8s := lang.T("No kubernetes") - utils.IgnoreErrWriteString(term, utils.WrapperString(noK8s, utils.Red)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) - utils.IgnoreErrWriteString(term, utils.WrapperString(searchHeader, utils.Green)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) + u.displayNoResultMsg(searchHeader, noK8s) return } + u.displayAssets(searchHeader) +} +func (u *UserSelectHandler) displayResult(searchHeader string, Labels, fields []string, + fieldSize map[string][3]int, data []map[string]string) { + lang := i18n.NewLang(u.h.i18nLang) + vt := u.h.term + w, _ := u.h.GetPtySize() currentPage := u.CurrentPage() pageSize := u.PageSize() totalPage := u.TotalPage() totalCount := u.TotalCount() - - idLabel := lang.T("ID") - nameLabel := lang.T("Name") - clusterLabel := lang.T("Cluster") - commentLabel := lang.T("Comment") - - Labels := []string{idLabel, nameLabel, clusterLabel, commentLabel} - fields := []string{"ID", "Name", "Cluster", "Comment"} - data := make([]map[string]string, len(currentDBS)) - for i, j := range currentDBS { - row := make(map[string]string) - row["ID"] = strconv.Itoa(i + 1) - filedMap := map[string]string{ - "name": "Name", - "cluster": "Cluster", - "comment": "Comment", - } - row = convertMapItemToRow(j, filedMap, row) - row["Comment"] = joinMultiLineString(row["Comment"]) - data[i] = row - } - w, _ := term.GetSize() - caption := fmt.Sprintf(lang.T("Page: %d, Count: %d, Total Page: %d, Total Count: %d"), currentPage, pageSize, totalPage, totalCount) caption = utils.WrapperString(caption, utils.Green) table := common.WrapperTable{ - Fields: fields, - Labels: Labels, - FieldsSize: map[string][3]int{ - "ID": {0, 0, 5}, - "Name": {0, 8, 0}, - "Cluster": {0, 20, 0}, - "Comment": {0, 0, 0}, - }, + Fields: fields, + Labels: Labels, + FieldsSize: fieldSize, Data: data, TotalSize: w, Caption: caption, TruncPolicy: common.TruncMiddle, } table.Initial() - - loginTip := lang.T("Enter ID number directly login the kubernetes, multiple search use // + field, such as: //16") + loginTip := lang.T("Enter ID number directly login, multiple search use // + field, such as: //16") pageActionTip := lang.T("Page up: b Page down: n") actionTip := fmt.Sprintf("%s %s", loginTip, pageActionTip) - _, _ = term.Write([]byte(utils.CharClear)) - _, _ = term.Write([]byte(table.Display())) - utils.IgnoreErrWriteString(term, utils.WrapperString(actionTip, utils.Green)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) - utils.IgnoreErrWriteString(term, utils.WrapperString(searchHeader, utils.Green)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) + _, _ = vt.Write([]byte(utils.CharClear)) + _, _ = vt.Write([]byte(table.Display())) + utils.IgnoreErrWriteString(vt, utils.WrapperString(actionTip, utils.Green)) + utils.IgnoreErrWriteString(vt, utils.CharNewLine) + utils.IgnoreErrWriteString(vt, utils.WrapperString(searchHeader, utils.Green)) + utils.IgnoreErrWriteString(vt, utils.CharNewLine) +} + +func (u *UserSelectHandler) displayNoResultMsg(searchHeader, tips string) { + vt := u.h.term + utils.IgnoreErrWriteString(vt, utils.WrapperString(tips, utils.Red)) + utils.IgnoreErrWriteString(vt, utils.CharNewLine) + utils.IgnoreErrWriteString(vt, utils.WrapperString(searchHeader, utils.Green)) + utils.IgnoreErrWriteString(vt, utils.CharNewLine) } diff --git a/pkg/handler/app_mysql.go b/pkg/handler/app_mysql.go deleted file mode 100644 index 9007f05a9..000000000 --- a/pkg/handler/app_mysql.go +++ /dev/null @@ -1,127 +0,0 @@ -package handler - -import ( - "fmt" - "strconv" - - "github.com/jumpserver/koko/pkg/common" - "github.com/jumpserver/koko/pkg/i18n" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/logger" - "github.com/jumpserver/koko/pkg/utils" -) - -func (u *UserSelectHandler) retrieveRemoteDatabase(reqParam model.PaginationParam) []map[string]interface{} { - res, err := u.h.jmsService.GetUserPermsDatabase(u.user.ID, reqParam) - if err != nil { - logger.Errorf("Get user perm Database failed: %s", err) - } - return u.updateRemotePageData(reqParam, res) -} - -func (u *UserSelectHandler) searchLocalDatabase(searches ...string) []map[string]interface{} { - /* - { - "id": "2b8f37ad-1580-4275-962a-7ea0f53c40b3", - "name": "www", - "domain": null, - "category": "db", - "type": "mysql", - "attrs": { - "host": "www", - "port": 32342, - "database": null - }, - "comment": "", - "org_id": "", - "category_display": "数据库", - "type_display": "MySQL", - "org_name": "DEFAULT" - } - */ - fields := map[string]struct{}{ - "name": {}, - "host": {}, - "database": {}, - "comment": {}, - } - return u.searchLocalFromFields(fields, searches...) -} - -func (u *UserSelectHandler) displayDatabaseResult(searchHeader string) { - currentDBS := u.currentResult - term := u.h.term - lang := i18n.NewLang(u.h.i18nLang) - if len(currentDBS) == 0 { - noDatabases := lang.T("No Databases") - utils.IgnoreErrWriteString(term, utils.WrapperString(noDatabases, utils.Red)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) - utils.IgnoreErrWriteString(term, utils.WrapperString(searchHeader, utils.Green)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) - return - } - - currentPage := u.CurrentPage() - pageSize := u.PageSize() - totalPage := u.TotalPage() - totalCount := u.TotalCount() - - idLabel := lang.T("ID") - nameLabel := lang.T("Name") - ipLabel := lang.T("IP") - dbTypeLabel := lang.T("DBType") - dbNameLabel := lang.T("DB Name") - commentLabel := lang.T("Comment") - - Labels := []string{idLabel, nameLabel, ipLabel, - dbTypeLabel, dbNameLabel, commentLabel} - fields := []string{"ID", "Name", "IP", "DBType", "DBName", "Comment"} - data := make([]map[string]string, len(currentDBS)) - for i, j := range currentDBS { - row := make(map[string]string) - row["ID"] = strconv.Itoa(i + 1) - fieldsMap := map[string]string{ - "name": "Name", - "host": "IP", - "type": "DBType", - "database": "DBName", - "comment": "Comment"} - row = convertMapItemToRow(j, fieldsMap, row) - // 特殊处理 comment - row["Comment"] = joinMultiLineString(row["Comment"]) - data[i] = row - } - w, _ := term.GetSize() - - caption := fmt.Sprintf(lang.T("Page: %d, Count: %d, Total Page: %d, Total Count: %d"), - currentPage, pageSize, totalPage, totalCount) - - caption = utils.WrapperString(caption, utils.Green) - table := common.WrapperTable{ - Fields: fields, - Labels: Labels, - FieldsSize: map[string][3]int{ - "ID": {0, 0, 5}, - "Name": {0, 8, 0}, - "IP": {0, 15, 40}, - "DBType": {0, 8, 0}, - "DBName": {0, 8, 0}, - "Comment": {0, 0, 0}, - }, - Data: data, - TotalSize: w, - Caption: caption, - TruncPolicy: common.TruncMiddle, - } - table.Initial() - loginTip := lang.T("Enter ID number directly login the database, multiple search use // + field, such as: //16") - pageActionTip := lang.T("Page up: b Page down: n") - actionTip := fmt.Sprintf("%s %s", loginTip, pageActionTip) - - _, _ = term.Write([]byte(utils.CharClear)) - _, _ = term.Write([]byte(table.Display())) - utils.IgnoreErrWriteString(term, utils.WrapperString(actionTip, utils.Green)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) - utils.IgnoreErrWriteString(term, utils.WrapperString(searchHeader, utils.Green)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) -} diff --git a/pkg/handler/asset.go b/pkg/handler/asset.go index fb5472f23..e47cf3468 100644 --- a/pkg/handler/asset.go +++ b/pkg/handler/asset.go @@ -1,20 +1,25 @@ package handler import ( + "errors" "fmt" - "sort" + "io" "strconv" "strings" - "github.com/jumpserver/koko/pkg/common" + "golang.org/x/term" + + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" + "github.com/jumpserver/koko/pkg/i18n" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" "github.com/jumpserver/koko/pkg/logger" "github.com/jumpserver/koko/pkg/proxy" + "github.com/jumpserver/koko/pkg/srvconn" "github.com/jumpserver/koko/pkg/utils" ) -func (u *UserSelectHandler) retrieveRemoteAsset(reqParam model.PaginationParam) []map[string]interface{} { +func (u *UserSelectHandler) retrieveRemoteAsset(reqParam model.PaginationParam) []model.PermAsset { res, err := u.h.jmsService.GetUserPermsAssets(u.user.ID, reqParam) if err != nil { logger.Errorf("Get user perm assets failed: %s", err.Error()) @@ -22,221 +27,281 @@ func (u *UserSelectHandler) retrieveRemoteAsset(reqParam model.PaginationParam) return u.updateRemotePageData(reqParam, res) } -func (u *UserSelectHandler) searchLocalAsset(searches ...string) []map[string]interface{} { - /* - { - "id": "1ccad81f-76a6-4ee2-a3ac-e652ef3afecb", - "hostname": "127.0.0.1", - "ip": "192.168.1.97", - "protocols": [ - "rdp/3389" - ], - "os": null, - "domain": null, - "platform": "Windows", - "comment": "", - "org_id": "", - "is_active": true, - "org_name": "DEFAULT" - }, - */ - fields := map[string]struct{}{ - "name": {}, - "hostname": {}, - "ip": {}, - "comment": {}, +func (u *UserSelectHandler) searchLocalAsset(searches ...string) []model.PermAsset { + allFields := []string{"name", "address", "platform", "comment"} + fields := make(map[string]struct{}, len(allFields)) + for i := range allFields { + if u.isHiddenField(allFields[i]) { + continue + } + fields[allFields[i]] = struct{}{} } return u.searchLocalFromFields(fields, searches...) } func (u *UserSelectHandler) displayAssetResult(searchHeader string) { - term := u.h.term lang := i18n.NewLang(u.h.i18nLang) if len(u.currentResult) == 0 { noAssets := lang.T("No Assets") - utils.IgnoreErrWriteString(term, utils.WrapperString(noAssets, utils.Red)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) - utils.IgnoreErrWriteString(term, utils.WrapperString(searchHeader, utils.Green)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) + u.displayNoResultMsg(searchHeader, noAssets) return } - u.displaySortedAssets(searchHeader) + u.displayAssets(searchHeader) } -func (u *UserSelectHandler) displaySortedAssets(searchHeader string) { - lang := i18n.NewLang(u.h.i18nLang) - assetListSortBy := u.h.terminalConf.AssetListSortBy - switch assetListSortBy { - case "ip": - sortedAsset := IPAssetList(u.currentResult) - sort.Sort(sortedAsset) - u.currentResult = sortedAsset - default: - sortedAsset := HostnameAssetList(u.currentResult) - sort.Sort(sortedAsset) - u.currentResult = sortedAsset - } - term := u.h.term - currentPage := u.CurrentPage() - pageSize := u.PageSize() - totalPage := u.TotalPage() - totalCount := u.TotalCount() +const maxFieldSize = 80 // 仅仅是限制字段显示长度最大为 80 +func (u *UserSelectHandler) displayAssets(searchHeader string) { + currentResult := u.currentResult + lang := i18n.NewLang(u.h.i18nLang) idLabel := lang.T("ID") - hostLabel := lang.T("Hostname") - ipLabel := lang.T("IP") + nameLabel := lang.T("Name") + addressLabel := lang.T("Address") + platformLabel := lang.T("Platform") + orgLabel := lang.T("Organization") commentLabel := lang.T("Comment") - - Labels := []string{idLabel, hostLabel, ipLabel, commentLabel} - fields := []string{"ID", "Hostname", "IP", "Comment"} - data := make([]map[string]string, len(u.currentResult)) - for i, j := range u.currentResult { + idFieldSize := len(idLabel) + nameFieldSize := len(nameLabel) + addressFieldSize := len(addressLabel) + platformFieldSize := len(platformLabel) + organizationFieldSize := len(orgLabel) + commentFieldSize := len(commentLabel) + data := make([]map[string]string, len(currentResult)) + for i := range currentResult { + item := &u.currentResult[i] row := make(map[string]string) - row["ID"] = strconv.Itoa(i + 1) - fieldMap := map[string]string{ - "hostname": "Hostname", - "ip": "IP", - "comment": "Comment", - } - row = convertMapItemToRow(j, fieldMap, row) - row["Comment"] = joinMultiLineString(row["Comment"]) + idNumber := strconv.Itoa(i + 1) + row["ID"] = idNumber + row["Name"] = strings.ReplaceAll(item.Name, " ", "_") // 多个空格可能会导致换行,所以全部替换成下划线 + row["Address"] = item.Address + row["Platform"] = item.Platform.Name + row["Organization"] = item.OrgName + row["Comment"] = joinMultiLineString(item.Comment) data[i] = row + if idFieldSize < len(idNumber) { + idFieldSize = len(idNumber) + } + if len(item.Name) > nameFieldSize { + nameFieldSize = len(item.Name) + } + if len(item.Address) > addressFieldSize { + addressFieldSize = len(item.Address) + } } - w, _ := term.GetSize() - caption := fmt.Sprintf(lang.T("Page: %d, Count: %d, Total Page: %d, Total Count: %d"), - currentPage, pageSize, totalPage, totalCount) - - caption = utils.WrapperString(caption, utils.Green) - table := common.WrapperTable{ - Fields: fields, - Labels: Labels, - FieldsSize: map[string][3]int{ - "ID": {0, 0, 5}, - "Hostname": {0, 40, 0}, - "IP": {0, 15, 40}, - "Comment": {0, 0, 0}, - }, - Data: data, - TotalSize: w, - Caption: caption, - TruncPolicy: common.TruncMiddle, - } - table.Initial() - loginTip := lang.T("Enter ID number directly login the asset, multiple search use // + field, such as: //16") - pageActionTip := lang.T("Page up: b Page down: n") - actionTip := fmt.Sprintf("%s %s", loginTip, pageActionTip) - - _, _ = term.Write([]byte(utils.CharClear)) - _, _ = term.Write([]byte(table.Display())) - utils.IgnoreErrWriteString(term, utils.WrapperString(actionTip, utils.Green)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) - utils.IgnoreErrWriteString(term, utils.WrapperString(searchHeader, utils.Green)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) + if nameFieldSize > maxFieldSize { + nameFieldSize = maxFieldSize + } + if addressFieldSize > maxFieldSize { + addressFieldSize = maxFieldSize + } + + allFieldsSize := map[string][3]int{ + "ID": {idFieldSize, 0, 0}, + "Name": {nameFieldSize, 0, 0}, + "Address": {addressFieldSize, 0, 0}, + "Platform": {0, platformFieldSize, 0}, + "Organization": {0, organizationFieldSize, 0}, + "Comment": {0, commentFieldSize, 0}, + } + allLabels := []string{idLabel, nameLabel, addressLabel, platformLabel, orgLabel, commentLabel} + allFields := []string{"ID", "Name", "Address", "Platform", "Organization", "Comment"} + labels := make([]string, 0, len(allLabels)) + fields := make([]string, 0, len(allFields)) + for i := range allFields { + if u.isHiddenField(allFields[i]) { + continue + } + labels = append(labels, allLabels[i]) + fields = append(fields, allFields[i]) + } + fieldsSize := make(map[string][3]int, len(fields)) + for i := range fields { + fieldsSize[fields[i]] = allFieldsSize[fields[i]] + } + u.displayResult(searchHeader, labels, fields, fieldsSize, data) } -func (u *UserSelectHandler) proxyAsset(asset model.Asset) { - systemUsers, err := u.h.jmsService.GetSystemUsersByUserIdAndAssetId(u.user.ID, asset.ID) +func GetInputUsername(sess io.ReadWriteCloser) (username string, err error) { + vt := term.NewTerminal(sess, "username: ") + count := 0 + for count < 3 { + username, err = vt.ReadLine() + if err != nil { + return "", err + } + username = strings.TrimSpace(username) + if username != "" { + return username, nil + } + count++ + } + return "", errors.New("input username exceed max retry") +} + +func (u *UserSelectHandler) proxyAsset(asset model.PermAsset) { + u.selectedAsset = &asset + permAssetDetail, err := u.h.jmsService.GetUserPermAssetDetailById(u.user.ID, asset.ID) if err != nil { + logger.Errorf("Get asset accounts err: %s", err) return } - highestSystemUsers := selectHighestPrioritySystemUsers(systemUsers) - selectedSystemUser, ok := u.h.chooseSystemUser(highestSystemUsers) - + // 过滤仅支持的连接协议 + allSupportedProtocols := srvconn.SupportedProtocols() + filterFunc := func(p string) bool { + name := strings.ToLower(p) + for i := range allSupportedProtocols { + if strings.EqualFold(name, allSupportedProtocols[i]) { + return true + } + } + return false + } + protocols := make([]string, 0, len(permAssetDetail.PermedProtocols)) + for i := range permAssetDetail.PermedProtocols { + if filterFunc(permAssetDetail.PermedProtocols[i].Name) { + protocols = append(protocols, permAssetDetail.PermedProtocols[i].Name) + } + } + protocol, ok := u.h.chooseAssetProtocol(protocols) if !ok { + logger.Info("Not select protocol") return } i18nLang := u.h.i18nLang - srv, err := proxy.NewServer(u.h.sess, - u.h.jmsService, - proxy.ConnectProtocolType(selectedSystemUser.Protocol), - proxy.ConnectI18nLang(i18nLang), - proxy.ConnectUser(u.h.user), - proxy.ConnectAsset(&asset), - proxy.ConnectSystemUser(&selectedSystemUser), - ) - if err != nil { - logger.Error(err) + lang := i18n.NewLang(i18nLang) + if err2 := srvconn.IsSupportedProtocol(protocol); err2 != nil { + var errMsg string + switch { + case errors.As(err2, &srvconn.ErrNoClient{}): + errMsg = lang.T("%s protocol client not installed.") + errMsg = fmt.Sprintf(errMsg, protocol) + default: + errMsg = lang.T("Terminal does not support protocol %s, please use web terminal to access") + errMsg = fmt.Sprintf(errMsg, protocol) + } + utils.IgnoreErrWriteString(u.h.term, utils.WrapperWarn(errMsg)) return } - srv.Proxy() - logger.Infof("Request %s: asset %s proxy end", u.h.sess.Uuid, asset.Hostname) - -} - -var ( - _ sort.Interface = (HostnameAssetList)(nil) - _ sort.Interface = (IPAssetList)(nil) -) - -type HostnameAssetList []map[string]interface{} - -func (l HostnameAssetList) Len() int { - return len(l) -} - -func (l HostnameAssetList) Less(i, j int) bool { - iHostnameValue := l[i]["hostname"] - jHostnameValue := l[j]["hostname"] - iHostname, ok := iHostnameValue.(string) + supportAccounts := u.filterValidAccount(permAssetDetail.PermedAccounts) + selectedAccount, ok := u.h.chooseAccount(supportAccounts) if !ok { - return false + logger.Info("Not select account") + return } - jHostname, ok := jHostnameValue.(string) - if !ok { - return false + u.selectedAccount = &selectedAccount + req := service.SuperConnectTokenReq{ + UserId: u.user.ID, + AssetId: asset.ID, + Account: selectedAccount.Alias, + Protocol: protocol, + ConnectMethod: "ssh", + InputUsername: selectedAccount.Username, + RemoteAddr: u.h.sess.RemoteAddr(), + } + if selectedAccount.IsInputUser() { + inputUsername, err1 := GetInputUsername(u.h.sess) + if err1 != nil { + logger.Errorf("Get input username err: %s", err1) + return + } + req.InputUsername = inputUsername } - return CompareString(iHostname, jHostname) -} - -func (l HostnameAssetList) Swap(i, j int) { - l[j], l[i] = l[i], l[j] -} - -type IPAssetList []map[string]interface{} -func (l IPAssetList) Len() int { - return len(l) -} + tokenInfo, err := u.h.jmsService.CreateSuperConnectToken(&req) + if err != nil { + if tokenInfo.Code == "" { + logger.Errorf("Create connect token and auth info failed: %s", err) + utils.IgnoreErrWriteString(u.h.term, lang.T("Core API failed")) + return + } + switch tokenInfo.Code { + case model.ACLReject: + logger.Errorf("Create connect token and auth info failed: %s", tokenInfo.Detail) + utils.IgnoreErrWriteString(u.h.term, lang.T("ACL reject")) + utils.IgnoreErrWriteString(u.h.term, utils.CharNewLine) + return + case model.ACLFaceVerify, model.ACLFaceOnline, model.ACLFaceOnlineNotSupported: + // todo: 需要人脸验证 后续需要发站内信通知用户,并且等待用户人脸验证通过 + logger.Errorf("Create connect token and auth info failed: %s %s", tokenInfo.Code, tokenInfo.Detail) + msg := lang.T("Face ACL is not supported yet. Please use the WebTerminal to connect the asset.") + utils.IgnoreErrWriteString(u.h.term, msg) + utils.IgnoreErrWriteString(u.h.term, utils.CharNewLine) + return + case model.ACLReview: + reviewHandler := LoginReviewHandler{ + readWriter: u.h.sess, + i18nLang: u.h.i18nLang, + user: u.user, + jmsService: u.h.jmsService, + req: &req, + } + ok2, err2 := reviewHandler.WaitReview(u.h.sess.Context()) + if err2 != nil { + logger.Errorf("Wait login review failed: %s", err) + utils.IgnoreErrWriteString(u.h.term, lang.T("Core API failed")) + return + } + if !ok2 { + logger.Error("Wait login review failed") + return + } + tokenInfo = reviewHandler.tokenInfo + default: + msg := lang.T("Unknown error code: %s, detail: %s") + utils.IgnoreErrWriteString(u.h.term, fmt.Sprintf(msg, tokenInfo.Code, tokenInfo.Detail)) + utils.IgnoreErrWriteString(u.h.term, utils.CharNewLine) + logger.Errorf("Create connect token and auth info failed: %s %s", tokenInfo.Code, tokenInfo.Detail) + return + } + } -func (l IPAssetList) Less(i, j int) bool { - iIPValue := l[i]["ip"] - jIPValue := l[j]["ip"] - iIP, ok := iIPValue.(string) - if !ok { - return false + connectToken, err := u.h.jmsService.GetConnectTokenInfo(tokenInfo.ID, true) + if err != nil { + logger.Errorf("connect token err: %s", err) + utils.IgnoreErrWriteString(u.h.term, lang.T("get connect token err")) + return } - jIP, ok := jIPValue.(string) - if !ok { - return false + proxyOpts := make([]proxy.ConnectionOption, 0, 10) + proxyOpts = append(proxyOpts, proxy.ConnectTokenAuthInfo(&connectToken)) + proxyOpts = append(proxyOpts, proxy.ConnectI18nLang(i18nLang)) + srv, err := proxy.NewServer(u.h.sess, u.h.jmsService, proxyOpts...) + if err != nil { + logger.Errorf("create proxy server err: %s", err) + return } - return CompareIP(iIP, jIP) + srv.Proxy() } -func (l IPAssetList) Swap(i, j int) { - l[j], l[i] = l[i], l[j] +func (u *UserSelectHandler) isHiddenField(field string) bool { + fieldName := strings.ToLower(field) + if isBuiltinFields(fieldName) { + return false + } + _, ok := u.hiddenFields[fieldName] + return ok } -func CompareIP(ipA, ipB string) bool { - iIPs := strings.Split(ipA, ".") - jIPs := strings.Split(ipB, ".") - for i := 0; i < len(iIPs); i++ { - if i >= len(jIPs) { - return false +func (u *UserSelectHandler) filterValidAccount(accounts []model.PermAccount) []model.PermAccount { + ret := make([]model.PermAccount, 0, len(accounts)) + for i := range accounts { + // 匿名账号不显示 + if accounts[i].IsAnonymous() { + continue } - if len(iIPs[i]) == len(jIPs[i]) { - if iIPs[i] == jIPs[i] { - continue - } else { - return iIPs[i] < jIPs[i] - } - } else { - return len(iIPs[i]) < len(jIPs[i]) - } - + ret = append(ret, accounts[i]) } - return true + return ret +} + +var builtinFields = map[string]struct{}{ + "id": {}, + "name": {}, + "address": {}, + "comment": {}, } -func CompareString(a, b string) bool { - return a < b +func isBuiltinFields(field string) bool { + fieldName := strings.ToLower(field) + _, ok := builtinFields[fieldName] + return ok } diff --git a/pkg/handler/asset_node.go b/pkg/handler/asset_node.go index 753ee2d1d..4d7acc670 100644 --- a/pkg/handler/asset_node.go +++ b/pkg/handler/asset_node.go @@ -2,14 +2,13 @@ package handler import ( "fmt" - "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver-dev/sdk-go/model" "github.com/jumpserver/koko/pkg/i18n" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/utils" + "github.com/jumpserver/koko/pkg/logger" ) -func (u *UserSelectHandler) retrieveRemoteNodeAsset(reqParam model.PaginationParam) []map[string]interface{} { +func (u *UserSelectHandler) retrieveRemoteNodeAsset(reqParam model.PaginationParam) []model.PermAsset { res, err := u.h.jmsService.GetUserNodeAssets(u.user.ID, u.selectedNode.ID, reqParam) if err != nil { logger.Errorf("Get user %s node assets failed %s", u.user.Name, err) @@ -18,15 +17,11 @@ func (u *UserSelectHandler) retrieveRemoteNodeAsset(reqParam model.PaginationPar } func (u *UserSelectHandler) displayNodeAssetResult(searchHeader string) { - term := u.h.term lang := i18n.NewLang(u.h.i18nLang) if len(u.currentResult) == 0 { noNodeAssets := fmt.Sprintf(lang.T("%s node has no assets"), u.selectedNode.Name) - utils.IgnoreErrWriteString(term, utils.WrapperString(noNodeAssets, utils.Red)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) - utils.IgnoreErrWriteString(term, utils.WrapperString(searchHeader, utils.Green)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) + u.displayNoResultMsg(searchHeader, noNodeAssets) return } - u.displaySortedAssets(searchHeader) + u.displayAssets(searchHeader) } diff --git a/pkg/handler/asset_test.go b/pkg/handler/asset_test.go deleted file mode 100644 index 9ba572717..000000000 --- a/pkg/handler/asset_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package handler - -import ( - "strings" - "testing" -) - -func TestCompareIP(t *testing.T) { - testIPs := [][3]string{ - {"192.168.2.2", "192.168.2.3", "true"}, - {"192.168.2.3", "172.168.1.2", "false"}, - {"10.0.2.1", "172.168.1.2", "true"}, - {"192.168.2.1", "192.168", "false"}, - {"192.168.2.1", "", "false"}, - {"192.168.2.1", "192.168.8.2", "true"}, - } - for i := range testIPs { - result := "false" - if CompareIP(testIPs[i][0], testIPs[i][1]) { - result = "true" - } - if !strings.EqualFold(testIPs[i][2], result) { - t.Fatalf("test failed %v", testIPs[i]) - } - } -} - -func TestCompareString(t *testing.T) { - testHostname := [][3]string{ - {"ass", "bb", "true"}, - {"ass", "ba", "true"}, - {"ass", "ab", "false"}, - {"ass", "as", "false"}, - {"ass", "asw", "true"}, - {"ass", "d", "true"}, - } - for i := range testHostname { - result := "false" - if CompareString(testHostname[i][0], testHostname[i][1]) { - result = "true" - } - if !strings.EqualFold(testHostname[i][2], result) { - t.Fatalf("test failed %v", testHostname[i]) - } - } -} diff --git a/pkg/handler/banner.go b/pkg/handler/banner.go index 9b15f22c8..7142cad79 100644 --- a/pkg/handler/banner.go +++ b/pkg/handler/banner.go @@ -3,16 +3,17 @@ package handler import ( "fmt" "io" + "strings" "text/template" + "time" + "github.com/jumpserver-dev/sdk-go/model" "github.com/jumpserver/koko/pkg/i18n" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" "github.com/jumpserver/koko/pkg/logger" "github.com/jumpserver/koko/pkg/utils" ) type MenuItem struct { - id int instruct string helpText string } @@ -28,16 +29,17 @@ func (h *InteractiveHandler) displayBanner(sess io.ReadWriter, user string, term lang := i18n.NewLang(h.i18nLang) defaultTitle := utils.WrapperTitle(lang.T("Welcome to use JumpServer open source fortress system")) menu := Menu{ - {id: 1, instruct: lang.T("part IP, Hostname, Comment"), helpText: lang.T("to search login if unique")}, - {id: 2, instruct: lang.T("/ + IP, Hostname, Comment"), helpText: lang.T("to search, such as: /192.168")}, - {id: 3, instruct: "p", helpText: lang.T("display the host you have permission")}, - {id: 4, instruct: "g", helpText: lang.T("display the node that you have permission")}, - {id: 5, instruct: "d", helpText: lang.T("display the databases that you have permission")}, - {id: 6, instruct: "k", helpText: lang.T("display the kubernetes that you have permission")}, - {id: 7, instruct: "r", helpText: lang.T("refresh your assets and nodes")}, - {id: 8, instruct: "s", helpText: lang.T("Chinese-English-Japanese switch")}, - {id: 9, instruct: "h", helpText: lang.T("print help")}, - {id: 10, instruct: "q", helpText: lang.T("exit")}, + {instruct: lang.T("part IP, Hostname, Comment"), helpText: lang.T("to search login if unique")}, + {instruct: lang.T("/ + IP, Hostname, Comment"), helpText: lang.T("to search, such as: /192.168")}, + {instruct: "p", helpText: lang.T("display the assets you have permission")}, + {instruct: "g", helpText: lang.T("display the node that you have permission")}, + {instruct: "h", helpText: lang.T("display the hosts that you have permission")}, + {instruct: "d", helpText: lang.T("display the databases that you have permission")}, + {instruct: "k", helpText: lang.T("display the kubernetes that you have permission")}, + {instruct: "r", helpText: lang.T("refresh your assets and nodes")}, + {instruct: "s", helpText: lang.T("language switch")}, + {instruct: "?", helpText: lang.T("print help")}, + {instruct: "q", helpText: lang.T("exit")}, } title := defaultTitle @@ -54,12 +56,60 @@ func (h *InteractiveHandler) displayBanner(sess io.ReadWriter, user string, term return } cm := ColorMeta{GreenBoldColor: "\033[1;32m", ColorEnd: "\033[0m"} - for _, v := range menu { - line := fmt.Sprintf(lang.T("\t%d) Enter {{.GreenBoldColor}}%s{{.ColorEnd}} to %s.%s"), - v.id, v.instruct, v.helpText, "\r\n") + for i, v := range menu { + line := fmt.Sprintf(lang.T("\t%2d) Enter {{.GreenBoldColor}}%s{{.ColorEnd}} to %s.%s"), + i+1, v.instruct, v.helpText, "\r\n") tmpl := template.Must(template.New("item").Parse(line)) - if err := tmpl.Execute(sess, cm); err != nil { - logger.Error(err) + if err1 := tmpl.Execute(sess, cm); err1 != nil { + logger.Error(err1) } } } + +func (h *InteractiveHandler) displayAnnouncement(sess io.ReadWriter, setting *model.PublicSetting) { + if !setting.EnableAnnouncement { + return + } + if setting.Announcement.Subject == "" && setting.Announcement.Content == "" { + return + } + now := time.Now() + if now.Before(setting.Announcement.DateStart.Time) || now.After(setting.Announcement.DateEnd.Time) { + logger.Info("Announcement is not in the effective date range") + return + } + lang := i18n.NewLang(h.i18nLang) + greenBoldBegin := "\033[1;32m" + colorEnd := "\033[0m" + suffix := utils.CharNewLine + title := utils.CharNewLine + lang.T("Announcement: ") + setting.Announcement.Subject + suffix + content := PrettyContent(setting.Announcement.Content) + utils.CharNewLine + announcement := Announcement{ + GreenBoldColor: greenBoldBegin, + ColorEnd: colorEnd, + Title: title, + Content: content, + } + tmpl := template.Must(template.New("announcement").Parse(announcementTmpl)) + if err := tmpl.Execute(sess, announcement); err != nil { + logger.Error(err) + } + utils.IgnoreErrWriteString(sess, utils.CharNewLine) +} + +type Announcement struct { + GreenBoldColor string + ColorEnd string + Title string + Content string +} + +var announcementTmpl = `{{.GreenBoldColor}}{{.Title }}{{.ColorEnd}} +{{.GreenBoldColor}}{{.Content}}{{.ColorEnd}}` + +func PrettyContent(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\n\n", "\n") + s = strings.ReplaceAll(s, "\n", "\r\n") + return s +} diff --git a/pkg/handler/direct_handler.go b/pkg/handler/direct_handler.go index df619f55c..bce2ff99d 100644 --- a/pkg/handler/direct_handler.go +++ b/pkg/handler/direct_handler.go @@ -3,38 +3,37 @@ package handler import ( "fmt" "io" + "net" + "sort" "strconv" "strings" "github.com/gliderlabs/ssh" + "golang.org/x/term" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" "github.com/jumpserver/koko/pkg/common" "github.com/jumpserver/koko/pkg/i18n" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" "github.com/jumpserver/koko/pkg/logger" "github.com/jumpserver/koko/pkg/proxy" + "github.com/jumpserver/koko/pkg/srvconn" "github.com/jumpserver/koko/pkg/utils" ) /* -直接连接资产使用的登录名,支持使用以下四种格式: +直接连接资产使用的登录名,支持使用以下格式: -1. JMS_username@systemUser_username@asset_ip -2. JMS_username#systemUser_username#asset_ip -3. JMS_username@systemUser_uuid@asset_uuid -4. JMS_username#systemUser_uuid#asset_uuid +1. JMS_username[@mysql|ssh|redis]@account_username@asset_target +2. JMS_username[#mysql|ssh|redis]#account_username#asset_target JMS_username: JumpServer 平台上的用户名 -systemUser_username: 对应系统用户的用户名 -asset_ip: 对应资产的ip -systemUser_uuid: 对应系统用户的UUID -asset_uuid: 对应资产的UUID +account_username: 对应账号的用户名 +asset_target: 对应资产的ip 或者 id +FormatNORMAL: 使用 account_username 和 asset_ip 的登录方式,即1和2的方式 -FormatNORMAL: 使用 systemUser_username 和 asset_ip 的登录方式,即1和2的方式 - -FormatUUID: 使用 systemUser_uuid 和 asset_uuid 的登录方式,即3和4的方式 +FormatToken: 使用 JMS-{token} 的方式登陆方式 */ @@ -42,29 +41,38 @@ type FormatType int const ( FormatNORMAL FormatType = iota - FormatUUID + FormatToken ) type DirectOpt func(*directOpt) type directOpt struct { - targetAsset string - targetSystemUser string - User *model.User - terminalConf *model.TerminalConfig + formatType FormatType + protocol string + targetAccount string + User *model.User + terminalConf *model.TerminalConfig + + tokenInfo *model.ConnectToken + + sftpMode bool + + assets []model.PermAsset +} - formatType FormatType +func (d directOpt) IsTokenConnection() bool { + return d.formatType == FormatToken } -func DirectTargetAsset(targetAsset string) DirectOpt { +func DirectAssets(assets []model.PermAsset) DirectOpt { return func(opts *directOpt) { - opts.targetAsset = targetAsset + opts.assets = assets } } -func DirectTargetSystemUser(targetSystemUser string) DirectOpt { +func DirectTargetAccount(username string) DirectOpt { return func(opts *directOpt) { - opts.targetSystemUser = targetSystemUser + opts.targetAccount = username } } @@ -86,69 +94,122 @@ func DirectFormatType(format FormatType) DirectOpt { } } -func selectAssetsByDirectOpt(jmsService *service.JMService, opts *directOpt) ([]model.Asset, error) { - switch opts.formatType { - case FormatUUID: - asset, err := jmsService.GetAssetById(opts.targetAsset) - if err != nil { - return nil, err - } - return []model.Asset{asset}, nil - default: - return jmsService.GetUserPermAssetsByIP(opts.User.ID, opts.targetAsset) +func DirectConnectToken(tokenInfo *model.ConnectToken) DirectOpt { + return func(opts *directOpt) { + opts.tokenInfo = tokenInfo } } -func NewDirectHandler(sess ssh.Session, jmsService *service.JMService, optSetters ...DirectOpt) (*DirectHandler, error) { - opts := &directOpt{} - for i := range optSetters { - optSetters[i](opts) +func DirectConnectProtocol(protocol string) DirectOpt { + return func(opts *directOpt) { + opts.protocol = protocol } - selectedAssets, err := selectAssetsByDirectOpt(jmsService, opts) - if err != nil { - logger.Errorf("Get direct asset failed: %s", err) - utils.IgnoreErrWriteString(sess, i18n.T("Core API failed")) - return nil, err +} + +func DirectConnectSftpMode(sftpMode bool) DirectOpt { + return func(opts *directOpt) { + opts.sftpMode = sftpMode } - if len(selectedAssets) <= 0 { - msg := fmt.Sprintf(i18n.T("not found matched asset %s"), opts.targetAsset) - utils.IgnoreErrWriteString(sess, msg+"\r\n") - return nil, fmt.Errorf("no found matched asset: %s", opts.targetAsset) +} + +func NewDirectHandler(sess ssh.Session, jmsService *service.JMService, setters ...DirectOpt) *DirectHandler { + opts := &directOpt{} + for i := range setters { + setters[i](opts) + } + i18nLang := getUserDefaultLangCode(opts.User) + var ( + wrapperSess *WrapperSession + termVt *term.Terminal + ) + + if !opts.sftpMode { + wrapperSess = NewWrapperSession(sess) + termVt = term.NewTerminal(wrapperSess, "Opt> ") } - wrapperSess := NewWrapperSession(sess) - term := utils.NewTerminal(wrapperSess, "Opt> ") d := &DirectHandler{ - sess: sess, + opts: opts, + sess: sess, + jmsService: jmsService, + i18nLang: i18nLang, + wrapperSess: wrapperSess, - opts: opts, - jmsService: jmsService, - assets: selectedAssets, - term: term, + term: termVt, } - return d, err + return d } type DirectHandler struct { - term *utils.Terminal + term *term.Terminal sess ssh.Session wrapperSess *WrapperSession opts *directOpt jmsService *service.JMService - IsPtyStatus bool + i18nLang string - assets []model.Asset + selectAsset *model.PermAsset + selectAccount *model.PermAccount +} - selectedSystemUser *model.SystemUser +func (d *DirectHandler) NewSFTPHandler() *SftpHandler { + addr, _, _ := net.SplitHostPort(d.sess.RemoteAddr().String()) + opts := make([]srvconn.UserSftpOption, 0, 5) + opts = append(opts, srvconn.WithUser(d.opts.User)) + opts = append(opts, srvconn.WithRemoteAddr(addr)) + opts = append(opts, srvconn.WithLoginFrom(model.LoginFromSSH)) + opts = append(opts, srvconn.WithTerminalCfg(d.opts.terminalConf)) + if !d.opts.IsTokenConnection() { + opts = append(opts, srvconn.WithAssets(d.opts.assets)) + if len(d.opts.assets) == 1 { + asset := d.opts.assets[0] + if permAssetDetail, err := d.jmsService.GetUserPermAssetDetailById(d.opts.User.ID, asset.ID); err == nil { + matchedAccount := GetMatchedAccounts(permAssetDetail.PermedAccounts, d.opts.targetAccount) + if len(matchedAccount) == 1 { + selectAccount := &matchedAccount[0] + req := service.SuperConnectTokenReq{ + UserId: d.opts.User.ID, + AssetId: asset.ID, + Account: selectAccount.Alias, + Protocol: model.ProtocolSFTP, + ConnectMethod: model.ProtocolSSH, + RemoteAddr: addr, + } + if tokenInfo, err1 := d.jmsService.CreateSuperConnectToken(&req); err1 == nil { + if connectToken, err2 := d.jmsService.GetConnectTokenInfo(tokenInfo.ID, true); err2 == nil { + opts = append(opts, srvconn.WithConnectToken(&connectToken)) + opts = append(opts, srvconn.WithAssets(nil)) + } + } + } + } + } + opts = append(opts, srvconn.WithAccountUsername(d.opts.targetAccount)) + } else { + opts = append(opts, srvconn.WithConnectToken(d.opts.tokenInfo)) + } + return &SftpHandler{ + UserSftpConn: srvconn.NewUserSftpConn(d.jmsService, opts...), + recorder: proxy.GetFTPFileRecorder(d.jmsService), + } } func (d *DirectHandler) Dispatch() { _, winChan, _ := d.sess.Pty() go d.WatchWinSizeChange(winChan) + if d.opts.IsTokenConnection() { + d.LoginConnectToken(d.opts.tokenInfo) + return + } d.LoginAsset() } +func (d *DirectHandler) GetPtyWinSize() (width, height int) { + pty := d.wrapperSess.Pty() + return pty.Window.Width, pty.Window.Height +} + func (d *DirectHandler) WatchWinSizeChange(winChan <-chan ssh.Window) { defer logger.Infof("Request %s: Windows change watch close", d.wrapperSess.Uuid) for { @@ -167,20 +228,24 @@ func (d *DirectHandler) WatchWinSizeChange(winChan <-chan ssh.Window) { } func (d *DirectHandler) LoginAsset() { - switch len(d.assets) { + switch len(d.opts.assets) { case 1: - d.Proxy(d.assets[0]) + d.Proxy(d.opts.assets[0]) default: + checkChan := make(chan bool) + go d.checkMaxIdleTime(checkChan) for { - d.displayAssets(d.assets) + d.displayAssets(d.opts.assets) + checkChan <- true num, err := d.term.ReadLine() if err != nil { logger.Error(err) return } - if indexNum, err2 := strconv.Atoi(num); err2 == nil && len(d.assets) > 0 { - if indexNum > 0 && indexNum <= len(d.assets) { - d.Proxy(d.assets[indexNum-1]) + checkChan <- false + if indexNum, err2 := strconv.Atoi(num); err2 == nil && len(d.opts.assets) > 0 { + if indexNum > 0 && indexNum <= len(d.opts.assets) { + d.Proxy(d.opts.assets[indexNum-1]) return } } @@ -193,38 +258,43 @@ func (d *DirectHandler) LoginAsset() { } } -func (d *DirectHandler) selectSystemUsers(systemUsers []model.SystemUser) (model.SystemUser, bool) { - length := len(systemUsers) +func (d *DirectHandler) checkMaxIdleTime(checkChan chan bool) { + maxIdleMinutes := d.opts.terminalConf.MaxIdleTime + checkMaxIdleTime(maxIdleMinutes, d.i18nLang, d.opts.User, + d.sess, checkChan) +} + +func (d *DirectHandler) chooseAccount(permAccounts []model.PermAccount) (model.PermAccount, bool) { + lang := i18n.NewLang(d.i18nLang) + length := len(permAccounts) switch length { case 0: - warningInfo := i18n.T("No system user found.") - _, _ = io.WriteString(d.sess, warningInfo+"\n\r") - return model.SystemUser{}, false + warningInfo := lang.T("No Account found.") + _, _ = io.WriteString(d.term, warningInfo+"\n\r") + return model.PermAccount{}, false case 1: - return systemUsers[0], true + return permAccounts[0], true default: } - displaySystemUsers := selectHighestPrioritySystemUsers(systemUsers) - if len(displaySystemUsers) == 1 { - return displaySystemUsers[0], true - } + displayAccounts := model.PermAccountList(permAccounts) + sort.Sort(displayAccounts) - idLabel := i18n.T("ID") - nameLabel := i18n.T("Name") - usernameLabel := i18n.T("Username") + idLabel := lang.T("ID") + nameLabel := lang.T("Name") + usernameLabel := lang.T("Username") labels := []string{idLabel, nameLabel, usernameLabel} fields := []string{"ID", "Name", "Username"} - data := make([]map[string]string, len(displaySystemUsers)) - for i, j := range displaySystemUsers { + data := make([]map[string]string, len(displayAccounts)) + for i, j := range displayAccounts { row := make(map[string]string) row["ID"] = strconv.Itoa(i + 1) row["Name"] = j.Name row["Username"] = j.Username data[i] = row } - pty, _, _ := d.sess.Pty() + w, _ := d.GetPtyWinSize() table := common.WrapperTable{ Fields: fields, Labels: labels, @@ -234,14 +304,14 @@ func (d *DirectHandler) selectSystemUsers(systemUsers []model.SystemUser) (model "Username": {0, 10, 0}, }, Data: data, - TotalSize: pty.Window.Width, + TotalSize: w, TruncPolicy: common.TruncMiddle, } table.Initial() d.term.SetPrompt("ID> ") - selectTip := i18n.T("Tips: Enter system user ID and directly login") - backTip := i18n.T("Back: B/b") + selectTip := fmt.Sprintf(lang.T("Tips: Enter asset[%s] account ID"), d.selectAsset.String()) + backTip := lang.T("Back: B/b") for { utils.IgnoreErrWriteString(d.term, table.Display()) utils.IgnoreErrWriteString(d.term, utils.WrapperString(selectTip, utils.Green)) @@ -250,44 +320,49 @@ func (d *DirectHandler) selectSystemUsers(systemUsers []model.SystemUser) (model utils.IgnoreErrWriteString(d.term, utils.CharNewLine) line, err := d.term.ReadLine() if err != nil { - return model.SystemUser{}, false + logger.Errorf("select account err: %s", err) + return model.PermAccount{}, false } line = strings.TrimSpace(line) switch strings.ToLower(line) { case "q", "b", "quit", "exit", "back": - return model.SystemUser{}, false + logger.Info("select account cancel") + return model.PermAccount{}, false } - if num, err := strconv.Atoi(line); err == nil { - if num > 0 && num <= len(displaySystemUsers) { - return displaySystemUsers[num-1], true + if num, err2 := strconv.Atoi(line); err2 == nil { + if num > 0 && num <= len(displayAccounts) { + return displayAccounts[num-1], true } + } else { + logger.Errorf("select account not right number %s", line) + return model.PermAccount{}, false } } } -func (d *DirectHandler) displayAssets(assets []model.Asset) { +func (d *DirectHandler) displayAssets(assets []model.PermAsset) { assetListSortBy := d.opts.terminalConf.AssetListSortBy - model.AssetList(assets).SortBy(assetListSortBy) - - term := d.term + model.PermAssetList(assets).SortBy(assetListSortBy) - idLabel := i18n.T("ID") - hostLabel := i18n.T("Hostname") - ipLabel := i18n.T("IP") - commentLabel := i18n.T("Comment") + vt := d.term + lang := i18n.NewLang(d.i18nLang) + idLabel := lang.T("ID") + hostLabel := lang.T("Hostname") + ipLabel := lang.T("Address") + commentLabel := lang.T("Comment") Labels := []string{idLabel, hostLabel, ipLabel, commentLabel} - fields := []string{"ID", "Hostname", "IP", "Comment"} + fields := []string{"ID", "Hostname", "Address", "Comment"} data := make([]map[string]string, len(assets)) for i := range assets { row := make(map[string]string) row["ID"] = strconv.Itoa(i + 1) - row["Hostname"] = assets[i].Hostname - row["IP"] = assets[i].IP + row["Hostname"] = assets[i].Name + row["Address"] = assets[i].Address row["Comment"] = joinMultiLineString(assets[i].Comment) data[i] = row } - w, _ := d.term.GetSize() + w, _ := d.GetPtyWinSize() table := common.WrapperTable{ Fields: fields, @@ -295,7 +370,7 @@ func (d *DirectHandler) displayAssets(assets []model.Asset) { FieldsSize: map[string][3]int{ "ID": {0, 0, 5}, "Hostname": {0, 40, 0}, - "IP": {0, 15, 40}, + "Address": {0, 15, 40}, "Comment": {0, 0, 0}, }, Data: data, @@ -303,75 +378,114 @@ func (d *DirectHandler) displayAssets(assets []model.Asset) { TruncPolicy: common.TruncMiddle, } table.Initial() - loginTip := i18n.T("select one asset to login") - - _, _ = term.Write([]byte(utils.CharClear)) - _, _ = term.Write([]byte(table.Display())) - utils.IgnoreErrWriteString(term, utils.WrapperString(loginTip, utils.Green)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) - utils.IgnoreErrWriteString(term, utils.WrapperString(d.opts.targetAsset, utils.Green)) - utils.IgnoreErrWriteString(term, utils.CharNewLine) + loginTip := lang.T("select one asset to login") + + _, _ = vt.Write([]byte(utils.CharClear)) + _, _ = vt.Write([]byte(table.Display())) + utils.IgnoreErrWriteString(vt, utils.WrapperString(loginTip, utils.Green)) + utils.IgnoreErrWriteString(vt, utils.CharNewLine) } -func (d *DirectHandler) Proxy(asset model.Asset) { - matched := d.getMatchedSystemUsers(asset) +func (d *DirectHandler) Proxy(asset model.PermAsset) { + d.selectAsset = &asset + lang := i18n.NewLang(d.i18nLang) + permAssetDetail, err := d.jmsService.GetUserPermAssetDetailById(d.opts.User.ID, asset.ID) + if err != nil { + logger.Errorf("Get account failed: %s", err) + utils.IgnoreErrWriteString(d.term, lang.T("Core API failed")) + return + } + matched := GetMatchedAccounts(permAssetDetail.PermedAccounts, d.opts.targetAccount) if len(matched) == 0 { - msg := fmt.Sprintf(i18n.T("not found matched username %s"), d.opts.targetSystemUser) + msg := fmt.Sprintf(lang.T("not found matched username %s"), d.opts.targetAccount) utils.IgnoreErrWriteString(d.term, msg+"\r\n") - logger.Errorf("Get systemUser failed: %s", msg) + logger.Errorf("Get account failed: %s", msg) return } - selectSys, ok := d.selectSystemUsers(matched) + selectAccount, ok := d.chooseAccount(matched) if !ok { - logger.Info("Do not select system user") + logger.Info("Do not select account") return } - d.selectedSystemUser = &selectSys - srv, err := proxy.NewServer(d.wrapperSess, - d.jmsService, - proxy.ConnectProtocolType(d.selectedSystemUser.Protocol), - proxy.ConnectUser(d.opts.User), - proxy.ConnectAsset(&asset), - proxy.ConnectSystemUser(d.selectedSystemUser), - ) - if err != nil { - logger.Error(err) - return + d.selectAccount = &selectAccount + protocol := d.opts.protocol + req := service.SuperConnectTokenReq{ + UserId: d.opts.User.ID, + AssetId: asset.ID, + Account: selectAccount.Alias, + Protocol: protocol, + ConnectMethod: model.ProtocolSSH, + RemoteAddr: d.wrapperSess.RemoteAddr(), } - srv.Proxy() - logger.Infof("Request %s: asset %s proxy end", d.wrapperSess.Uuid, asset.Hostname) - -} - -func (d *DirectHandler) getMatchedSystemUsers(asset model.Asset) []model.SystemUser { - switch d.opts.formatType { - case FormatUUID: - systemUser, err := d.jmsService.GetSystemUserById(d.opts.targetSystemUser) - if err != nil { - logger.Errorf("Get systemUser failed: %s", err) - utils.IgnoreErrWriteString(d.term, i18n.T("Core API failed")) - return nil - } - return []model.SystemUser{systemUser} - default: - systemUsers, err := d.jmsService.GetSystemUsersByUserIdAndAssetId(d.opts.User.ID, asset.ID) - if err != nil { - logger.Errorf("Get systemUser failed: %s", err) - utils.IgnoreErrWriteString(d.term, i18n.T("Core API failed")) - return nil + if selectAccount.IsInputUser() { + inputUsername, err1 := GetInputUsername(d.wrapperSess) + if err1 != nil { + logger.Errorf("Get input username err: %s", err1) + return } - matched := make([]model.SystemUser, 0, len(systemUsers)) - for i := range systemUsers { - compareUsername := systemUsers[i].Username + req.InputUsername = inputUsername + } - if systemUsers[i].UsernameSameWithUser { - // 此为动态系统用户,系统用户名和登录用户名相同 - compareUsername = d.opts.User.Username + tokenInfo, err := d.jmsService.CreateSuperConnectToken(&req) + if err != nil { + if tokenInfo.Code == "" { + logger.Errorf("Create connect token and auth info failed: %s", err) + utils.IgnoreErrWriteString(d.term, lang.T("Core API failed")) + return + } + switch tokenInfo.Code { + case model.ACLReject: + logger.Errorf("Create connect token and auth info failed: %s", tokenInfo.Detail) + utils.IgnoreErrWriteString(d.term, lang.T("ACL reject")) + utils.IgnoreErrWriteString(d.term, utils.CharNewLine) + return + case model.ACLFaceVerify: + // todo: 需要人脸验证 后续需要发站内信通知用户,并且等待用户人脸验证通过 + logger.Errorf("Create connect token and auth info failed: %s %s", tokenInfo.Code, tokenInfo.Detail) + msg := lang.T("Face verification is not supported yet. Please use the WebTerminal to connect the asset.") + utils.IgnoreErrWriteString(d.term, msg) + utils.IgnoreErrWriteString(d.term, utils.CharNewLine) + return + case model.ACLReview: + reviewHandler := LoginReviewHandler{ + readWriter: d.wrapperSess, + i18nLang: d.i18nLang, + user: d.opts.User, + jmsService: d.jmsService, + req: &req, + } + ok2, err2 := reviewHandler.WaitReview(d.sess.Context()) + if err2 != nil { + logger.Errorf("Wait login review failed: %s", err) + utils.IgnoreErrWriteString(d.term, lang.T("Core API failed")) + return } - if compareUsername == d.opts.targetSystemUser { - matched = append(matched, systemUsers[i]) + if !ok2 { + logger.Error("Wait login review failed") + return } + tokenInfo = reviewHandler.tokenInfo + default: + logger.Errorf("Create connect token and auth info failed: %s", tokenInfo.Detail) + return + } + } + connectToken, err := d.jmsService.GetConnectTokenInfo(tokenInfo.ID, true) + if err != nil { + logger.Errorf("Create connect token and auth info failed: %s", err) + utils.IgnoreErrWriteString(d.term, lang.T("get connect token err")) + return + } + d.LoginConnectToken(&connectToken) +} + +func GetMatchedAccounts(accounts []model.PermAccount, username string) []model.PermAccount { + matched := make([]model.PermAccount, 0, len(accounts)) + for i := range accounts { + account := accounts[i] + if account.Username == username { + matched = append(matched, account) } - return matched } + return matched } diff --git a/pkg/handler/direct_token_handler.go b/pkg/handler/direct_token_handler.go new file mode 100644 index 000000000..9d6e27846 --- /dev/null +++ b/pkg/handler/direct_token_handler.go @@ -0,0 +1,23 @@ +package handler + +import ( + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/proxy" +) + +func (d *DirectHandler) LoginConnectToken(connectToken *model.ConnectToken) { + i18nLang := d.i18nLang + proxyOpts := make([]proxy.ConnectionOption, 0, 3) + proxyOpts = append(proxyOpts, proxy.ConnectTokenAuthInfo(connectToken)) + proxyOpts = append(proxyOpts, proxy.ConnectI18nLang(i18nLang)) + srv, err := proxy.NewServer(d.wrapperSess, d.jmsService, proxyOpts...) + if err != nil { + logger.Errorf("create proxy server err: %s", err) + return + } + srv.Proxy() + logger.Infof("Request %s: token %s asset %s proxy end", d.wrapperSess.Uuid, + connectToken.Id, connectToken.Asset.String()) + +} diff --git a/pkg/handler/dispatch.go b/pkg/handler/dispatch.go index 43b68e1c8..e2eca823e 100644 --- a/pkg/handler/dispatch.go +++ b/pkg/handler/dispatch.go @@ -5,21 +5,26 @@ import ( "strconv" "strings" - "github.com/jumpserver/koko/pkg/exchange" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver/koko/pkg/common" "github.com/jumpserver/koko/pkg/i18n" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/utils" ) func (h *InteractiveHandler) Dispatch() { defer logger.Infof("Request %s: User %s stop interactive", h.sess.ID(), h.user.Name) var initialed bool + checkChan := make(chan bool) + go h.checkMaxIdleTime(checkChan) for { + checkChan <- true line, err := h.term.ReadLine() if err != nil { - logger.Debugf("User %s close connect", h.user.Name) + logger.Debugf("User %s close connect %s", h.user.Name, err) break } + checkChan <- false line = strings.TrimSpace(line) if len(line) == 0 { // 当 只是回车 空字符单独处理 @@ -43,6 +48,10 @@ func (h *InteractiveHandler) Dispatch() { case "b": h.selectHandler.MovePrePage() continue + case "h": + h.selectHandler.SetSelectType(TypeHost) + h.selectHandler.Search("") + continue case "d": h.selectHandler.SetSelectType(TypeDatabase) h.selectHandler.Search("") @@ -54,7 +63,7 @@ func (h *InteractiveHandler) Dispatch() { h.wg.Wait() // 等待node加载完成 h.displayNodeTree(h.nodes) continue - case "h": + case "?": h.displayHelp() initialed = false continue @@ -99,73 +108,98 @@ func (h *InteractiveHandler) Dispatch() { continue } } - case strings.Index(line, "join") == 0: - roomID := strings.TrimSpace(strings.TrimPrefix(line, "join")) - JoinRoom(h, roomID) - continue } } h.selectHandler.SearchOrProxy(line) } } +func (h *InteractiveHandler) checkMaxIdleTime(checkChan <-chan bool) { + maxIdleMinutes := h.terminalConf.MaxIdleTime + checkMaxIdleTime(maxIdleMinutes, h.i18nLang, h.user, h.sess.Sess, checkChan) +} + func (h *InteractiveHandler) ChangeLang() { lang := i18n.NewLang(h.i18nLang) i18nLang := h.i18nLang - switch lang { - case i18n.EN: - i18nLang = i18n.ZH.String() - case i18n.ZH: - i18nLang = i18n.JA.String() - case i18n.JA: - i18nLang = i18n.EN.String() + allLangCodes := i18n.AllCodes + langs := i18n.AllLangCodesStr + idLabel := lang.T("ID") + nameLabel := lang.T("Name") + labels := []string{idLabel, nameLabel} + fields := []string{"ID", "Name"} + data := make([]map[string]string, len(langs)) + for i, j := range langs { + row := make(map[string]string) + row["ID"] = strconv.Itoa(i + 1) + row["Name"] = j + data[i] = row + } + w, _ := h.GetPtySize() + table := common.WrapperTable{ + Fields: fields, + Labels: labels, + FieldsSize: map[string][3]int{ + "ID": {0, 0, 5}, + "Name": {0, 8, 0}, + }, + Data: data, + TotalSize: w, + TruncPolicy: common.TruncMiddle, + } + table.Initial() + + h.term.SetPrompt("ID> ") + selectTip := lang.T("Tips: switch language by ID (Current session only)") + setLangTip := lang.T("Tips: To set a default language, go to Personal Settings → Preferences on Web") + backTip := lang.T("Back: B/b") + for i := 0; i < 3; i++ { + utils.IgnoreErrWriteString(h.term, table.Display()) + utils.IgnoreErrWriteString(h.term, utils.WrapperString(selectTip, utils.Green)) + utils.IgnoreErrWriteString(h.term, utils.CharNewLine) + utils.IgnoreErrWriteString(h.term, utils.WrapperString(setLangTip, utils.Green)) + utils.IgnoreErrWriteString(h.term, utils.CharNewLine) + utils.IgnoreErrWriteString(h.term, utils.WrapperString(backTip, utils.Green)) + utils.IgnoreErrWriteString(h.term, utils.CharNewLine) + line, err := h.term.ReadLine() + if err != nil { + logger.Errorf("User %s switch language err %s", h.user.Name, err) + break + } + line = strings.TrimSpace(line) + switch strings.ToLower(line) { + case "q", "b", "quit", "exit", "back": + logger.Infof("User %s switch language exit", h.user.Name) + return + case "": + continue + } + if num, err2 := strconv.Atoi(line); err2 == nil { + if num > 0 && num <= len(allLangCodes) { + lang = allLangCodes[num-1] + i18nLang = lang.String() + break + } else { + utils.IgnoreErrWriteString(h.term, utils.WrapperString(lang.T("Invalid ID"), utils.Red)) + utils.IgnoreErrWriteString(h.term, utils.CharNewLine) + } + } + } + if i18nLang != h.i18nLang { + utils.IgnoreErrWriteString(h.term, utils.WrapperString(lang.T("Switch language successfully"), utils.Green)) + utils.IgnoreErrWriteString(h.term, utils.CharNewLine) } - userLangGlobalStore.Store(h.user.ID, i18nLang) h.i18nLang = i18nLang } func (h *InteractiveHandler) displayNodeTree(nodes model.NodeList) { lang := i18n.NewLang(h.i18nLang) - tree := ConstructNodeTree(nodes) + tree, newNodes := ConstructNodeTree(nodes) + h.nodes = newNodes _, _ = io.WriteString(h.term, "\n\r"+lang.T("Node: [ ID.Name(Asset amount) ]")) _, _ = io.WriteString(h.term, tree.String()) _, err := io.WriteString(h.term, lang.T("Tips: Enter g+NodeID to display the host under the node, such as g1")+"\n\r") if err != nil { - logger.Info("displayAssetNodes err:", err) - } -} - -func (h *InteractiveHandler) CheckShareRoomWritePerm(shareRoomID string) bool { - // todo: check current user has pem to write - return false -} - -func (h *InteractiveHandler) CheckShareRoomReadPerm(shareRoomID string) bool { - ret, err := h.jmsService.ValidateJoinSessionPermission(h.user.ID, shareRoomID) - if err != nil { - logger.Error(err) - return false - } - return ret.Ok - -} - -func JoinRoom(h *InteractiveHandler, roomId string) { - if room := exchange.GetRoom(roomId); room != nil { - conn := exchange.WrapperUserCon(h.sess) - room.Subscribe(conn) - defer room.UnSubscribe(conn) - for { - buf := make([]byte, 1024) - nr, err := h.sess.Read(buf) - if nr > 0 && h.CheckShareRoomWritePerm(roomId) { - room.Receive(&exchange.RoomMessage{ - Event: exchange.DataEvent, Body: buf[:nr]}) - } - if err != nil { - break - } - } - logger.Infof("Conn[%s] user read end", h.sess.Uuid) + logger.Errorf("displayAssetNodes err: %s", err) } } diff --git a/pkg/handler/interactive.go b/pkg/handler/interactive.go index 9717e9583..a9397437b 100644 --- a/pkg/handler/interactive.go +++ b/pkg/handler/interactive.go @@ -11,39 +11,79 @@ import ( "github.com/gliderlabs/ssh" "github.com/xlab/treeprint" + "golang.org/x/term" + + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" "github.com/jumpserver/koko/pkg/common" "github.com/jumpserver/koko/pkg/config" "github.com/jumpserver/koko/pkg/i18n" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" "github.com/jumpserver/koko/pkg/logger" "github.com/jumpserver/koko/pkg/utils" ) -func NewInteractiveHandler(sess ssh.Session, user *model.User, jmsService *service.JMService, termConfig model.TerminalConfig) *InteractiveHandler { +func NewInteractiveHandler(sess ssh.Session, user *model.User, jmsService *service.JMService, + termConfig model.TerminalConfig) *InteractiveHandler { wrapperSess := NewWrapperSession(sess) - term := utils.NewTerminal(wrapperSess, "Opt> ") + publicSetting, err := jmsService.GetPublicSetting() + if err != nil { + logger.Errorf("Get public setting error: %s", err) + } + vt := term.NewTerminal(wrapperSess, "Opt> ") handler := &InteractiveHandler{ sess: wrapperSess, user: user, - term: term, + term: vt, jmsService: jmsService, terminalConf: &termConfig, + + publicSetting: &publicSetting, } handler.Initial() return handler } -var ( - // 全局永久缓存 ssh 登录用户切换的语言 - userLangGlobalStore = sync.Map{} -) +func getUserDefaultLangCode(user *model.User) string { + if user.Language != "" { + return user.Language + } + return config.GetConf().LanguageCode +} + +func checkMaxIdleTime(maxIdleMinutes int, langCode string, user *model.User, sess ssh.Session, checkChan <-chan bool) { + maxIdleTime := time.Duration(maxIdleMinutes) * time.Minute + tick := time.NewTicker(maxIdleTime) + defer tick.Stop() + checkStatus := true + for { + select { + case <-tick.C: + if checkStatus { + lang := i18n.NewLang(langCode) + msg := fmt.Sprintf(lang.T("Connect idle more than %d minutes, disconnect"), maxIdleMinutes) + _, _ = io.WriteString(sess, "\r\n"+msg+"\r\n") + _ = sess.Close() + logger.Infof("User %s input idle more than %d minutes", user.Name, maxIdleMinutes) + } + case <-sess.Context().Done(): + logger.Infof("Stop checking user %s input idle time", user.Name) + return + case checkStatus = <-checkChan: + if !checkStatus { + logger.Debugf("Stop checking user %s idle time if more than %d minutes", user.Name, maxIdleMinutes) + continue + } + tick.Reset(maxIdleTime) + logger.Debugf("Start checking user %s idle time if more than %d minutes", user.Name, maxIdleMinutes) + } + } +} type InteractiveHandler struct { sess *WrapperSession user *model.User - term *utils.Terminal + term *term.Terminal selectHandler *UserSelectHandler @@ -57,6 +97,8 @@ type InteractiveHandler struct { terminalConf *model.TerminalConfig + publicSetting *model.PublicSetting + i18nLang string } @@ -66,15 +108,19 @@ func (h *InteractiveHandler) Initial() { go h.keepSessionAlive(time.Duration(conf.ClientAliveInterval) * time.Second) } h.assetLoadPolicy = strings.ToLower(conf.AssetLoadPolicy) - h.i18nLang = conf.LanguageCode - if langCode, ok := userLangGlobalStore.Load(h.user.ID); ok { - h.i18nLang = langCode.(string) - } + h.i18nLang = getUserDefaultLangCode(h.user) h.displayHelp() + hiddenFields := make(map[string]struct{}) + for i := range conf.HiddenFields { + name := strings.TrimSpace(strings.ToLower(conf.HiddenFields[i])) + hiddenFields[name] = struct{}{} + } h.selectHandler = &UserSelectHandler{ user: h.user, h: h, pageInfo: &pageInfo{}, + + hiddenFields: hiddenFields, } switch h.assetLoadPolicy { case "all": @@ -88,6 +134,12 @@ func (h *InteractiveHandler) Initial() { } +func (h *InteractiveHandler) GetPtySize() (int, int) { + // todo: 优化直接存储 + pty := h.sess.Pty() + return pty.Window.Width, pty.Window.Height +} + func (h *InteractiveHandler) firstLoadData() { h.wg.Add(1) go func() { @@ -99,6 +151,7 @@ func (h *InteractiveHandler) firstLoadData() { func (h *InteractiveHandler) displayHelp() { h.term.SetPrompt("Opt> ") h.displayBanner(h.sess, h.user.Name, h.terminalConf) + h.displayAnnouncement(h.sess, h.publicSetting) } func (h *InteractiveHandler) WatchWinSizeChange(winChan <-chan ssh.Window) { @@ -137,39 +190,37 @@ func (h *InteractiveHandler) keepSessionAlive(keepAliveTime time.Duration) { } } -func (h *InteractiveHandler) chooseSystemUser(systemUsers []model.SystemUser) (systemUser model.SystemUser, ok bool) { - - length := len(systemUsers) +func (h *InteractiveHandler) chooseAccount(permAccounts []model.PermAccount) (model.PermAccount, bool) { + lang := i18n.NewLang(h.i18nLang) + length := len(permAccounts) switch length { case 0: - warningInfo := i18n.T("No system user found.") + warningInfo := lang.T("No account found.") _, _ = io.WriteString(h.term, warningInfo+"\n\r") - return model.SystemUser{}, false + return model.PermAccount{}, false case 1: - return systemUsers[0], true + return permAccounts[0], true default: } - displaySystemUsers := selectHighestPrioritySystemUsers(systemUsers) - if len(displaySystemUsers) == 1 { - return displaySystemUsers[0], true - } + displayAccounts := model.PermAccountList(permAccounts) + sort.Sort(displayAccounts) - idLabel := i18n.T("ID") - nameLabel := i18n.T("Name") - usernameLabel := i18n.T("Username") + idLabel := lang.T("ID") + nameLabel := lang.T("Name") + usernameLabel := lang.T("Username") labels := []string{idLabel, nameLabel, usernameLabel} fields := []string{"ID", "Name", "Username"} - data := make([]map[string]string, len(displaySystemUsers)) - for i, j := range displaySystemUsers { + data := make([]map[string]string, len(displayAccounts)) + for i, j := range displayAccounts { row := make(map[string]string) row["ID"] = strconv.Itoa(i + 1) row["Name"] = j.Name row["Username"] = j.Username data[i] = row } - w, _ := h.term.GetSize() + w, _ := h.GetPtySize() table := common.WrapperTable{ Fields: fields, Labels: labels, @@ -183,11 +234,12 @@ func (h *InteractiveHandler) chooseSystemUser(systemUsers []model.SystemUser) (s TruncPolicy: common.TruncMiddle, } table.Initial() + userHandler := h.selectHandler h.term.SetPrompt("ID> ") - selectTip := i18n.T("Tips: Enter system user ID and directly login") - backTip := i18n.T("Back: B/b") - for { + selectTip := fmt.Sprintf(lang.T("Tips: Enter asset[%s] account ID"), userHandler.selectedAsset.String()) + backTip := lang.T("Back: B/b") + for i := 0; i < 3; i++ { utils.IgnoreErrWriteString(h.term, table.Display()) utils.IgnoreErrWriteString(h.term, utils.WrapperString(selectTip, utils.Green)) utils.IgnoreErrWriteString(h.term, utils.CharNewLine) @@ -195,19 +247,103 @@ func (h *InteractiveHandler) chooseSystemUser(systemUsers []model.SystemUser) (s utils.IgnoreErrWriteString(h.term, utils.CharNewLine) line, err := h.term.ReadLine() if err != nil { - return + logger.Errorf("select account err: %s", err) + return model.PermAccount{}, false } line = strings.TrimSpace(line) switch strings.ToLower(line) { case "q", "b", "quit", "exit", "back": - return + logger.Info("select account cancel") + return model.PermAccount{}, false + case "": + continue } - if num, err := strconv.Atoi(line); err == nil { - if num > 0 && num <= len(displaySystemUsers) { - return displaySystemUsers[num-1], true + if num, err2 := strconv.Atoi(line); err2 == nil { + if num > 0 && num <= len(displayAccounts) { + return displayAccounts[num-1], true } } } + maxTryTip := lang.T("Select account exceed max retry times.") + utils.IgnoreErrWriteString(h.term, utils.WrapperWarn(maxTryTip)) + utils.IgnoreErrWriteString(h.term, utils.CharNewLine) + return model.PermAccount{}, false +} + +func (h *InteractiveHandler) chooseAssetProtocol(protocols []string) (string, bool) { + lang := i18n.NewLang(h.i18nLang) + length := len(protocols) + switch length { + case 0: + warningInfo := lang.T("No protocol found.") + _, _ = io.WriteString(h.term, warningInfo+"\n\r") + return "", false + case 1: + return protocols[0], true + default: + } + displayProtocols := protocols + + idLabel := lang.T("ID") + nameLabel := lang.T("Protocol") + + labels := []string{idLabel, nameLabel} + fields := []string{"ID", "Protocol"} + + data := make([]map[string]string, len(displayProtocols)) + for i := range displayProtocols { + row := make(map[string]string) + row["ID"] = strconv.Itoa(i + 1) + row["Protocol"] = displayProtocols[i] + data[i] = row + } + w, _ := h.GetPtySize() + table := common.WrapperTable{ + Fields: fields, + Labels: labels, + FieldsSize: map[string][3]int{ + "ID": {0, 0, 5}, + "Protocol": {0, 8, 0}, + }, + Data: data, + TotalSize: w, + TruncPolicy: common.TruncMiddle, + } + table.Initial() + + h.term.SetPrompt("ID> ") + selectTip := lang.T("Tips: Enter protocol ID") + backTip := lang.T("Back: B/b") + for i := 0; i < 3; i++ { + utils.IgnoreErrWriteString(h.term, table.Display()) + utils.IgnoreErrWriteString(h.term, utils.WrapperString(selectTip, utils.Green)) + utils.IgnoreErrWriteString(h.term, utils.CharNewLine) + utils.IgnoreErrWriteString(h.term, utils.WrapperString(backTip, utils.Green)) + utils.IgnoreErrWriteString(h.term, utils.CharNewLine) + line, err := h.term.ReadLine() + if err != nil { + logger.Errorf("select protocol err: %s", err) + return "", false + } + line = strings.TrimSpace(line) + switch strings.ToLower(line) { + case "q", "b", "quit", "exit", "back": + logger.Info("select account cancel") + return "", false + case "": + continue + } + if num, err2 := strconv.Atoi(line); err2 == nil { + if num > 0 && num <= len(displayProtocols) { + return displayProtocols[num-1], true + } + } + } + maxTryTip := lang.T("Select protocol exceed max retry times.") + utils.IgnoreErrWriteString(h.term, utils.WrapperWarn(maxTryTip)) + utils.IgnoreErrWriteString(h.term, utils.CharNewLine) + time.Sleep(time.Millisecond * 500) + return "", false } func (h *InteractiveHandler) refreshAssetsAndNodesData() { @@ -239,7 +375,8 @@ func (h *InteractiveHandler) refreshAssetsAndNodesData() { h.terminalConf = &tConfig }() h.wg.Wait() - _, err := io.WriteString(h.term, i18n.T("Refresh done")+"\n\r") + lang := i18n.NewLang(h.i18nLang) + _, err := io.WriteString(h.term, lang.T("Refresh done")+"\n\r") if err != nil { logger.Error("refresh Assets Nodes err:", err) } @@ -254,31 +391,13 @@ func (h *InteractiveHandler) loadUserNodes() { h.nodes = nodes } -func selectHighestPrioritySystemUsers(systemUsers []model.SystemUser) []model.SystemUser { - length := len(systemUsers) - if length == 0 { - return systemUsers - } - var result = make([]model.SystemUser, 0) - model.SortSystemUserByPriority(systemUsers) - - highestPriority := systemUsers[0].Priority - result = append(result, systemUsers[0]) - for i := 1; i < length; i++ { - if highestPriority == systemUsers[i].Priority { - result = append(result, systemUsers[i]) - } - } - return result -} - -func getPageSize(term *utils.Terminal, termConf *model.TerminalConfig) int { +func getPageSize(h *InteractiveHandler, termConf *model.TerminalConfig) int { var ( pageSize int minHeight = 8 // 分页显示的最小高度 ) - _, height := term.GetSize() + _, height := h.GetPtySize() AssetListPageSize := termConf.AssetListPageSize switch AssetListPageSize { @@ -299,27 +418,26 @@ func getPageSize(term *utils.Terminal, termConf *model.TerminalConfig) int { return pageSize } -func ConstructNodeTree(assetNodes []model.Node) treeprint.Tree { +func ConstructNodeTree(assetNodes []model.Node) (treeprint.Tree, []model.Node) { model.SortNodesByKey(assetNodes) - keyIndexMap := make(map[string]int) - for index := range assetNodes { - keyIndexMap[assetNodes[index].Key] = index - } rootTree := treeprint.New() - constructDisplayTree(rootTree, convertToDisplayTrees(assetNodes), keyIndexMap) - return rootTree + newNodes := make([]model.Node, 0, len(assetNodes)) + newNodes = constructDisplayTree(rootTree, convertToDisplayTrees(assetNodes), newNodes) + return rootTree, newNodes } -func constructDisplayTree(tree treeprint.Tree, rootNodes []*displayTree, keyMap map[string]int) { +func constructDisplayTree(tree treeprint.Tree, rootNodes []*displayTree, newNodes []model.Node) []model.Node { for i := 0; i < len(rootNodes); i++ { subTree := tree.AddBranch(fmt.Sprintf("%d.%s(%s)", - keyMap[rootNodes[i].Key]+1, rootNodes[i].node.Name, + len(newNodes)+1, rootNodes[i].node.Name, strconv.Itoa(rootNodes[i].node.AssetsAmount))) + newNodes = append(newNodes, rootNodes[i].node) if len(rootNodes[i].subTrees) > 0 { sort.Sort(nodeTrees(rootNodes[i].subTrees)) - constructDisplayTree(subTree, rootNodes[i].subTrees, keyMap) + newNodes = constructDisplayTree(subTree, rootNodes[i].subTrees, newNodes) } } + return newNodes } func convertToDisplayTrees(assetNodes []model.Node) []*displayTree { diff --git a/pkg/handler/login_confirm.go b/pkg/handler/login_confirm.go new file mode 100644 index 000000000..d970c6032 --- /dev/null +++ b/pkg/handler/login_confirm.go @@ -0,0 +1,150 @@ +package handler + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "golang.org/x/term" + + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" + "github.com/jumpserver/koko/pkg/auth" + "github.com/jumpserver/koko/pkg/i18n" + "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/utils" +) + +// 校验用户登录资产是否需要复核 + +type LoginReviewHandler struct { + i18nLang string + readWriter io.ReadWriteCloser + jmsService *service.JMService + user *model.User + req *service.SuperConnectTokenReq + + tokenInfo model.ConnectTokenInfo +} + +func (l *LoginReviewHandler) GetTokenInfo() model.ConnectTokenInfo { + return l.tokenInfo +} + +func (l *LoginReviewHandler) WaitReview(ctx context.Context) (bool, error) { + lang := i18n.NewLang(l.i18nLang) + vt := term.NewTerminal(l.readWriter, lang.T("Need ACL review, continue? (y/n): ")) + utils.IgnoreErrWriteString(vt, utils.CharNewLine) + count := 0 + for { + okMsg, err2 := vt.ReadLine() + if err2 != nil { + logger.Errorf("Wait confirm user readLine exit: %s", err2.Error()) + return false, err2 + } + count++ + if okMsg == "" && count < 3 { + continue + } + if count >= 3 || strings.ToLower(okMsg) != "y" { + logger.Info("ACL review required cancel") + utils.IgnoreErrWriteString(vt, lang.T("Cancel to login asset or max 3 retry")) + utils.IgnoreErrWriteString(vt, utils.CharNewLine) + return false, nil + } + if strings.ToLower(okMsg) == "y" { + break + } + + } + + l.req.Params = map[string]string{"create_ticket": "true"} + tokenInfo, err := l.jmsService.CreateSuperConnectToken(l.req) + if err != nil { + logger.Errorf("Create connect token and auth info failed: %s", err) + utils.IgnoreErrWriteString(vt, lang.T("Core API failed")) + return false, err + } + l.tokenInfo = tokenInfo + srv := auth.NewLoginReview(l.jmsService, + auth.WithReviewUser(l.user), + auth.WithReviewTokenInfo(&tokenInfo)) + return l.WaitTicketReview(ctx, &srv) +} + +func (l *LoginReviewHandler) WaitTicketReview(ctx context.Context, srv *auth.LoginReviewService) (bool, error) { + lang := i18n.NewLang(l.i18nLang) + ctx, cancelFunc := context.WithCancel(ctx) + vt := term.NewTerminal(l.readWriter, " ") + go func() { + defer cancelFunc() + for { + line, err := vt.ReadLine() + if err != nil { + logger.Errorf("Wait confirm user readLine exit: %s", err.Error()) + return + } + switch line { + case "quit", "q": + logger.Infof("User %s quit confirm", l.user.String()) + return + } + } + }() + reviewers := srv.GetReviewers() + detailURL := srv.GetTicketUrl() + titleMsg := lang.T("Need ticket confirm to login, already send email to the reviewers") + reviewersMsg := fmt.Sprintf(lang.T("Ticket Reviewers: %s"), strings.Join(reviewers, ", ")) + detailURLMsg := fmt.Sprintf(lang.T("Could copy website URL to notify reviewers: %s"), detailURL) + waitMsg := lang.T("Please waiting for the reviewers to confirm, enter q to exit. ") + utils.IgnoreErrWriteString(vt, titleMsg) + utils.IgnoreErrWriteString(vt, utils.CharNewLine) + utils.IgnoreErrWriteString(vt, reviewersMsg) + utils.IgnoreErrWriteString(vt, utils.CharNewLine) + utils.IgnoreErrWriteString(vt, detailURLMsg) + utils.IgnoreErrWriteString(vt, utils.CharNewLine) + go func() { + delay := 0 + for { + select { + case <-ctx.Done(): + + return + default: + delayS := fmt.Sprintf("%ds", delay) + data := strings.Repeat("\x08", len(delayS)+len(waitMsg)) + waitMsg + delayS + utils.IgnoreErrWriteString(vt, data) + time.Sleep(time.Second) + delay += 1 + } + } + }() + + status := srv.WaitLoginConfirm(ctx) + cancelFunc() + l.readWriter.Close() + processor := srv.GetProcessor() + var success bool + statusMsg := lang.T("Unknown status") + switch status { + case auth.StatusApprove: + // 审核通过 + formatMsg := lang.T("%s approved") + statusMsg = utils.WrapperString(fmt.Sprintf(formatMsg, processor), utils.Green) + success = true + case auth.StatusReject: + // 审核未通过 + formatMsg := lang.T("%s rejected") + statusMsg = utils.WrapperString(fmt.Sprintf(formatMsg, processor), utils.Red) + case auth.StatusCancel: + // 审核取消 + statusMsg = utils.WrapperString(lang.T("Cancel confirm"), utils.Red) + } + logger.Infof("User %s Login Confirm result: %s", l.user.String(), statusMsg) + utils.IgnoreErrWriteString(vt, utils.CharNewLine) + utils.IgnoreErrWriteString(vt, statusMsg) + utils.IgnoreErrWriteString(vt, utils.CharNewLine) + return success, nil +} diff --git a/pkg/handler/select_handler.go b/pkg/handler/select_handler.go index 6ae402e26..3b8c10de5 100644 --- a/pkg/handler/select_handler.go +++ b/pkg/handler/select_handler.go @@ -6,9 +6,10 @@ import ( "strconv" "strings" + "github.com/jumpserver-dev/sdk-go/model" "github.com/jumpserver/koko/pkg/i18n" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/srvconn" "github.com/jumpserver/koko/pkg/utils" ) @@ -19,6 +20,14 @@ const ( loadingFromRemote dataSource = "remote" ) +type prompt string + +const ( + promptHost prompt = "[Host]> " + promptK8s prompt = "[K8S]> " + promptDatabase prompt = "[DB]> " +) + type selectType int const ( @@ -26,6 +35,7 @@ const ( TypeNodeAsset TypeK8s TypeDatabase + TypeHost ) type UserSelectHandler struct { @@ -34,17 +44,23 @@ type UserSelectHandler struct { loadingPolicy dataSource currentType selectType + promptStr prompt searchKeys []string hasPre bool hasNext bool - allLocalData []map[string]interface{} + allLocalData []model.PermAsset selectedNode model.Node - currentResult []map[string]interface{} + currentResult []model.PermAsset *pageInfo + + selectedAsset *model.PermAsset + selectedAccount *model.PermAccount + + hiddenFields map[string]struct{} } func (u *UserSelectHandler) SetSelectType(s selectType) { @@ -56,15 +72,16 @@ func (u *UserSelectHandler) SetSelectType(s selectType) { u.SetLoadPolicy(loadingFromLocal) u.AutoCompletion() } - u.h.term.SetPrompt("[Host]> ") - case TypeNodeAsset: - u.h.term.SetPrompt("[Host]> ") + u.promptStr = promptHost + case TypeNodeAsset, TypeHost: + u.promptStr = promptHost case TypeK8s: - u.h.term.SetPrompt("[K8S]> ") + u.promptStr = promptK8s case TypeDatabase: - u.h.term.SetPrompt("[DB]> ") + u.promptStr = promptDatabase } u.currentType = s + u.h.term.SetPrompt(string(u.promptStr)) } func (u *UserSelectHandler) AutoCompletion() { @@ -72,18 +89,13 @@ func (u *UserSelectHandler) AutoCompletion() { suggests := make([]string, 0, len(assets)) for _, v := range assets { - switch u.currentType { - case TypeAsset, TypeNodeAsset: - suggests = append(suggests, v["hostname"].(string)) - default: - suggests = append(suggests, v["name"].(string)) - } + suggests = append(suggests, v.Name) } sort.Strings(suggests) u.h.term.AutoCompleteCallback = func(line string, pos int, key rune) (newLine string, newPos int, ok bool) { if key == 9 { - termWidth, _ := u.h.term.GetSize() + termWidth, _ := u.h.GetPtySize() if len(line) >= 1 { sugs := utils.FilterPrefix(suggests, line) if len(sugs) >= 1 { @@ -110,9 +122,9 @@ func (u *UserSelectHandler) SetNode(node model.Node) { u.selectedNode = node } -func (u *UserSelectHandler) SetAllLocalData(data []map[string]interface{}) { +func (u *UserSelectHandler) SetAllLocalData(data []model.PermAsset) { // 使用副本 - u.allLocalData = make([]map[string]interface{}, len(data)) + u.allLocalData = make([]model.PermAsset, len(data)) copy(u.allLocalData, data) } @@ -123,7 +135,7 @@ func (u *UserSelectHandler) SetLoadPolicy(policy dataSource) { func (u *UserSelectHandler) MoveNextPage() { if u.HasNext() { offset := u.CurrentOffSet() - newPageSize := getPageSize(u.h.term, u.h.terminalConf) + newPageSize := getPageSize(u.h, u.h.terminalConf) u.currentResult = u.Retrieve(newPageSize, offset, u.searchKeys...) } u.DisplayCurrentResult() @@ -132,18 +144,15 @@ func (u *UserSelectHandler) MoveNextPage() { func (u *UserSelectHandler) MovePrePage() { if u.HasPrev() { offset := u.CurrentOffSet() - newPageSize := getPageSize(u.h.term, u.h.terminalConf) - start := offset - newPageSize*2 - if start <= 0 { - start = 0 - } + newPageSize := getPageSize(u.h, u.h.terminalConf) + start := max(offset-newPageSize*2, 0) u.currentResult = u.Retrieve(newPageSize, start, u.searchKeys...) } u.DisplayCurrentResult() } func (u *UserSelectHandler) Search(key string) { - newPageSize := getPageSize(u.h.term, u.h.terminalConf) + newPageSize := getPageSize(u.h, u.h.terminalConf) u.currentResult = u.Retrieve(newPageSize, 0, key) u.searchKeys = []string{key} u.DisplayCurrentResult() @@ -151,7 +160,7 @@ func (u *UserSelectHandler) Search(key string) { func (u *UserSelectHandler) SearchAgain(key string) { u.searchKeys = append(u.searchKeys, key) - newPageSize := getPageSize(u.h.term, u.h.terminalConf) + newPageSize := getPageSize(u.h, u.h.terminalConf) u.currentResult = u.Retrieve(newPageSize, 0, u.searchKeys...) u.DisplayCurrentResult() } @@ -164,7 +173,7 @@ func (u *UserSelectHandler) SearchOrProxy(key string) { } } - newPageSize := getPageSize(u.h.term, u.h.terminalConf) + newPageSize := getPageSize(u.h, u.h.terminalConf) currentResult := u.Retrieve(newPageSize, 0, key) u.currentResult = currentResult u.searchKeys = []string{key} @@ -206,41 +215,20 @@ func (u *UserSelectHandler) DisplayCurrentResult() { u.displayNodeAssetResult(searchHeader) case TypeAsset: u.displayAssetResult(searchHeader) + case TypeHost: + u.displayAssetResult(searchHeader) default: logger.Error("Display unknown type") } } -func (u *UserSelectHandler) Proxy(target map[string]interface{}) { - targetId := target["id"].(string) - lang := i18n.NewLang(u.h.i18nLang) - switch u.currentType { - case TypeAsset, TypeNodeAsset: - asset, err := u.h.jmsService.GetAssetById(targetId) - if err != nil || asset.ID == "" { - logger.Errorf("Select asset %s not found", targetId) - return - } - if !asset.IsActive { - logger.Debugf("Select asset %s is inactive", targetId) - msg := lang.T("The asset is inactive") - _, _ = u.h.term.Write([]byte(msg)) - return - } - u.proxyAsset(asset) - case TypeK8s, TypeDatabase: - app, err := u.h.jmsService.GetApplicationById(targetId) - if err != nil { - logger.Errorf("Select application %s err: %s", targetId, err) - return - } - u.proxyApp(app) - default: - logger.Errorf("Select unknown type for target id %s", targetId) - } +func (u *UserSelectHandler) Proxy(target model.PermAsset) { + u.proxyAsset(target) + // set current prompt + u.h.term.SetPrompt(string(u.promptStr)) } -func (u *UserSelectHandler) Retrieve(pageSize, offset int, searches ...string) []map[string]interface{} { +func (u *UserSelectHandler) Retrieve(pageSize, offset int, searches ...string) []model.PermAsset { switch u.loadingPolicy { case loadingFromLocal: return u.retrieveFromLocal(pageSize, offset, searches...) @@ -249,7 +237,7 @@ func (u *UserSelectHandler) Retrieve(pageSize, offset int, searches ...string) [ } } -func (u *UserSelectHandler) retrieveFromLocal(pageSize, offset int, searches ...string) []map[string]interface{} { +func (u *UserSelectHandler) retrieveFromLocal(pageSize, offset int, searches ...string) []model.PermAsset { if pageSize <= 0 { pageSize = PAGESIZEALL } @@ -259,7 +247,7 @@ func (u *UserSelectHandler) retrieveFromLocal(pageSize, offset int, searches ... searchResult := u.retrieveLocal(searches...) var ( - totalData []map[string]interface{} + totalData []model.PermAsset total int currentOffset int currentPageSize int @@ -291,13 +279,9 @@ func (u *UserSelectHandler) retrieveFromLocal(pageSize, offset int, searches ... return currentData } -func (u *UserSelectHandler) retrieveLocal(searches ...string) []map[string]interface{} { +func (u *UserSelectHandler) retrieveLocal(searches ...string) []model.PermAsset { switch u.currentType { - case TypeDatabase: - return u.searchLocalDatabase(searches...) - case TypeK8s: - return u.searchLocalK8s(searches...) - case TypeAsset: + case TypeAsset, TypeHost, TypeDatabase, TypeK8s: return u.searchLocalAsset(searches...) default: // TypeAsset @@ -307,32 +291,61 @@ func (u *UserSelectHandler) retrieveLocal(searches ...string) []map[string]inter } } -func (u *UserSelectHandler) searchLocalFromFields(fields map[string]struct{}, searches ...string) []map[string]interface{} { - items := make([]map[string]interface{}, 0, len(u.allLocalData)) +func (u *UserSelectHandler) searchLocalFromFields(fields map[string]struct{}, searches ...string) []model.PermAsset { + items := make([]model.PermAsset, 0, len(u.allLocalData)) for i := range u.allLocalData { - if containKeysInMapItemFields(u.allLocalData[i], fields, searches...) { + assetData := u.allLocalData[i] + data := map[string]interface{}{ + "name": u.allLocalData[i].Name, + "address": assetData.Address, + "org_name": assetData.OrgName, + "platform": assetData.Platform.Name, + "comment": assetData.Comment, + } + if containKeysInMapItemFields(data, fields, searches...) { items = append(items, u.allLocalData[i]) } } return items } -func (u *UserSelectHandler) retrieveFromRemote(pageSize, offset int, searches ...string) []map[string]interface{} { +func (u *UserSelectHandler) retrieveFromRemote(pageSize, offset int, searches ...string) []model.PermAsset { + + var order string + switch u.h.terminalConf.AssetListSortBy { + case "ip": + order = "address" + default: + order = "name" + } reqParam := model.PaginationParam{ PageSize: pageSize, Offset: offset, Searches: searches, + Order: order, + IsActive: true, } switch u.currentType { case TypeDatabase: - return u.retrieveRemoteDatabase(reqParam) + reqParam.Category = "database" + reqParam.Protocols = srvconn.SupportedDBProtocols() + return u.retrieveRemoteAsset(reqParam) case TypeK8s: - return u.retrieveRemoteK8s(reqParam) + reqParam.Type = "k8s" + return u.retrieveRemoteAsset(reqParam) case TypeNodeAsset: return u.retrieveRemoteNodeAsset(reqParam) case TypeAsset: + reqParam.Category = "" + reqParam.Protocols = srvconn.SupportedProtocols() + return u.retrieveRemoteAsset(reqParam) + case TypeHost: + reqParam.Category = "host" + reqParam.Protocols = srvconn.SupportedHostProtocols() return u.retrieveRemoteAsset(reqParam) default: + reqParam.Category = "" + reqParam.Protocols = srvconn.SupportedProtocols() // TypeAsset u.SetSelectType(TypeAsset) logger.Info("Retrieve default remote data type: Asset") @@ -341,7 +354,7 @@ func (u *UserSelectHandler) retrieveFromRemote(pageSize, offset int, searches .. } func (u *UserSelectHandler) updateRemotePageData(reqParam model.PaginationParam, - res model.PaginationResponse) []map[string]interface{} { + res model.PaginationResponse) []model.PermAsset { u.hasNext = false u.hasPre = false @@ -394,25 +407,6 @@ func containKeysInMapItemFields(item map[string]interface{}, return false } -func convertMapItemToRow(item map[string]interface{}, fields map[string]string, row map[string]string) map[string]string { - for key, value := range item { - if rowKey, ok := fields[key]; ok { - switch ret := value.(type) { - case string: - row[rowKey] = ret - case int: - row[rowKey] = strconv.Itoa(ret) - } - continue - } - switch ret := value.(type) { - case map[string]interface{}: - row = convertMapItemToRow(ret, fields, row) - } - } - return row -} - func joinMultiLineString(lines string) string { lines = strings.ReplaceAll(lines, "\r", "\n") lines = strings.ReplaceAll(lines, "\n\n", "\n") @@ -428,11 +422,12 @@ func joinMultiLineString(lines string) string { return strings.Join(lineSlice, "|") } -func getUniqueAssetFromKey(key string, currentResult []map[string]interface{}) (data map[string]interface{}, ok bool) { +func getUniqueAssetFromKey(key string, currentResult []model.PermAsset) (data model.PermAsset, ok bool) { result := make([]int, 0, len(currentResult)) for i := range currentResult { - ip := currentResult[i]["ip"].(string) - hostname := currentResult[i]["hostname"].(string) + asset := currentResult[i] + ip := asset.Address + hostname := asset.Name switch key { case ip, hostname: result = append(result, i) @@ -441,5 +436,5 @@ func getUniqueAssetFromKey(key string, currentResult []map[string]interface{}) ( if len(result) == 1 { return currentResult[result[0]], true } - return nil, false + return model.PermAsset{}, false } diff --git a/pkg/handler/server.go b/pkg/handler/server.go new file mode 100644 index 000000000..f55be81ae --- /dev/null +++ b/pkg/handler/server.go @@ -0,0 +1,101 @@ +package handler + +import ( + "net" + "sync" + "sync/atomic" + "time" + + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" + "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/srvconn" +) + +func NewServer(termCfg model.TerminalConfig, jmsService *service.JMService) *Server { + app := Server{ + jmsService: jmsService, + vscodeClients: make(map[string]*vscodeReq), + } + app.UpdateTerminalConfig(termCfg) + go app.run() + return &app +} + +type Server struct { + terminalConf atomic.Value + jmsService *service.JMService + sync.Mutex + + vscodeClients map[string]*vscodeReq +} + +func (s *Server) run() { + for { + time.Sleep(time.Minute) + conf, err := s.jmsService.GetTerminalConfig() + if err != nil { + logger.Errorf("Update terminal config failed: %s", err) + continue + } + s.UpdateTerminalConfig(conf) + } +} + +func (s *Server) UpdateTerminalConfig(conf model.TerminalConfig) { + s.terminalConf.Store(conf) +} + +func (s *Server) GetTerminalConfig() model.TerminalConfig { + return s.terminalConf.Load().(model.TerminalConfig) +} + +func (s *Server) getVSCodeReq(reqId string) *vscodeReq { + s.Lock() + defer s.Unlock() + return s.vscodeClients[reqId] +} + +func (s *Server) addVSCodeReq(vsReq *vscodeReq) { + s.Lock() + defer s.Unlock() + s.vscodeClients[vsReq.reqId] = vsReq +} + +func (s *Server) deleteVSCodeReq(vsReq *vscodeReq) { + s.Lock() + defer s.Unlock() + delete(s.vscodeClients, vsReq.reqId) +} + +type vscodeReq struct { + reqId string + user *model.User + client *srvconn.SSHClient + + expireInfo model.ExpireInfo + Actions model.Actions + + sync.Mutex + forwards map[string]net.Listener +} + +func (s *vscodeReq) GetForward(addr string) net.Listener { + s.Lock() + defer s.Unlock() + + return s.forwards[addr] +} + +func (s *vscodeReq) AddForward(addr string, ln net.Listener) { + s.Lock() + defer s.Unlock() + + s.forwards[addr] = ln +} + +func (s *vscodeReq) RemoveForward(addr string) { + s.Lock() + defer s.Unlock() + delete(s.forwards, addr) +} diff --git a/pkg/handler/server_ssh.go b/pkg/handler/server_ssh.go new file mode 100644 index 000000000..0f99c2874 --- /dev/null +++ b/pkg/handler/server_ssh.go @@ -0,0 +1,843 @@ +package handler + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/gliderlabs/ssh" + "github.com/pkg/sftp" + gossh "golang.org/x/crypto/ssh" + + "github.com/jumpserver-dev/sdk-go/common" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" + + "github.com/jumpserver/koko/pkg/auth" + "github.com/jumpserver/koko/pkg/cache" + "github.com/jumpserver/koko/pkg/config" + "github.com/jumpserver/koko/pkg/i18n" + "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/proxy" + "github.com/jumpserver/koko/pkg/session" + "github.com/jumpserver/koko/pkg/srvconn" + "github.com/jumpserver/koko/pkg/utils" +) + +const ctxID = "ctxID" + +func (s *Server) PasswordAuth(ctx ssh.Context, password string) error { + ctx.SetValue(ctxID, ctx.SessionID()) + tConfig := s.GetTerminalConfig() + if !tConfig.PasswordAuth { + logger.Info("Core API disable password auth") + return errors.New("password auth disabled") + } + sshAuthHandler := auth.SSHPasswordAndPublicKeyAuth(s.jmsService) + return sshAuthHandler(ctx, password, "") +} + +func (s *Server) PublicKeyAuth(ctx ssh.Context, key ssh.PublicKey) error { + ctx.SetValue(ctxID, ctx.SessionID()) + tConfig := s.GetTerminalConfig() + if !tConfig.PublicKeyAuth { + logger.Info("Core API disable publickey auth") + return errors.New("publickey auth disabled") + } + sshAuthHandler := auth.SSHPasswordAndPublicKeyAuth(s.jmsService) + value := string(gossh.MarshalAuthorizedKey(key)) + return sshAuthHandler(ctx, "", value) +} + +func (s *Server) SFTPHandler(sess ssh.Session) { + currentUser, ok := sess.Context().Value(auth.ContextKeyUser).(*model.User) + if !ok || currentUser.ID == "" { + logger.Errorf("SFTP User not found, exit.") + return + } + addr, _, _ := net.SplitHostPort(sess.RemoteAddr().String()) + directReq := sess.Context().Value(auth.ContextKeyDirectLoginFormat) + var sftpHandler *SftpHandler + termConf := s.GetTerminalConfig() + if directRequest, ok2 := directReq.(*auth.DirectLoginAssetReq); ok2 { + selectedAssets, err := s.getMatchedAssetsByDirectReq(currentUser, directRequest) + if err != nil { + logger.Errorf("Get matched assets failed: %s", err) + return + } + if directRequest.IsToken() && config.GetConf().ConnectionTokenReusable { + tokenInfo := directRequest.ConnectToken + key := cache.CreateAddrCacheKey(sess.RemoteAddr(), tokenInfo.Id) + // 缓存 token 信息 + cache.TokenCacheInstance.Save(key, tokenInfo) + defer cache.TokenCacheInstance.Recycle(key) + logger.Infof("SFTP token key %s cached", key) + } + opts := buildDirectRequestOptions(currentUser, directRequest) + opts = append(opts, DirectConnectSftpMode(true)) + opts = append(opts, DirectAssets(selectedAssets)) + opts = append(opts, DirectTerminalConf(&termConf)) + directSrv := NewDirectHandler(sess, s.jmsService, opts...) + sftpHandler = directSrv.NewSFTPHandler() + } else { + sftpHandler = s.NewSftpHandler(currentUser, addr) + } + handlers := sftp.Handlers{ + FileGet: sftpHandler, + FilePut: sftpHandler, + FileCmd: sftpHandler, + FileList: sftpHandler, + } + reqID := common.UUID() + logger.Infof("SFTP request %s: Handler start", reqID) + req := sftp.NewRequestServer(sess, handlers) + if err := req.Serve(); err == io.EOF { + logger.Debugf("SFTP request %s: Exited session.", reqID) + } else if err != nil { + logger.Errorf("SFTP request %s: Server completed with error %s", reqID, err) + } + _ = req.Close() + sftpHandler.Close() + logger.Infof("SFTP request %s: Handler exit.", reqID) +} + +func (s *Server) NewSftpHandler(user *model.User, addr string) *SftpHandler { + terminalCfg := s.GetTerminalConfig() + opts := make([]srvconn.UserSftpOption, 0, 5) + opts = append(opts, srvconn.WithUser(user)) + opts = append(opts, srvconn.WithRemoteAddr(addr)) + opts = append(opts, srvconn.WithLoginFrom(model.LoginFromSSH)) + opts = append(opts, srvconn.WithTerminalCfg(&terminalCfg)) + return &SftpHandler{ + UserSftpConn: srvconn.NewUserSftpConn(s.jmsService, opts...), + recorder: proxy.GetFTPFileRecorder(s.jmsService), + } +} + +func (s *Server) LocalPortForwardingPermission(ctx ssh.Context, dstHost string, dstPort uint32) bool { + logger.Debugf("LocalPortForwardingPermission: %s %s %d", ctx.User(), dstHost, dstPort) + return config.GlobalConfig.EnableLocalPortForward +} + +func (s *Server) DirectTCPIPChannelHandler(ctx ssh.Context, newChan gossh.NewChannel, destAddr string) { + if !config.GetConf().EnableVscodeSupport { + _ = newChan.Reject(gossh.Prohibited, "port forwarding is disabled") + return + } + reqId, ok := ctx.Value(ctxID).(string) + if !ok { + _ = newChan.Reject(gossh.Prohibited, "port forwarding is disabled") + return + } + vsReq := s.getVSCodeReq(reqId) + if vsReq == nil { + _ = newChan.Reject(gossh.Prohibited, "port forwarding is disabled") + return + } + dConn, err := vsReq.client.Dial("tcp", destAddr) + if err != nil { + _ = newChan.Reject(gossh.ConnectionFailed, err.Error()) + return + } + defer dConn.Close() + ch, reqs, err := newChan.Accept() + if err != nil { + _ = dConn.Close() + _ = newChan.Reject(gossh.ConnectionFailed, err.Error()) + return + } + logger.Infof("User %s start port forwarding from (%s) to (%s)", vsReq.user, + vsReq.client, destAddr) + defer ch.Close() + go gossh.DiscardRequests(reqs) + go func() { + defer ch.Close() + defer dConn.Close() + _, _ = io.Copy(ch, dConn) + }() + _, _ = io.Copy(dConn, ch) + logger.Infof("User %s end port forwarding from (%s) to (%s)", vsReq.user, + vsReq.client, destAddr) +} + +func (s *Server) SessionHandler(sess ssh.Session) { + user, ok := sess.Context().Value(auth.ContextKeyUser).(*model.User) + if !ok || user.ID == "" { + logger.Errorf("SSH User %s not found, exit.", sess.User()) + utils.IgnoreErrWriteString(sess, "Not auth user.\n") + return + } + i18nLang := i18n.NewLang(user.Language) + termConf := s.GetTerminalConfig() + directReq := sess.Context().Value(auth.ContextKeyDirectLoginFormat) + if pty, winChan, isPty := sess.Pty(); isPty && sess.RawCommand() == "" { + if directRequest, ok3 := directReq.(*auth.DirectLoginAssetReq); ok3 { + opts := buildDirectRequestOptions(user, directRequest) + opts = append(opts, DirectTerminalConf(&termConf)) + if !directRequest.IsToken() { + selectedAssets, err := s.getMatchedAssetsByDirectReq(user, directRequest) + if err != nil { + utils.IgnoreErrWriteString(sess, err.Error()) + logger.Errorf("Get matched assets failed: %s", err) + return + } + opts = append(opts, DirectAssets(selectedAssets)) + } + directSrv := NewDirectHandler(sess, s.jmsService, opts...) + directSrv.Dispatch() + return + } + + interactiveSrv := NewInteractiveHandler(sess, user, s.jmsService, termConf) + logger.Infof("User %s request pty %s", sess.User(), pty.Term) + go interactiveSrv.WatchWinSizeChange(winChan) + interactiveSrv.Dispatch() + utils.IgnoreErrWriteWindowTitle(sess, termConf.HeaderTitle) + return + } + + if directRequest, ok3 := directReq.(*auth.DirectLoginAssetReq); ok3 { + if directRequest.IsToken() { + tokenInfo := directRequest.ConnectToken + matchedProtocol := tokenInfo.Protocol == model.ProtocolSSH + assetSupportedSSH := tokenInfo.Asset.IsSupportProtocol(model.ProtocolSSH) + if !matchedProtocol || !assetSupportedSSH { + msg := "not ssh asset connection token" + utils.IgnoreErrWriteString(sess, msg) + logger.Errorf("vscode failed: %s", msg) + return + } + s.proxyTokenInfo(sess, tokenInfo) + return + } + selectedAssets, err := s.getMatchedAssetsByDirectReq(user, directRequest) + if err != nil { + logger.Error(err) + utils.IgnoreErrWriteString(sess, err.Error()) + return + } + if len(selectedAssets) != 1 { + msg := fmt.Sprintf(i18nLang.T("Must be unique asset for %s"), directRequest.AssetTarget) + utils.IgnoreErrWriteString(sess, msg) + logger.Error(msg) + return + } + permAssetDetail, err := s.jmsService.GetUserPermAssetDetailById(user.ID, selectedAssets[0].ID) + if err != nil { + msg := fmt.Sprintf(i18nLang.T("Must be unique asset for %s"), directRequest.AssetTarget) + utils.IgnoreErrWriteString(sess, msg) + logger.Errorf("Get permAssetDetail failed: %s", err) + return + } + + matchedProtocol := directRequest.Protocol == model.ProtocolSSH + assetSupportedSSH := permAssetDetail.SupportProtocol(model.ProtocolSSH) + if !matchedProtocol || !assetSupportedSSH { + msg := "not ssh asset connection" + utils.IgnoreErrWriteString(sess, msg) + logger.Errorf("Direct Request ssh failed: %s", msg) + return + } + + selectAccounts, err := s.getMatchedAccounts(user, directRequest, permAssetDetail) + if err != nil { + logger.Error(err) + utils.IgnoreErrWriteString(sess, err.Error()) + return + } + if len(selectAccounts) != 1 { + msg := fmt.Sprintf(i18nLang.T("Must be unique account for %s"), directRequest.AccountUsername) + utils.IgnoreErrWriteString(sess, msg) + logger.Error(msg) + return + } + selectAccount := selectAccounts[0] + switch selectAccount.Username { + case "@INPUT", "@USER": + msg := fmt.Sprintf(i18nLang.T("Must be auto login account for %s"), directRequest.AccountUsername) + utils.IgnoreErrWriteString(sess, msg) + logger.Error(msg) + return + default: + s.proxyDirectRequest(sess, user, selectedAssets[0], selectAccount) + } + } + +} + +func (s *Server) proxyDirectRequest(sess ssh.Session, user *model.User, asset model.PermAsset, + permAccount model.PermAccount) { + // 仅支持 ssh 的协议资产 + remoteAddr, _, _ := net.SplitHostPort(sess.RemoteAddr().String()) + req := &service.SuperConnectTokenReq{ + UserId: user.ID, + AssetId: asset.ID, + Account: permAccount.Alias, + Protocol: model.ProtocolSSH, + ConnectMethod: model.ProtocolSSH, + RemoteAddr: remoteAddr, + } + // ssh 非交互式的直连格式,不支持资产的登录复核 + tokenInfo, err := s.jmsService.CreateSuperConnectToken(req) + if err != nil { + msg := err.Error() + if tokenInfo.Detail != "" { + msg = tokenInfo.Detail + } + logger.Errorf("Create super connect token failed: %s", msg) + return + } + connectToken, err := s.jmsService.GetConnectTokenInfo(tokenInfo.ID, true) + if err != nil { + logger.Errorf("Create super connect token err: %s", err) + utils.IgnoreErrWriteString(sess, err.Error()) + return + } + s.proxyTokenInfo(sess, &connectToken) +} + +func (s *Server) proxyTokenInfo(sess ssh.Session, tokenInfo *model.ConnectToken) { + ctxId, ok := sess.Context().Value(ctxID).(string) + if !ok { + logger.Error("Not found ctxID") + utils.IgnoreErrWriteString(sess, "not found ctx id") + return + } + asset := tokenInfo.Asset + account := tokenInfo.Account + var gateways []model.Gateway + // todo:domain + if tokenInfo.Gateway != nil { + gateways = []model.Gateway{*tokenInfo.Gateway} + } + enableReused := config.GetConf().ReuseConnection + reusedKey := GenerateSSHTokenResueKey(tokenInfo) + + var ( + sshClient *srvconn.SSHClient + ok1 bool + err1 error + ) + if enableReused { + sshClient, ok1 = srvconn.GetClientFromCache(reusedKey) + if ok1 { + logger.Infof("reused ssh client: %s", sshClient) + } + } + if sshClient == nil { + sshAuthOpts := buildSSHClientOptions(&asset, &account, gateways) + // add Reuse ssh client + sshClient, err1 = srvconn.NewSSHClient(sshAuthOpts...) + if err1 != nil { + logger.Errorf("Get SSH Client failed: %s", err1) + utils.IgnoreErrWriteString(sess, err1.Error()) + return + } + if enableReused { + srvconn.AddClientCache(reusedKey, sshClient) + } + } + //defer sshClient.Close() + vsReq := &vscodeReq{ + reqId: ctxId, + user: &tokenInfo.User, + client: sshClient, + expireInfo: tokenInfo.ExpireAt, + forwards: make(map[string]net.Listener), + } + + go func() { + s.addVSCodeReq(vsReq) + defer s.deleteVSCodeReq(vsReq) + <-sess.Context().Done() + if sshClient.KeyId != "" { + srvconn.ReleaseClientCacheKey(sshClient.KeyId, sshClient) + } else { + _ = sshClient.Close() + } + logger.Infof("User %s end vscode request %s", vsReq.user, sshClient) + }() + if len(sess.Command()) != 0 { + s.proxyAssetCommand(sess, sshClient, tokenInfo) + return + } + + if !config.GetConf().EnableVscodeSupport { + utils.IgnoreErrWriteString(sess, "No support vscode like requested.\n") + return + } + + if err := s.proxyVscodeShell(sess, vsReq, sshClient, tokenInfo); err != nil { + utils.IgnoreErrWriteString(sess, err.Error()) + } +} + +func IsScpCommand(rawStr string) bool { + rawCommands := strings.Split(rawStr, ";") + for _, cmd := range rawCommands { + cmd = strings.TrimSpace(cmd) + if strings.HasPrefix(cmd, "scp") { + return true + } + } + return false +} + +func (s *Server) recordSessionLifecycle(sid string, event model.LifecycleEvent, reason string) { + logObj := model.SessionLifecycleLog{Reason: reason} + if err2 := s.jmsService.RecordSessionLifecycleLog(sid, event, logObj); err2 != nil { + logger.Errorf("Record session %s lifecycle %s failed: %s", sid, event, err2) + } +} + +func (s *Server) proxyAssetCommand(sess ssh.Session, sshClient *srvconn.SSHClient, + tokenInfo *model.ConnectToken) { + rawStr := sess.RawCommand() + if IsScpCommand(rawStr) { + if !config.GetConf().EnableVscodeSupport { + logger.Errorf("Not support scp command: %s", rawStr) + utils.IgnoreErrWriteString(sess, "Not support scp command") + return + } + // 开启了 vscode 支持,放开使用 scp 命令传输文件 + // todo: 解析 scp 数据包,获取文件信息 + logger.Infof("Execute scp command: %s", rawStr) + } else { + logger.Infof("Execute command: %s", rawStr) + } + + // todo: 暂且不支持 acl 工单 + acls := tokenInfo.CommandFilterACLs + sort.Sort(model.CommandACLs(acls)) + for i := range acls { + acl := acls[i] + _, action, _ := acl.Match(rawStr) + switch action { + case model.ActionReview: + msg := "SSH Command not support ACL review ticket" + utils.IgnoreErrWriteString(sess, msg) + logger.Errorf("SSH Command not support ACL review ticket `%s`", rawStr) + return + case model.ActionReject: + logger.Errorf("ACL reject execute %s ", rawStr) + return + default: + } + if action == model.ActionAccept { + logger.Debugf("ACL accept execute %s ", rawStr) + break + } + } + + host, _, _ := net.SplitHostPort(sess.RemoteAddr().String()) + reqSession := tokenInfo.CreateSession(host, model.LoginFromSSH, model.COMMANDType) + respSession, err := s.jmsService.CreateSession(reqSession) + if err != nil { + logger.Errorf("Create command session err: %s", err) + return + } + ctx, cancel := context.WithCancel(sess.Context()) + defer cancel() + respSession.TokenId = tokenInfo.Id + traceSession := session.NewSession(&respSession, func(task *model.TerminalTask) error { + switch task.Name { + case model.TaskKillSession: + cancel() + logger.Infof("User %s end command request %s as task kill_session", + tokenInfo.User.String(), sshClient) + return nil + case model.TaskPermExpired: + cancel() + logger.Infof("User %s end command request %s as task permission has expired", + tokenInfo.User.String(), sshClient) + return nil + case model.TaskPermValid: + return nil + + } + return fmt.Errorf("ssh proxy not support task: %s", task.Name) + }) + session.AddSession(traceSession) + + defer func() { + if _, err2 := s.jmsService.SessionFinished(respSession.ID, common.NewNowUTCTime()); err2 != nil { + logger.Errorf("Create tunnel session err: %s", err2) + } + session.RemoveSession(traceSession) + }() + + goSess, err := sshClient.AcquireSession() + if err != nil { + logger.Errorf("Get SSH session failed: %s", err) + return + } + s.recordSessionLifecycle(respSession.ID, model.AssetConnectSuccess, "") + defer goSess.Close() + defer sshClient.ReleaseSession(goSess) + go func() { + <-ctx.Done() + _ = goSess.Close() + }() + + // to fix this issue: https://github.com/ploxiln/fab-classic/issues/46 + // make pty for client when client required or command is login shell + if pty, _, isPty := sess.Pty(); isPty && + (strings.Contains(rawStr, "bash --login") || strings.Contains(rawStr, "bash -l")) { + _ = goSess.RequestPty( + pty.Term, + pty.Window.Width, + pty.Window.Height, + gossh.TerminalModes{ + gossh.ECHO: 1, // enable echoing + gossh.TTY_OP_ISPEED: 14400, // input speed = 14.4 kbaud + gossh.TTY_OP_OSPEED: 14400, // output speed = 14.4 kbaud + }, + ) + } + + goSess.Stdin = sess + out, err := goSess.StdoutPipe() + if err != nil { + logger.Errorf("Get SSH session stdout failed: %s", err) + return + } + errOut, err := goSess.StderrPipe() + if err != nil { + logger.Errorf("Get SSH session stderr failed: %s", err) + return + } + stderrWriter := sess.Stderr() + recordBuf := utils.NewMaxSizeBuffer(1024) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + outRecorderWriter := io.MultiWriter(sess, recordBuf) + _, _ = io.Copy(outRecorderWriter, out) + logger.Debugf("User %s finished session stdout", tokenInfo.User.String()) + }() + + go func() { + defer wg.Done() + errRecorderWriter := io.MultiWriter(stderrWriter, recordBuf) + _, _ = io.Copy(errRecorderWriter, errOut) + logger.Debugf("User %s finished session stderr", tokenInfo.User.String()) + }() + now := time.Now() + err = goSess.Run(rawStr) + if err != nil { + logger.Errorf("User %s Run command %s failed: %s", + tokenInfo.User.String(), rawStr, err) + var exitErr *gossh.ExitError + if errors.As(err, &exitErr) { + exitCode := exitErr.ExitStatus() + if err1 := sess.Exit(exitCode); err1 != nil { + logger.Errorf("Create sess exit code %d err: %s", exitCode, err1) + } + } + } + wg.Wait() + cmd := model.Command{ + SessionID: respSession.ID, + OrgID: respSession.OrgID, + Input: rawStr, + User: respSession.User, + Server: respSession.Asset, + Account: respSession.Account, + Timestamp: now.Unix(), + DateCreated: now, + } + outResult := recordBuf.String() + cmd.Output = strings.ReplaceAll(outResult, "\x00", "") + termCfg := s.GetTerminalConfig() + cmdStorage := proxy.NewCommandStorage(s.jmsService, &termCfg) + if err2 := cmdStorage.BulkSave([]*model.Command{&cmd}); err2 != nil { + logger.Errorf("Create command err: %s", err2) + } + reason := string(model.ReasonErrConnectDisconnect) + s.recordSessionLifecycle(respSession.ID, model.AssetConnectFinished, reason) +} + +func (s *Server) proxyVscodeShell(sess ssh.Session, vsReq *vscodeReq, sshClient *srvconn.SSHClient, + tokenInfo *model.ConnectToken) error { + host, _, _ := net.SplitHostPort(sess.RemoteAddr().String()) + reqSession := tokenInfo.CreateSession(host, model.LoginFromSSH, model.TUNNELType) + respSession, err := s.jmsService.CreateSession(reqSession) + if err != nil { + logger.Errorf("Create tunnel session err: %s", err) + utils.IgnoreErrWriteString(sess, err.Error()) + return err + } + ctx, cancel := context.WithCancel(sess.Context()) + defer cancel() + traceSession := session.NewSession(&respSession, func(task *model.TerminalTask) error { + switch task.Name { + case model.TaskKillSession: + cancel() + logger.Infof("User %s end vscode request %s as task kill_session", vsReq.user, sshClient) + return nil + case model.TaskPermExpired: + cancel() + logger.Infof("User %s end vscode request %s as permission has expired", vsReq.user, sshClient) + return nil + case model.TaskPermValid: + return nil + + } + return fmt.Errorf("ssh proxy not support task: %s", task.Name) + }) + session.AddSession(traceSession) + defer func() { + if _, err2 := s.jmsService.SessionFinished(respSession.ID, common.NewNowUTCTime()); err2 != nil { + logger.Errorf("Create tunnel session err: %s", err2) + } + session.RemoveSession(traceSession) + }() + + goSess, err := sshClient.AcquireSession() + if err != nil { + logger.Errorf("Get SSH session failed: %s", err) + return err + } + s.recordSessionLifecycle(respSession.ID, model.AssetConnectSuccess, "") + defer goSess.Close() + defer sshClient.ReleaseSession(goSess) + stdOut, err := goSess.StdoutPipe() + if err != nil { + logger.Errorf("Get SSH session StdoutPipe failed: %s", err) + return err + } + stdin, err := goSess.StdinPipe() + if err != nil { + logger.Errorf("Get SSH session StdinPipe failed: %s", err) + return err + } + err = goSess.Shell() + if err != nil { + logger.Errorf("Get SSH session shell failed: %s", err) + s.recordSessionLifecycle(respSession.ID, model.AssetConnectFinished, err.Error()) + return err + } + logger.Infof("User %s start vscode request to %s", vsReq.user, sshClient) + + go func() { + _, _ = io.Copy(stdin, sess) + logger.Infof("User %s vscode request %s stdin end", vsReq.user, sshClient) + }() + go func() { + _, _ = io.Copy(sess, stdOut) + logger.Infof("User %s vscode request %s stdOut end", vsReq.user, sshClient) + }() + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + logger.Infof("SSH conn[%s] User %s end vscode request %s as session done", + vsReq.reqId, vsReq.user, sshClient) + reason := string(model.ReasonErrConnectDisconnect) + s.recordSessionLifecycle(respSession.ID, model.AssetConnectFinished, reason) + return nil + case now := <-ticker.C: + if vsReq.expireInfo.IsExpired(now) { + logger.Infof("SSH conn[%s] User %s end vscode request %s as permission has expired", + vsReq.reqId, vsReq.user, sshClient) + reason := string(model.ReasonErrPermissionExpired) + s.recordSessionLifecycle(respSession.ID, model.AssetConnectFinished, reason) + return nil + } + logger.Debugf("SSH conn[%s] user %s vscode request still alive", vsReq.reqId, vsReq.user) + } + } +} + +func buildSSHClientOptions(asset *model.Asset, account *model.Account, + gateways []model.Gateway) []srvconn.SSHClientOption { + timeout := config.GlobalConfig.SSHTimeout + sshAuthOpts := make([]srvconn.SSHClientOption, 0, 7) + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientUsername(account.Username)) + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientHost(asset.Address)) + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPort(asset.ProtocolPort(model.ProtocolSSH))) + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientTimeout(timeout)) + if account.IsSSHKey() { + if signer, err1 := gossh.ParsePrivateKey([]byte(account.Secret)); err1 == nil { + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPrivateAuth(signer)) + } else { + logger.Errorf("Parse account %s private key failed: %s", account.Username, err1) + } + } else { + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPassword(account.Secret)) + } + + if len(gateways) > 0 { + proxyArgs := make([]srvconn.SSHClientOptions, 0, len(gateways)) + for i := range gateways { + gateway := gateways[i] + loginAccount := gateway.Account + port := gateway.Protocols.GetProtocolPort(model.ProtocolSSH) + proxyArg := srvconn.SSHClientOptions{ + Host: gateway.Address, + Port: strconv.Itoa(port), + Username: loginAccount.Username, + Timeout: timeout, + } + if loginAccount.IsSSHKey() { + proxyArg.PrivateKey = loginAccount.Secret + } else { + proxyArg.Password = loginAccount.Secret + } + proxyArgs = append(proxyArgs, proxyArg) + } + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientProxyClient(proxyArgs...)) + } + return sshAuthOpts +} + +func (s *Server) getMatchedAssetsByDirectReq(user *model.User, req *auth.DirectLoginAssetReq) ([]model.PermAsset, error) { + var getUserPermAssets func() ([]model.PermAsset, error) + if common.IsUUID(req.AssetTarget) { + getUserPermAssets = func() ([]model.PermAsset, error) { + return s.jmsService.GetUserPermAssetsById(user.ID, req.AssetTarget) + } + } else { + getUserPermAssets = func() ([]model.PermAsset, error) { + return s.jmsService.GetUserPermAssetsByIP(user.ID, req.AssetTarget) + } + } + i18nLang := i18n.NewLang(user.Language) + assets, err := getUserPermAssets() + if err != nil { + logger.Errorf("Get user %s perm asset failed: %s", user.String(), err) + return nil, fmt.Errorf("match asset failed: %s", i18nLang.T("Core API failed")) + } + if len(assets) == 0 { + logger.Infof("User %s no perm for asset %s", user.String(), req.AssetTarget) + return nil, fmt.Errorf("match asset failed: %s", i18nLang.T("No found asset")) + } + return assets, nil +} + +func (s *Server) getMatchedAccounts(user *model.User, req *auth.DirectLoginAssetReq, + permAssetDetail model.PermAssetDetail) ([]model.PermAccount, error) { + matched := GetMatchedAccounts(permAssetDetail.PermedAccounts, req.AccountUsername) + return matched, nil +} + +func buildDirectRequestOptions(user *model.User, directRequest *auth.DirectLoginAssetReq) []DirectOpt { + opts := make([]DirectOpt, 0, 7) + opts = append(opts, DirectUser(user)) + opts = append(opts, DirectTargetAccount(directRequest.AccountUsername)) + opts = append(opts, DirectConnectProtocol(directRequest.Protocol)) + if directRequest.IsToken() { + opts = append(opts, DirectFormatType(FormatToken)) + opts = append(opts, DirectConnectToken(directRequest.ConnectToken)) + } + return opts +} + +func (s *Server) buildConnectToken(ctx ssh.Context, user *model.User, req *auth.DirectLoginAssetReq) (*model.ConnectToken, error) { + selectedAssets, err := s.getMatchedAssetsByDirectReq(user, req) + if err != nil { + return nil, err + } + i18nLang := i18n.NewLang(user.Language) + if len(selectedAssets) != 1 { + msg := fmt.Sprintf(i18nLang.T("Must be unique asset for %s"), req.AssetTarget) + return nil, errors.New(msg) + } + permAssetDetail, err := s.jmsService.GetUserPermAssetDetailById(user.ID, selectedAssets[0].ID) + if err != nil { + msg := fmt.Sprintf(i18nLang.T("Must be unique asset for %s"), req.AssetTarget) + logger.Errorf("Get permAssetDetail failed: %s", err) + return nil, errors.New(msg) + } + + matchedProtocol := req.Protocol == model.ProtocolSSH + assetSupportedSSH := permAssetDetail.SupportProtocol(model.ProtocolSSH) + if !matchedProtocol || !assetSupportedSSH { + msg := "not ssh asset connection" + logger.Errorf("Direct Request ssh failed: %s", msg) + return nil, errors.New(msg) + } + + selectAccounts, err := s.getMatchedAccounts(user, req, permAssetDetail) + if err != nil { + return nil, err + } + if len(selectAccounts) != 1 { + msg := fmt.Sprintf(i18nLang.T("Must be unique account for %s"), req.AccountUsername) + logger.Error(msg) + return nil, errors.New(msg) + } + selectAccount := selectAccounts[0] + remoteAddr, _, _ := net.SplitHostPort(ctx.RemoteAddr().String()) + sessReq := &service.SuperConnectTokenReq{ + UserId: user.ID, + AssetId: permAssetDetail.ID, + Account: selectAccount.Alias, + Protocol: model.ProtocolSSH, + ConnectMethod: model.ProtocolSSH, + RemoteAddr: remoteAddr, + } + // ssh 非交互式的直连格式,不支持资产的登录复核 + tokenInfo, err := s.jmsService.CreateSuperConnectToken(sessReq) + if err != nil { + msg := err.Error() + if tokenInfo.Detail != "" { + msg = tokenInfo.Detail + } + logger.Errorf("Create super connect token failed: %s", msg) + return nil, err + } + connectToken, err := s.jmsService.GetConnectTokenInfo(tokenInfo.ID, true) + if err != nil { + logger.Errorf("Create super connect token err: %s", err) + return nil, err + } + return &connectToken, nil +} + +func (s *Server) buildSSHClient(tokenInfo *model.ConnectToken) (*srvconn.SSHClient, error) { + asset := tokenInfo.Asset + account := tokenInfo.Account + var gateways []model.Gateway + if tokenInfo.Gateway != nil { + gateways = []model.Gateway{*tokenInfo.Gateway} + } + sshAuthOpts := buildSSHClientOptions(&asset, &account, gateways) + // add reuse ssh client + enableReused := config.GetConf().ReuseConnection + reusedKey := GenerateSSHTokenResueKey(tokenInfo) + if enableReused { + if client, ok := srvconn.GetClientFromCache(reusedKey); ok { + logger.Infof("Reused ssh client key: %s", reusedKey) + return client, nil + } + } + sshClient, err := srvconn.NewSSHClient(sshAuthOpts...) + if err != nil { + logger.Errorf("Get SSH Client failed: %s", err) + return sshClient, err + } + if enableReused { + srvconn.AddClientCache(reusedKey, sshClient) + } + return sshClient, nil +} + +func GenerateSSHTokenResueKey(tokenInfo *model.ConnectToken) string { + userId := tokenInfo.User.ID + assetId := tokenInfo.Asset.ID + ip := tokenInfo.Asset.Address + port := tokenInfo.Asset.ProtocolPort("ssh") + accountUsername := tokenInfo.Account.Username + return fmt.Sprintf("SSHD_%s_%s_%s_%d_%s", + userId, assetId, ip, port, accountUsername) +} diff --git a/pkg/handler/server_ssh_forward.go b/pkg/handler/server_ssh_forward.go new file mode 100644 index 000000000..77a476783 --- /dev/null +++ b/pkg/handler/server_ssh_forward.go @@ -0,0 +1,240 @@ +package handler + +import ( + "context" + "fmt" + "io" + "net" + "strconv" + + "github.com/gliderlabs/ssh" + "github.com/jumpserver/koko/pkg/srvconn" + gossh "golang.org/x/crypto/ssh" + + modelCommon "github.com/jumpserver-dev/sdk-go/common" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver/koko/pkg/auth" + "github.com/jumpserver/koko/pkg/config" + "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/session" +) + +const ( + ChannelTCPIPForward = "tcpip-forward" + ChannelCancelTCPIPForward = "cancel-tcpip-forward" + ChannelForwardedTCPIP = "forwarded-tcpip" +) + +func (s *Server) ReversePortForwardingPermission(ctx ssh.Context, dstHost string, dstPort uint32) bool { + logger.Debugf("Reverse Port Forwarding: %s %s %d", ctx.User(), dstHost, dstPort) + return config.GlobalConfig.EnableReversePortForward +} + +func (s *Server) HandleSSHRequest(ctx ssh.Context, srv *ssh.Server, req *gossh.Request) (bool, []byte) { + reqId, ok := ctx.Value(ctxID).(string) + if !ok { + logger.Errorf("cannot get request id from context") + return false, []byte("port forwarding is disabled") + } + switch req.Type { + case ChannelTCPIPForward: + var reqPayload remoteForwardRequest + if err := gossh.Unmarshal(req.Payload, &reqPayload); err != nil { + logger.Errorf("parse tcpip-forward request failed: %s", err.Error()) + return false, []byte{} + } + if srv.ReversePortForwardingCallback == nil || !srv.ReversePortForwardingCallback(ctx, reqPayload.BindAddr, reqPayload.BindPort) { + return false, []byte("port forwarding is disabled") + } + addr := net.JoinHostPort(reqPayload.BindAddr, strconv.Itoa(int(reqPayload.BindPort))) + + vsReq := s.getVSCodeReq(reqId) + if vsReq == nil { + user := ctx.Value(auth.ContextKeyUser).(*model.User) + directReq := ctx.Value(auth.ContextKeyDirectLoginFormat) + directRequest, ok3 := directReq.(*auth.DirectLoginAssetReq) + if !ok3 { + return false, []byte("port forwarding is disabled, must be direct login request") + } + var tokenInfo *model.ConnectToken + var err error + if directRequest.IsToken() { + // connection token 的方式使用 vscode 连接 + tokenInfo = directRequest.ConnectToken + matchedProtocol := tokenInfo.Protocol == model.ProtocolSSH + assetSupportedSSH := tokenInfo.Asset.IsSupportProtocol(model.ProtocolSSH) + if !matchedProtocol || !assetSupportedSSH { + msg := "not ssh asset connection token" + logger.Errorf("ide support failed: %s", msg) + return false, []byte(msg) + } + } else { + tokenInfo, err = s.buildConnectToken(ctx, user, directRequest) + if err != nil { + msg := "cannot build connect token" + logger.Errorf("ide supoort failed, err:%s", err.Error()) + return false, []byte(msg) + } + } + sshClient, err1 := s.buildSSHClient(tokenInfo) + if err1 != nil { + msg := "cannot build ssh client" + logger.Errorf("ide support failed: %s", msg) + return false, []byte(msg) + } + host, _, _ := net.SplitHostPort(ctx.RemoteAddr().String()) + reqSession := tokenInfo.CreateSession(host, model.LoginFromSSH, model.TUNNELType) + respSession, err := s.jmsService.CreateSession(reqSession) + if err != nil { + logger.Errorf("Create reverse port tunnel session err: %s", err) + return false, []byte("cannot create tunnel session") + } + childCtx, cancel := context.WithCancel(ctx) + traceSession := session.NewSession(&respSession, func(task *model.TerminalTask) error { + switch task.Name { + case model.TaskKillSession: + cancel() + logger.Info("ide session killed as task kill session") + return nil + case model.TaskPermExpired: + cancel() + logger.Info("ide session killed as task perm expired") + return nil + case model.TaskPermValid: + return nil + } + return fmt.Errorf("ssh proxy not support task: %s", task.Name) + }) + session.AddSession(traceSession) + defer func() { + if _, err2 := s.jmsService.SessionFinished(respSession.ID, modelCommon.NewNowUTCTime()); err2 != nil { + logger.Errorf("Finish tunnel session err: %s", err2) + } + session.RemoveSession(traceSession) + }() + s.recordSessionLifecycle(respSession.ID, model.AssetConnectSuccess, "") + vsReq = &vscodeReq{ + reqId: reqId, + user: user, + client: sshClient, + forwards: make(map[string]net.Listener), + } + go func() { + s.addVSCodeReq(vsReq) + defer s.deleteVSCodeReq(vsReq) + <-childCtx.Done() + if sshClient.KeyId != "" { + srvconn.ReleaseClientCacheKey(sshClient.KeyId, sshClient) + } else { + _ = sshClient.Close() + } + logger.Info("ide client removed, all alive forward will be closed by default") + if _, err2 := s.jmsService.SessionFinished(respSession.ID, modelCommon.NewNowUTCTime()); err2 != nil { + logger.Errorf("Create tunnel session err: %s", err2) + } + session.RemoveSession(traceSession) + s.recordSessionLifecycle(respSession.ID, model.AssetConnectFinished, "") + }() + } + + ln, err := vsReq.client.Listen("tcp", addr) + if err != nil { + logger.Errorf("port forwarding listen failed: %s", err.Error()) + return false, []byte("port forwarding is failed, cannot listen tcp") + } + go func() { + vsReq.AddForward(addr, ln) + defer vsReq.RemoveForward(addr) + <-ctx.Done() + logger.Info("ide port forward removed") + }() + _, destPortStr, _ := net.SplitHostPort(ln.Addr().String()) + destPort, _ := strconv.Atoi(destPortStr) + go func() { + for { + c, err2 := ln.Accept() + if err2 != nil { + if err2 != io.EOF { + logger.Errorf("accept failed: %s", err2.Error()) + } else { + logger.Infof("accept failed: %s", err2.Error()) + } + break + } + originAddr, orignPortStr, _ := net.SplitHostPort(c.RemoteAddr().String()) + originPort, _ := strconv.Atoi(orignPortStr) + payload := gossh.Marshal(&remoteForwardChannelData{ + DestAddr: reqPayload.BindAddr, + DestPort: uint32(destPort), + OriginAddr: originAddr, + OriginPort: uint32(originPort), + }) + conn := ctx.Value(ssh.ContextKeyConn).(*gossh.ServerConn) + go func() { + ch, reqs, err1 := conn.OpenChannel(ChannelForwardedTCPIP, payload) + if err1 != nil { + logger.Errorf("open forwarded-tcpip channel failed: %s", err1.Error()) + return + } + go gossh.DiscardRequests(reqs) + go func() { + defer func() { + _ = ch.Close() + _ = c.Close() + }() + _, _ = io.Copy(ch, c) + }() + go func() { + defer func() { + _ = ch.Close() + _ = c.Close() + }() + _, _ = io.Copy(c, ch) + }() + }() + } + }() + return true, gossh.Marshal(&remoteForwardSuccess{uint32(destPort)}) + + case ChannelCancelTCPIPForward: + vsReq := s.getVSCodeReq(reqId) + if vsReq == nil { + return false, []byte("port forwarding is disabled, cannot found alive connection") + } + var reqPayload remoteForwardCancelRequest + if err := gossh.Unmarshal(req.Payload, &reqPayload); err != nil { + logger.Errorf("parse cancel-tcpip-forward request failed: %s", err.Error()) + return false, []byte{} + } + addr := net.JoinHostPort(reqPayload.BindAddr, strconv.Itoa(int(reqPayload.BindPort))) + ln := vsReq.GetForward(addr) + if ln != nil { + _ = ln.Close() + vsReq.RemoveForward(addr) + } + return true, nil + default: + return false, nil + } +} + +type remoteForwardChannelData struct { + DestAddr string + DestPort uint32 + OriginAddr string + OriginPort uint32 +} + +type remoteForwardRequest struct { + BindAddr string + BindPort uint32 +} + +type remoteForwardSuccess struct { + BindPort uint32 +} + +type remoteForwardCancelRequest struct { + BindAddr string + BindPort uint32 +} diff --git a/pkg/handler/sftp.go b/pkg/handler/sftp.go index 65582b140..4eccc925c 100644 --- a/pkg/handler/sftp.go +++ b/pkg/handler/sftp.go @@ -9,25 +9,22 @@ import ( "github.com/pkg/sftp" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/proxy" "github.com/jumpserver/koko/pkg/srvconn" ) -func NewSFTPHandler(jmsService *service.JMService, user *model.User, addr string) *sftpHandler { - return &sftpHandler{UserSftpConn: srvconn.NewUserSftpConn(jmsService, user, addr)} -} - -type sftpHandler struct { +type SftpHandler struct { *srvconn.UserSftpConn + + recorder *proxy.FTPFileRecorder } -func (fs *sftpHandler) Filelist(r *sftp.Request) (sftp.ListerAt, error) { +func (s *SftpHandler) Filelist(r *sftp.Request) (sftp.ListerAt, error) { switch r.Method { case "List": logger.Debug("List method: ", r.Filepath) - res, err := fs.ReadDir(r.Filepath) + res, err := s.ReadDir(r.Filepath) fileInfos := make(listerat, 0, len(res)) for i := 0; i < len(res); i++ { fileInfos = append(fileInfos, &wrapperSFTPFileInfo{f: res[i]}) @@ -35,18 +32,18 @@ func (fs *sftpHandler) Filelist(r *sftp.Request) (sftp.ListerAt, error) { return fileInfos, err case "Stat": logger.Debug("stat method: ", r.Filepath) - fsInfo, err := fs.Stat(r.Filepath) + fsInfo, err := s.Stat(r.Filepath) return listerat([]os.FileInfo{fsInfo}), err case "Readlink": logger.Debug("Readlink method", r.Filepath) - filename, err := fs.ReadLink(r.Filepath) + filename, err := s.ReadLink(r.Filepath) fsInfo := srvconn.NewFakeSymFile(filename) return listerat([]os.FileInfo{&wrapperSFTPFileInfo{f: fsInfo}}), err } return nil, sftp.ErrSshFxOpUnsupported } -func (fs *sftpHandler) Filecmd(r *sftp.Request) (err error) { +func (s *SftpHandler) Filecmd(r *sftp.Request) (err error) { logger.Debug("File cmd: ", r.Filepath) switch r.Method { @@ -54,57 +51,87 @@ func (fs *sftpHandler) Filecmd(r *sftp.Request) (err error) { return case "Rename": logger.Debugf("%s=>%s", r.Filepath, r.Target) - return fs.Rename(r.Filepath, r.Target) + return s.Rename(r.Filepath, r.Target) case "Rmdir": - err = fs.RemoveDirectory(r.Filepath) + logger.Debug("Remove directory: ", r.Filepath) + err = s.RemoveDirectory(r.Filepath) case "Remove": - err = fs.Remove(r.Filepath) + logger.Debug("Remove: ", r.Filepath) + err = s.Remove(r.Filepath) case "Mkdir": - err = fs.MkdirAll(r.Filepath) + logger.Debug("Mkdir: ", r.Filepath) + err = s.MkdirAll(r.Filepath) case "Symlink": logger.Debugf("%s=>%s", r.Filepath, r.Target) - err = fs.Symlink(r.Filepath, r.Target) + err = s.Symlink(r.Filepath, r.Target) default: + logger.Debug("Unsupported method: ", r.Method) return } return } -func (fs *sftpHandler) Filewrite(r *sftp.Request) (io.WriterAt, error) { +func (s *SftpHandler) Filewrite(r *sftp.Request) (io.WriterAt, error) { logger.Debug("File write: ", r.Filepath) - f, err := fs.Create(r.Filepath) + f, err := s.Create(r.Filepath) if err != nil { return nil, err } + go func() { <-r.Context().Done() + + fileInfo, err2 := f.Stat() + if err2 != nil { + logger.Errorf("Get file %s stat err: %s", r.Filepath, err2) + return + } + + if err1 := s.recorder.ChunkedRecord(f.FTPLog, f, 0, fileInfo.Size()); err1 != nil { + logger.Errorf("Record file %s err: %s", r.Filepath, err1) + } + if err := f.Close(); err != nil { logger.Errorf("Remote sftp file %s close err: %s", r.Filepath, err) } logger.Infof("Sftp file write %s done", r.Filepath) + s.recorder.FinishFTPFile(f.FTPLog.ID) }() - return NewWriterAt(f), err + return NewWriterAt(f, s.recorder), err } -func (fs *sftpHandler) Fileread(r *sftp.Request) (io.ReaderAt, error) { +func (s *SftpHandler) Fileread(r *sftp.Request) (io.ReaderAt, error) { logger.Debug("File read: ", r.Filepath) - f, err := fs.Open(r.Filepath) + f, err := s.Open(r.Filepath) + if err != nil { + return nil, err + } + + fileInfo, err := f.Stat() if err != nil { return nil, err } + go func() { <-r.Context().Done() - if err := f.Close(); err != nil { - logger.Errorf("Remote sftp file %s close err: %s", r.Filepath, err) + + if err1 := s.recorder.ChunkedRecord(f.FTPLog, f, 0, fileInfo.Size()); err1 != nil { + logger.Errorf("Record file %s err: %s", r.Filepath, err1) + } + + if err2 := f.Close(); err2 != nil { + logger.Errorf("Remote sftp file %s close err: %s", r.Filepath, err2) } - logger.Infof("Sftp File read %s done", r.Filepath) + logger.Infof("Sftp File read %s done", r.Filepath) + s.recorder.FinishFTPFile(f.FTPLog.ID) }() - return f, err + // 包裹一层,兼容 WinSCP 目录的批量下载 + return NewReaderAt(f), err } -func (fs *sftpHandler) Close() { - fs.UserSftpConn.Close() +func (s *SftpHandler) Close() { + s.UserSftpConn.Close() } type listerat []os.FileInfo @@ -121,20 +148,27 @@ func (f listerat) ListAt(ls []os.FileInfo, offset int64) (int, error) { return n, nil } -func NewWriterAt(f *sftp.File) io.WriterAt { +func NewWriterAt(f *srvconn.SftpFile, recorder *proxy.FTPFileRecorder) io.WriterAt { + return &clientReadWritAt{f: f, mu: new(sync.RWMutex), recorder: recorder} +} + +func NewReaderAt(f *srvconn.SftpFile) io.ReaderAt { return &clientReadWritAt{f: f, mu: new(sync.RWMutex)} } type clientReadWritAt struct { - f *sftp.File + f *srvconn.SftpFile mu *sync.RWMutex + + recorder *proxy.FTPFileRecorder } func (c *clientReadWritAt) WriteAt(p []byte, off int64) (n int, err error) { - c.mu.Lock() - defer c.mu.Unlock() - _, _ = c.f.Seek(off, 0) - return c.f.Write(p) + return c.f.WriteAt(p, off) +} + +func (c *clientReadWritAt) ReadAt(p []byte, off int64) (n int, err error) { + return c.f.ReadAt(p, off) } type wrapperSFTPFileInfo struct { diff --git a/pkg/httpd/chat.go b/pkg/httpd/chat.go new file mode 100644 index 000000000..5f881b63c --- /dev/null +++ b/pkg/httpd/chat.go @@ -0,0 +1,192 @@ +package httpd + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver/koko/pkg/common" + "github.com/jumpserver/koko/pkg/logger" + "github.com/sashabaranov/go-openai" + + "github.com/jumpserver/koko/pkg/srvconn" +) + +var _ Handler = (*chat)(nil) + +type chat struct { + ws *UserWebsocket + + // conversations: map[conversationID]*AIConversation + conversations sync.Map + + term *model.TerminalConfig +} + +func (h *chat) Name() string { + return ChatName +} + +func (h *chat) CleanUp() { h.cleanupAll() } + +func (h *chat) CheckValidation() error { + return nil +} + +func (h *chat) HandleMessage(msg *Message) { + if msg.Interrupt { + h.interrupt(msg.Id) + return + } + + conv, err := h.getOrCreateConversation(msg) + if err != nil { + h.sendError(msg.Id, err.Error()) + return + } + conv.Question = msg.Data + + go h.runChat(conv) +} + +func (h *chat) getOrCreateConversation(msg *Message) (*AIConversation, error) { + if msg.Id != "" { + if v, ok := h.conversations.Load(msg.Id); ok { + return v.(*AIConversation), nil + } + return nil, fmt.Errorf("conversation %s not found", msg.Id) + } + + conv := &AIConversation{ + Id: common.UUID(), + Prompt: msg.Prompt, + Context: make([]QARecord, 0), + } + h.conversations.Store(conv.Id, conv) + return conv, nil +} + +func (h *chat) runChat(conv *AIConversation) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + client := srvconn.NewOpenAIClient( + h.term.GptApiKey, h.term.GptBaseUrl, h.term.GptProxy, + ) + + // Keep the last 8 contexts + if len(conv.Context) > 8 { + conv.Context = conv.Context[len(conv.Context)-8:] + } + messages := buildChatMessages(conv) + + chatModel := conv.Model + if conv.Model == "" { + chatModel = h.term.GptModel + } + + conn := &srvconn.OpenAIConn{ + Id: conv.Id, + Client: client, + Prompt: conv.Prompt, + Model: chatModel, + Question: conv.Question, + Context: messages, + AnswerCh: make(chan string), + DoneCh: make(chan string), + IsReasoning: false, + Type: h.term.ChatAIType, + } + + // 启动 streaming + go conn.Chat(&conv.InterruptCurrentChat) + + h.streamResponses(ctx, conv, conn) +} + +func buildChatMessages(conv *AIConversation) []openai.ChatCompletionMessage { + chatMessages := make([]openai.ChatCompletionMessage, 0, len(conv.Context)*2) + for _, r := range conv.Context { + chatMessages = append(chatMessages, + openai.ChatCompletionMessage{Role: openai.ChatMessageRoleUser, Content: r.Question}, + openai.ChatCompletionMessage{Role: openai.ChatMessageRoleAssistant, Content: r.Answer}, + ) + } + return chatMessages +} + +func (h *chat) streamResponses( + ctx context.Context, conv *AIConversation, conn *srvconn.OpenAIConn, +) { + msgID := common.UUID() + for { + select { + case <-ctx.Done(): + h.sendError(conv.Id, "chat timeout") + return + case ans := <-conn.AnswerCh: + h.sendMessage(conv.Id, msgID, ans, "message", conn.IsReasoning) + case ans := <-conn.DoneCh: + h.sendMessage(conv.Id, msgID, ans, "finish", false) + h.finalizeConversation(conv, ans) + return + } + } +} + +func (h *chat) finalizeConversation(conv *AIConversation, fullAnswer string) { + runes := []rune(fullAnswer) + snippet := fullAnswer + if len(runes) > 100 { + snippet = string(runes[:100]) + } + conv.Context = append(conv.Context, QARecord{Question: conv.Question, Answer: snippet}) + +} + +func (h *chat) sendMessage( + convID, msgID, content, typ string, reasoning bool, +) { + msg := ChatGPTMessage{ + Content: content, + ID: msgID, + CreateTime: time.Now(), + Type: typ, + Role: openai.ChatMessageRoleAssistant, + IsReasoning: reasoning, + } + data, _ := json.Marshal(msg) + h.ws.SendMessage(&Message{Id: convID, Type: "message", Data: string(data)}) +} + +func (h *chat) sendError(convID, errMsg string) { + h.endConversation(convID, "error", errMsg) +} + +func (h *chat) endConversation(convID, typ, msg string) { + + defer func() { + if r := recover(); r != nil { + logger.Errorf("panic while sending message to session %s: %v", convID, r) + } + }() + + h.conversations.Delete(convID) + h.ws.SendMessage(&Message{Id: convID, Type: typ, Data: msg}) +} + +func (h *chat) interrupt(convID string) { + if v, ok := h.conversations.Load(convID); ok { + v.(*AIConversation).InterruptCurrentChat = true + } +} + +func (h *chat) cleanupAll() { + h.conversations.Range(func(key, _ interface{}) bool { + h.endConversation(key.(string), "close", "") + return true + }) +} diff --git a/pkg/httpd/client.go b/pkg/httpd/client.go index 37d9fb1d9..1dc21a162 100644 --- a/pkg/httpd/client.go +++ b/pkg/httpd/client.go @@ -1,15 +1,16 @@ package httpd import ( + "bytes" "context" "encoding/json" "io" "sync" + "time" "github.com/gliderlabs/ssh" "github.com/jumpserver/koko/pkg/exchange" - "github.com/jumpserver/koko/pkg/logger" ) @@ -21,6 +22,16 @@ type Client struct { pty ssh.Pty sync.Mutex + + // 用于防抖处理 + buffer bytes.Buffer + bufferMutex sync.Mutex + timer *time.Timer + + KubernetesId string + Namespace string + Pod string + Container string } func (c *Client) WinCh() <-chan ssh.Window { @@ -41,16 +52,61 @@ func (c *Client) Read(p []byte) (n int, err error) { return c.UserRead.Read(p) } +// 向客户端发送数据进行1毫秒的防抖处理 func (c *Client) Write(p []byte) (n int, err error) { + category := "" + connectToken := c.Conn.ConnectToken + if connectToken != nil { + category = connectToken.Platform.Category.Value + } + + if category == "database" { + c.bufferMutex.Lock() + c.buffer.Write(p) + c.bufferMutex.Unlock() + + if c.timer == nil { + c.timer = time.AfterFunc(time.Millisecond, c.flushBuffer) + } + return len(p), nil + + } + + messageType := TerminalBinary + if c.KubernetesId != "" { + messageType = TerminalK8SBinary + } + msg := Message{ - Id: c.Conn.Uuid, - Type: TERMINALBINARY, - Raw: p, + Id: c.Conn.Uuid, + Type: messageType, + Raw: p, + KubernetesId: c.KubernetesId, } c.Conn.SendMessage(&msg) return len(p), nil } +func (c *Client) flushBuffer() { + c.bufferMutex.Lock() + defer c.bufferMutex.Unlock() + + if c.buffer.Len() > 0 { + msg := Message{ + Id: c.Conn.Uuid, + Type: TerminalBinary, + Raw: c.buffer.Bytes(), + } + c.Conn.SendMessage(&msg) + c.buffer.Reset() + } + + if c.buffer.Len() == 0 && c.timer != nil { + c.timer.Stop() + c.timer = nil + } +} + func (c *Client) Pty() ssh.Pty { return c.pty } @@ -94,30 +150,56 @@ func (c *Client) HandleRoomEvent(event string, roomMsg *exchange.RoomMessage) { ) switch event { case exchange.ShareJoin: - msgType = TERMINALSHAREJOIN + msgType = TerminalShareJoin data, _ := json.Marshal(roomMsg.Meta) msgData = string(data) case exchange.ShareLeave: - msgType = TERMINALSHARELEAVE + msgType = TerminalShareLeave data, _ := json.Marshal(roomMsg.Meta) msgData = string(data) case exchange.ShareUsers: - msgType = TERMINALSHAREUSERS + msgType = TerminalShareUsers msgData = string(roomMsg.Body) case exchange.WindowsEvent: - msgType = TERMINALRESIZE + msgType = TerminalResize msgData = string(roomMsg.Body) case exchange.ActionEvent: - msgType = TERMINALACTION + msgType = TerminalAction + msgData = string(roomMsg.Body) + case exchange.ShareRemoveUser: + msgType = TerminalShareUserRemove + meta := roomMsg.Meta + if meta.TerminalId != c.Conn.Uuid { + logger.Debugf("Remove share user Ignore not self: %+v", meta.User) + return + } + logger.Infof("Remove share user self: %+v", meta.User) + msgData = string(roomMsg.Body) + case exchange.PauseEvent: + msgType = TerminalSessionPause + msgData = string(roomMsg.Body) + logger.Debugf("Pause terminal session : %+v", roomMsg) + case exchange.ResumeEvent: + msgType = TerminalSessionResume + msgData = string(roomMsg.Body) + logger.Debugf("Resume terminal session : %+v", roomMsg) + case exchange.PermValidEvent: + msgType = TerminalPermValid + msgData = string(roomMsg.Body) + logger.Debugf("Terminal perm is valid : %+v", roomMsg) + case exchange.PermExpiredEvent: + msgType = TerminalPermExpired msgData = string(roomMsg.Body) + logger.Debugf("Terminal perm is expired : %+v", roomMsg) default: logger.Infof("unsupported room msg %+v", roomMsg) return } var msg = Message{ - Id: c.Conn.Uuid, - Type: msgType, - Data: msgData, + Id: c.Conn.Uuid, + Type: msgType, + Data: msgData, + KubernetesId: c.KubernetesId, } c.Conn.SendMessage(&msg) } diff --git a/pkg/httpd/message.go b/pkg/httpd/message.go index ee0f648d5..b5cca63e3 100644 --- a/pkg/httpd/message.go +++ b/pkg/httpd/message.go @@ -3,36 +3,76 @@ package httpd import ( "time" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" + "github.com/jumpserver/koko/pkg/exchange" + + "github.com/jumpserver-dev/sdk-go/model" ) type Message struct { Id string `json:"id"` Type string `json:"type"` Data string `json:"data"` - Raw []byte `json:"-"` + Raw []byte `json:"raw"` Err string `json:"err"` + + //Chat AI + Prompt string `json:"prompt"` + Interrupt bool `json:"interrupt"` + + //K8s + KubernetesId string `json:"k8s_id"` + Namespace string `json:"namespace"` + Pod string `json:"pod"` + Container string `json:"container"` + + // Sftp + Cmd string `json:"cmd"` + CurrentPath string `json:"current_path"` } const ( - PING = "PING" - PONG = "PONG" - CONNECT = "CONNECT" - CLOSE = "CLOSE" - TERMINALINIT = "TERMINAL_INIT" - TERMINALDATA = "TERMINAL_DATA" - TERMINALRESIZE = "TERMINAL_RESIZE" - TERMINALBINARY = "TERMINAL_BINARY" - TERMINALACTION = "TERMINAL_ACTION" - - TERMINALSESSION = "TERMINAL_SESSION" - - TERMINALSHARE = "TERMINAL_SHARE" - TERMINALSHAREJOIN = "TERMINAL_SHARE_JOIN" - TERMINALSHARELEAVE = "TERMINAL_SHARE_LEAVE" - TERMINALSHAREUSERS = "TERMINAL_SHARE_USERS" - - TERMINALERROR = "TERMINAL_ERROR" + PING = "PING" + PONG = "PONG" + CONNECT = "CONNECT" + CLOSE = "CLOSE" + ERROR = "ERROR" + + TerminalInit = "TERMINAL_INIT" + TerminalData = "TERMINAL_DATA" + TerminalResize = "TERMINAL_RESIZE" + TerminalBinary = "TERMINAL_BINARY" + TerminalAction = "TERMINAL_ACTION" + TerminalSession = "TERMINAL_SESSION" + + TerminalSessionPause = "TERMINAL_SESSION_PAUSE" + TerminalSessionResume = "TERMINAL_SESSION_RESUME" + + TerminalPermValid = "TERMINAL_PERM_VALID" + TerminalPermExpired = "TERMINAL_PERM_EXPIRED" + + TerminalShare = "TERMINAL_SHARE" + TerminalShareJoin = "TERMINAL_SHARE_JOIN" + TerminalShareLeave = "TERMINAL_SHARE_LEAVE" + TerminalShareUsers = "TERMINAL_SHARE_USERS" + TerminalGetShareUser = "TERMINAL_GET_SHARE_USER" + + TerminalShareUserRemove = "TERMINAL_SHARE_USER_REMOVE" + + TerminalSyncUserPreference = "TERMINAL_SYNC_USER_PREFERENCE" + + TerminalError = "TERMINAL_ERROR" + + MessageNotify = "MESSAGE_NOTIFY" + + TerminalK8SInit = "TERMINAL_K8S_INIT" + TerminalK8STree = "TERMINAL_K8S_TREE" + TerminalK8SData = "TERMINAL_K8S_DATA" + TerminalK8SBinary = "TERMINAL_K8S_BINARY" + TerminalK8SResize = "TERMINAL_K8S_RESIZE" + K8SClose = "K8S_CLOSE" + + SFTPData = "SFTP_DATA" + SFTPBinary = "SFTP_BINARY" ) type WindowSize struct { @@ -46,9 +86,21 @@ type TerminalConnectData struct { Code string `json:"code"` } +type ShareRequestMeta struct { + Users []string `json:"users"` +} + type ShareRequestParams struct { - SessionID string `json:"session_id"` - ExpireTime int `json:"expired"` + model.SharingSessionRequest +} + +type GetUserParams struct { + Query string `json:"query"` +} + +type RemoveSharingUserParams struct { + SessionId string `json:"session"` + UserMeta exchange.MetaMessage `json:"user_meta"` } type ShareResponse struct { @@ -60,11 +112,12 @@ type ShareInfo struct { Record model.ShareRecord } -const ( - TargetTypeAsset = "asset" +type UserKoKoPreferenceParam struct { + ThemeName string `json:"terminal_theme_name"` +} - // TargetTypeMonitor todo: 前端参数将 统一修改成 monitor - TargetTypeMonitor = "shareroom" +const ( + TargetTypeMonitor = "monitor" TargetTypeShare = "share" ) @@ -77,9 +130,58 @@ const ( const ( TTYName = "terminal" WebFolderName = "web_folder" + ChatName = "chat" ) type ViewPageMata struct { ID string IconURL string } + +type WsRequestParams struct { + TargetType string `form:"type"` + TargetId string `form:"target_id"` + Token string `form:"token"` + + AssetId string `form:"asset"` + + // k8s container + Pod string `form:"pod"` + Namespace string `form:"namespace"` + Container string `form:"container"` + + // mysql database + DisableAutoHash string `form:"disableautohash"` +} + +type OpenAIParam struct { + AuthToken string + BaseURL string + Proxy string + Model string + Prompt string + Type string +} + +type QARecord struct { + Question string + Answer string +} + +type AIConversation struct { + Id string + Prompt string + Question string + Model string + Context []QARecord + InterruptCurrentChat bool +} + +type ChatGPTMessage struct { + ID string `json:"id"` + Content string `json:"content"` + CreateTime time.Time `json:"create_time,omitempty"` + Type string `json:"type"` + Role string `json:"role"` + IsReasoning bool `json:"is_reasoning"` +} diff --git a/pkg/httpd/sftpvolume.go b/pkg/httpd/sftpvolume.go index 274cb46d2..704a5c257 100644 --- a/pkg/httpd/sftpvolume.go +++ b/pkg/httpd/sftpvolume.go @@ -12,42 +12,94 @@ import ( "github.com/LeeEirc/elfinder" "github.com/pkg/sftp" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" + "github.com/jumpserver/koko/pkg/common" "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/proxy" "github.com/jumpserver/koko/pkg/srvconn" ) -func NewUserVolume(jmsService *service.JMService, user *model.User, addr, hostId string) *UserVolume { - var userSftp *srvconn.UserSftpConn - homename := "Home" +type volumeOption struct { + addr string + user *model.User + asset *model.PermAsset + connectToken *model.ConnectToken + terminalCfg *model.TerminalConfig +} +type VolumeOption func(*volumeOption) + +func WithUser(user *model.User) VolumeOption { + return func(opts *volumeOption) { + opts.user = user + } +} + +func WithAddr(addr string) VolumeOption { + return func(opts *volumeOption) { + opts.addr = addr + } +} + +func WithAsset(asset *model.PermAsset) VolumeOption { + return func(opts *volumeOption) { + opts.asset = asset + } +} + +func WithConnectToken(connectToken *model.ConnectToken) VolumeOption { + return func(opts *volumeOption) { + opts.connectToken = connectToken + } +} + +func WithTerminalCfg(cfg *model.TerminalConfig) VolumeOption { + return func(opts *volumeOption) { + opts.terminalCfg = cfg + } + +} + +func NewUserVolume(jmsService *service.JMService, opts ...VolumeOption) *UserVolume { + var volOpts volumeOption + for _, opt := range opts { + opt(&volOpts) + } + homeName := "Home" basePath := "/" - switch hostId { - case "": - userSftp = srvconn.NewUserSftpConn(jmsService, user, addr) - default: - assets, err := jmsService.GetUserAssetByID(user.ID, hostId) - if err != nil { - logger.Errorf("Get user asset failed: %s", err) - } - if len(assets) == 1 { - folderName := assets[0].Hostname - if strings.Contains(folderName, "/") { - folderName = strings.ReplaceAll(folderName, "/", "_") - } - homename = folderName - basePath = filepath.Join("/", homename) + asset := volOpts.asset + if asset != nil { + folderName := asset.Name + if strings.Contains(folderName, "/") { + folderName = strings.ReplaceAll(folderName, "/", "_") } - userSftp = srvconn.NewUserSftpConnWithAssets(jmsService, user, addr, assets...) + homeName = folderName + basePath = filepath.Join("/", homeName) } - rawID := fmt.Sprintf("%s@%s", user.Username, addr) + sftpOpts := make([]srvconn.UserSftpOption, 0, 5) + if volOpts.connectToken != nil { + sftpOpts = append(sftpOpts, srvconn.WithConnectToken(volOpts.connectToken)) + } + if volOpts.asset != nil { + sftpOpts = append(sftpOpts, srvconn.WithAssets([]model.PermAsset{*volOpts.asset})) + } + sftpOpts = append(sftpOpts, srvconn.WithUser(volOpts.user)) + sftpOpts = append(sftpOpts, srvconn.WithRemoteAddr(volOpts.addr)) + sftpOpts = append(sftpOpts, srvconn.WithLoginFrom(model.LoginFromWeb)) + sftpOpts = append(sftpOpts, srvconn.WithTerminalCfg(volOpts.terminalCfg)) + userSftp := srvconn.NewUserSftpConn(jmsService, sftpOpts...) + rawID := fmt.Sprintf("%s@%s", volOpts.user.Username, volOpts.addr) + + recorder := proxy.GetFTPFileRecorder(jmsService) uVolume := &UserVolume{ Uuid: elfinder.GenerateID(rawID), UserSftp: userSftp, - Homename: homename, + HomeName: homeName, basePath: basePath, chunkFilesMap: make(map[int]*sftp.File), lock: new(sync.Mutex), + recorder: recorder, + ftpLogMap: make(map[int]*model.FTPLog), } return uVolume } @@ -55,11 +107,14 @@ func NewUserVolume(jmsService *service.JMService, user *model.User, addr, hostId type UserVolume struct { Uuid string UserSftp *srvconn.UserSftpConn - Homename string + HomeName string basePath string chunkFilesMap map[int]*sftp.File + ftpLogMap map[int]*model.FTPLog lock *sync.Mutex + + recorder *proxy.FTPFileRecorder } func (u *UserVolume) ID() string { @@ -143,6 +198,20 @@ func (u *UserVolume) Parents(path string, dep int) []elfinder.FileDir { } for i := 0; i < len(tmps); i++ { + if tmps[i].Mode()&os.ModeSymlink != 0 { + linkInfo := NewElfinderFileInfo(u.Uuid, dirPath, tmps[i]) + _, err2 := u.UserSftp.ReadDir(filepath.Join(u.basePath, dirPath, tmps[i].Name())) + if err2 != nil { + logger.Errorf("link file %s is not dir err: %s", tmps[i].Name(), err2) + } else { + logger.Infof("link file %s is dir", tmps[i].Name()) + linkInfo.Mime = "directory" + linkInfo.Dirs = 1 + } + dirs = append(dirs, linkInfo) + continue + } + dirs = append(dirs, NewElfinderFileInfo(u.Uuid, dirPath, tmps[i])) } @@ -154,17 +223,29 @@ func (u *UserVolume) Parents(path string, dep int) []elfinder.FileDir { return dirs } -func (u *UserVolume) GetFile(path string) (reader io.ReadCloser, err error) { +func (u *UserVolume) GetFile(path string) (fileData elfinder.FileData, err error) { logger.Debug("GetFile path: ", path) - sftpFile, err := u.UserSftp.Open(filepath.Join(u.basePath, TrimPrefix(path))) + var rest elfinder.FileData + sf, err := u.UserSftp.Open(filepath.Join(u.basePath, TrimPrefix(path))) if err != nil { - return nil, err + return rest, err + } + + fileInfo, err := sf.Stat() + if err != nil { + return rest, err } + + if err1 := u.recorder.Record(sf.FTPLog, sf); err1 != nil { + logger.Errorf("Record file err: %s", err1) + } + _, _ = sf.Seek(0, io.SeekStart) // 屏蔽 sftp*File 的 WriteTo 方法,防止调用 sftp stat 命令 - return &fileReader{sftpFile}, nil + fileData = elfinder.FileData{Reader: sf, Size: fileInfo.Size()} + return fileData, nil } -func (u *UserVolume) UploadFile(dirPath, uploadPath, filename string, reader io.Reader) (elfinder.FileDir, error) { +func (u *UserVolume) UploadFile(dirPath, uploadPath, filename string, reader io.Reader, totalSize int64) (elfinder.FileDir, error) { var path string switch { case strings.Contains(uploadPath, filename): @@ -175,7 +256,7 @@ func (u *UserVolume) UploadFile(dirPath, uploadPath, filename string, reader io. path = filepath.Join(dirPath, filename) } - logger.Debug("Volume upload file path: ", path, " ", filename, " ", uploadPath) + logger.Debug("Volume upload file path: ", path, "|", filename, "|", uploadPath) var rest elfinder.FileDir fd, err := u.UserSftp.Create(filepath.Join(u.basePath, path)) if err != nil { @@ -183,11 +264,20 @@ func (u *UserVolume) UploadFile(dirPath, uploadPath, filename string, reader io. } defer fd.Close() - _, err = io.Copy(fd, reader) + readerAt, ok := reader.(io.ReaderAt) + if !ok { + return rest, fmt.Errorf("the provided reader does not implement io.ReaderAt") + } + + if err1 := u.recorder.Record(fd.FTPLog, reader); err1 != nil { + logger.Errorf("Record file err: %s", err1) + } + + err = common.ChunkedFileTransfer(fd, readerAt, 0, totalSize) if err != nil { return rest, err } - return u.Info(path) + return u.Info(filepath.Join(filepath.Dir(path), filepath.Base(fd.FTPLog.Path))) } func (u *UserVolume) UploadChunk(cid int, dirPath, uploadPath, filename string, rangeData elfinder.ChunkRange, reader io.Reader) error { @@ -195,6 +285,7 @@ func (u *UserVolume) UploadChunk(cid int, dirPath, uploadPath, filename string, var path string u.lock.Lock() fd, ok := u.chunkFilesMap[cid] + ftpLog := u.ftpLogMap[cid] u.lock.Unlock() if !ok { switch { @@ -206,23 +297,40 @@ func (u *UserVolume) UploadChunk(cid int, dirPath, uploadPath, filename string, path = filepath.Join(dirPath, filename) } - fd, err = u.UserSftp.Create(filepath.Join(u.basePath, path)) + f, err := u.UserSftp.Create(filepath.Join(u.basePath, path)) if err != nil { return err } + fd = f.File + ftpLog = f.FTPLog _, err = fd.Seek(rangeData.Offset, 0) if err != nil { return err } u.lock.Lock() u.chunkFilesMap[cid] = fd + u.ftpLogMap[cid] = ftpLog u.lock.Unlock() } - _, err = io.Copy(fd, reader) + + fileSize := rangeData.Length + offset := rangeData.Offset + readerAt, ok := reader.(io.ReaderAt) + if !ok { + return fmt.Errorf("the provided reader does not implement io.ReaderAt") + } + + if err2 := u.recorder.ChunkedRecord(ftpLog, readerAt, offset, fileSize); err2 != nil { + logger.Errorf("Record file err: %s", err2) + } + + err = common.ChunkedFileTransfer(fd, readerAt, offset, fileSize) + if err != nil { _ = fd.Close() u.lock.Lock() delete(u.chunkFilesMap, cid) + delete(u.ftpLogMap, cid) u.lock.Unlock() } return err @@ -243,7 +351,10 @@ func (u *UserVolume) MergeChunk(cid, total int, dirPath, uploadPath, filename st u.lock.Lock() if fd, ok := u.chunkFilesMap[cid]; ok { _ = fd.Close() + ftpLog := u.ftpLogMap[cid] delete(u.chunkFilesMap, cid) + u.recorder.FinishFTPFile(ftpLog.ID) + delete(u.ftpLogMap, cid) } u.lock.Unlock() return u.Info(path) @@ -266,9 +377,21 @@ func (u *UserVolume) MakeFile(dir, newFilename string) (elfinder.FileDir, error) path := filepath.Join(dir, newFilename) var rest elfinder.FileDir fd, err := u.UserSftp.Create(filepath.Join(u.basePath, path)) + if err != nil { return rest, err } + + fileInfo, err := fd.Stat() + if err != nil { + return rest, err + } + + if err1 := u.recorder.ChunkedRecord(fd.FTPLog, fd, 0, fileInfo.Size()); err1 != nil { + logger.Errorf("Record file err: %s", err1) + } + + _, _ = fd.Seek(0, io.SeekStart) _ = fd.Close() res, err := u.UserSftp.Stat(filepath.Join(u.basePath, path)) @@ -302,7 +425,9 @@ func (u *UserVolume) Remove(path string) error { return u.UserSftp.Remove(filepath.Join(u.basePath, path)) } -func (u *UserVolume) Paste(dir, filename, suffix string, reader io.ReadCloser) (elfinder.FileDir, error) { +func (u *UserVolume) Paste(dir, filename, suffix string, fileData elfinder.FileData) (elfinder.FileDir, error) { + reader := fileData.Reader + totalSize := fileData.Size defer reader.Close() var rest elfinder.FileDir path := filepath.Join(dir, filename) @@ -316,7 +441,13 @@ func (u *UserVolume) Paste(dir, filename, suffix string, reader io.ReadCloser) ( return rest, err } defer fd.Close() - _, err = io.Copy(fd, reader) + + readerAt, ok := reader.(io.ReaderAt) + if !ok { + return rest, fmt.Errorf("the provided reader does not implement io.ReaderAt") + } + + err = common.ChunkedFileTransfer(fd, readerAt, 0, totalSize) if err != nil { return rest, err } @@ -337,7 +468,7 @@ func (u *UserVolume) RootFileDir() elfinder.FileDir { readPem, writePem = elfinder.ReadWritePem(fInfo.Mode()) } var rest elfinder.FileDir - rest.Name = u.Homename + rest.Name = u.HomeName rest.Hash = hashPath(u.Uuid, "/") rest.Size = size rest.Volumeid = u.Uuid @@ -397,19 +528,3 @@ func hashPath(id, path string) string { func TrimPrefix(path string) string { return strings.TrimPrefix(path, "/") } - -var ( - _ io.ReadCloser = (*fileReader)(nil) -) - -type fileReader struct { - read io.ReadCloser -} - -func (f *fileReader) Read(p []byte) (nr int, err error) { - return f.read.Read(p) -} - -func (f *fileReader) Close() error { - return f.read.Close() -} diff --git a/pkg/httpd/sftpwebvolume.go b/pkg/httpd/sftpwebvolume.go new file mode 100644 index 000000000..4c1eddb01 --- /dev/null +++ b/pkg/httpd/sftpwebvolume.go @@ -0,0 +1,306 @@ +package httpd + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/jumpserver/koko/pkg/common" + "github.com/jumpserver/koko/pkg/logger" +) + +const ( + defaultZipMaxSize = 1024 * 1024 * 1024 // 1G + defaultTmpPath = "/tmp" +) + +type FileInfo struct { + Name string `json:"name"` + Size string `json:"size"` + Perm string `json:"perm"` + ModTime string `json:"mod_time"` + Type string `json:"type"` + IsDir bool `json:"is_dir"` +} + +type FileData struct { + Reader io.ReadCloser + Size int64 + IsDir bool +} + +func NewUserWebVolume(userVolume *UserVolume) *UserWebVolume { + uVolume := &UserWebVolume{ + userVolume, + } + return uVolume +} + +type UserWebVolume struct { + *UserVolume +} + +func (u *UserWebVolume) List(path string) []FileInfo { + logger.Debug("Volume List: ", path) + files := make([]FileInfo, 0) + + originFiles, err := u.UserSftp.ReadDir(path) + if err != nil { + logger.Errorf("ReadDir %s failed: %s", path, err) + return files + } + + for _, info := range originFiles { + size := fmt.Sprintf("%d", info.Size()) + modTime := strconv.FormatInt(info.ModTime().Unix(), 10) + + fileInfo := FileInfo{ + Name: info.Name(), + Size: size, + Perm: info.Mode().String(), + ModTime: modTime, + IsDir: info.IsDir(), + } + + files = append(files, fileInfo) + } + return files +} + +func (u *UserWebVolume) Download(path string, isDir bool) (FileData, string, error) { + logger.Debug("WebVolume Download: ", path) + var rest FileData + fileName := filepath.Base(path) + if !isDir { + file, err := u.GetFile(path) + if err != nil { + logger.Errorf("Download file failed: %s", err) + return rest, fileName, err + } + return file, fileName, nil + } + + filename := fmt.Sprintf("%s-%s.zip", + filepath.Base(path), time.Now().UTC().Format("20060102150405")) + zipTmpPath := filepath.Join(defaultTmpPath, filename) + + dstFd, err := os.Create(zipTmpPath) + if err != nil { + return rest, fileName, err + } + defer dstFd.Close() + + zipWriter := zip.NewWriter(dstFd) + defer zipWriter.Close() + + if err := u.zipFolder(zipWriter, path, ""); err != nil { + logger.Errorf("Zip folder failed: %s", err) + return rest, fileName, err + } + + file, err := os.Open(zipTmpPath) + if err != nil { + logger.Errorf("Open zip file failed: %s", err) + return rest, fileName, err + } + + fileInfo, err := file.Stat() + if err != nil { + logger.Errorf("Get zip file stat failed: %s", err) + return rest, fileName, err + } + + return FileData{ + Reader: file, + Size: fileInfo.Size(), + IsDir: false, + }, filename, nil +} + +func (u *UserWebVolume) zipFolder(zipWriter *zip.Writer, remotePath, basePath string) error { + entries, err := u.UserSftp.ReadDir(remotePath) + if err != nil { + return fmt.Errorf("failed to read remote directory: %v", err) + } + + if len(entries) == 0 { + header := &zip.FileHeader{ + Name: basePath + "/", + Method: zip.Store, + } + header.Modified = time.Now().UTC() + + _, err := zipWriter.CreateHeader(header) + if err != nil { + return fmt.Errorf("failed to create zip header for empty folder: %v", err) + } + return nil + } + + for _, entry := range entries { + remoteFilePath := filepath.Join(remotePath, entry.Name()) + localRelativePath := filepath.Join(basePath, entry.Name()) + + if entry.IsDir() { + if err := u.zipFolder(zipWriter, remoteFilePath, localRelativePath); err != nil { + return err + } + } else { + if err := u.zipFile(zipWriter, remoteFilePath, localRelativePath); err != nil { + return err + } + } + } + return nil +} + +func (u *UserWebVolume) zipFile(zipWriter *zip.Writer, remotePath, zipPath string) error { + remoteFile, err := u.UserSftp.Open(remotePath) + if err != nil { + return fmt.Errorf("failed to open remote file: %v", err) + } + defer remoteFile.Close() + + header := &zip.FileHeader{ + Name: zipPath, + Method: zip.Deflate, + } + + header.Modified = time.Now().UTC() + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return fmt.Errorf("failed to create zip header: %v", err) + } + + _, err = io.Copy(writer, remoteFile) + if err != nil { + return fmt.Errorf("failed to copy file content to zip: %v", err) + } + + return nil +} + +func (u *UserWebVolume) GetFile(path string) (fileData FileData, err error) { + logger.Debug("WebVolume GetFile path: ", path) + var rest FileData + sf, err := u.UserSftp.Open(path) + if err != nil { + return rest, err + } + + fileInfo, err := sf.Stat() + size := fileInfo.Size() + if err != nil { + return rest, err + } + + if err1 := u.recorder.ChunkedRecord(sf.FTPLog, sf, 0, size); err1 != nil { + logger.Errorf("Record file err: %s", err1) + } + + _, _ = sf.Seek(0, io.SeekStart) + fileData = FileData{sf, size, fileInfo.IsDir()} + return fileData, nil +} + +func (u *UserWebVolume) Rename(oldNamePath, newName string) error { + logger.Debug("WebVolume Rename") + newNamePath := filepath.Join(filepath.Dir(oldNamePath), newName) + err := u.UserSftp.Rename( + filepath.Join(u.basePath, oldNamePath), + filepath.Join(u.basePath, newNamePath), + ) + return err +} + +func (u *UserWebVolume) MakeDir(path string) error { + logger.Debug("WebVolume MakeDir") + err := u.UserSftp.MkdirAll(filepath.Join(u.basePath, path)) + return err +} + +func (u *UserWebVolume) UploadFile(path string, reader io.Reader, totalSize int64) error { + logger.Debug("WebVolume upload file path: ", path) + fd, err := u.UserSftp.Create(filepath.Join(path)) + if err != nil { + return err + } + defer fd.Close() + + if err1 := u.recorder.Record(fd.FTPLog, reader); err1 != nil { + logger.Errorf("Record file err: %s", err1) + } + + readerAt, ok := reader.(io.ReaderAt) + if !ok { + logger.Debug("reader is not io.ReaderAt, use io.SeekStart") + return fmt.Errorf("reader is not io.ReaderAt") + } + + err = common.ChunkedFileTransfer(fd, readerAt, 0, totalSize) + if err != nil { + return err + } + return nil +} + +func (u *UserWebVolume) UploadChunk(cid int, path string, offset, dataSize int64, readerAt io.ReaderAt) error { + logger.Debug("WebVolume upload chunk file path: ", path) + var err error + u.lock.Lock() + fd, ok := u.chunkFilesMap[cid] + ftpLog := u.ftpLogMap[cid] + u.lock.Unlock() + if !ok { + f, err := u.UserSftp.Create(path) + if err != nil { + return err + } + fd = f.File + ftpLog = f.FTPLog + _, err = fd.Seek(offset, 0) + if err != nil { + return err + } + u.lock.Lock() + u.chunkFilesMap[cid] = fd + u.ftpLogMap[cid] = ftpLog + u.lock.Unlock() + } + + if err2 := u.recorder.ChunkedRecord(ftpLog, readerAt, offset, dataSize); err2 != nil { + logger.Errorf("Record file err: %s", err2) + } + + err = common.ChunkedFileTransfer(fd, readerAt, offset, dataSize) + + if err != nil { + _ = fd.Close() + u.lock.Lock() + delete(u.chunkFilesMap, cid) + delete(u.ftpLogMap, cid) + u.lock.Unlock() + } + return err +} + +func (u *UserWebVolume) MergeChunk(cid int, path string) error { + logger.Debug("WebVolume merge chunk path: ", path) + u.lock.Lock() + defer u.lock.Unlock() + fd, ok := u.chunkFilesMap[cid] + if !ok { + return fmt.Errorf("chunk file not found %d", cid) + } + _ = fd.Close() + ftpLog := u.ftpLogMap[cid] + delete(u.chunkFilesMap, cid) + u.recorder.FinishFTPFile(ftpLog.ID) + delete(u.ftpLogMap, cid) + return nil +} diff --git a/pkg/httpd/staticfs.go b/pkg/httpd/staticfs.go new file mode 100644 index 000000000..ad3101efd --- /dev/null +++ b/pkg/httpd/staticfs.go @@ -0,0 +1,41 @@ +package httpd + +import ( + "net/http" + "os" + "time" +) + +// copy from https://github.com/golang/go/issues/44854 + +type StaticFSWrapper struct { + http.FileSystem + FixedModTime time.Time +} + +func (f *StaticFSWrapper) Open(name string) (http.File, error) { + file, err := f.FileSystem.Open(name) + + return &StaticFileWrapper{File: file, fixedModTime: f.FixedModTime}, err +} + +type StaticFileWrapper struct { + http.File + fixedModTime time.Time +} + +func (f *StaticFileWrapper) Stat() (os.FileInfo, error) { + + fileInfo, err := f.File.Stat() + + return &StaticFileInfoWrapper{FileInfo: fileInfo, fixedModTime: f.fixedModTime}, err +} + +type StaticFileInfoWrapper struct { + os.FileInfo + fixedModTime time.Time +} + +func (f *StaticFileInfoWrapper) ModTime() time.Time { + return f.fixedModTime +} diff --git a/pkg/httpd/tty.go b/pkg/httpd/tty.go index 186d49d77..70e4ea45b 100644 --- a/pkg/httpd/tty.go +++ b/pkg/httpd/tty.go @@ -2,20 +2,18 @@ package httpd import ( "encoding/json" + "errors" "io" - "net/url" - "strings" "sync" - "github.com/gliderlabs/ssh" + "github.com/jumpserver/koko/pkg/srvconn" + "github.com/gliderlabs/ssh" + "github.com/jumpserver-dev/sdk-go/common" + "github.com/jumpserver-dev/sdk-go/model" "github.com/jumpserver/koko/pkg/exchange" - "github.com/jumpserver/koko/pkg/jms-sdk-go/common" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" "github.com/jumpserver/koko/pkg/logger" "github.com/jumpserver/koko/pkg/proxy" - "github.com/jumpserver/koko/pkg/srvconn" ) var _ Handler = (*tty)(nil) @@ -23,24 +21,16 @@ var _ Handler = (*tty)(nil) type tty struct { ws *UserWebsocket - targetType string - targetId string - systemUserId string - - initialed bool - wg sync.WaitGroup - systemUser *model.SystemUser - asset *model.Asset - - app *model.Application + initialed bool + wg sync.WaitGroup backendClient *Client - jmsService *service.JMService - shareInfo *ShareInfo - extraParams url.Values + K8sClients map[string]*Client + + sessionInfo *proxy.SessionInfo } func (h *tty) Name() string { @@ -51,41 +41,32 @@ func (h *tty) CleanUp() { if h.backendClient != nil { _ = h.backendClient.Close() } - h.wg.Wait() + + for id, client := range h.K8sClients { + _ = client.Close() + delete(h.K8sClients, id) + } } -func (h *tty) CheckValidation() bool { - var ok bool - switch h.targetType { +func (h *tty) CheckValidation() error { + var err error + params := h.ws.wsParams + switch params.TargetType { case TargetTypeMonitor: - ok = h.CheckShareRoomReadPerm(h.ws.user.ID, h.targetId) + return h.CheckMonitorReadPerm(h.ws.user.ID, params.TargetId) case TargetTypeShare: - ok = h.CheckEnableShare() + return h.CheckEnableShare() default: - if h.systemUserId == "" || h.targetId == "" { - logger.Errorf("Ws[%s] miss required query params.", h.ws.Uuid) - return false + if h.ws.ConnectToken == nil { + return errors.New("connect token is nil") } - systemUser, err := h.jmsService.GetSystemUserById(h.systemUserId) - if err != nil { - logger.Errorf("Ws[%s] get system user err: %s", h.ws.Uuid, err) - return false - } - if systemUser.ID == "" { - logger.Errorf("Ws[%s] get invalid system user", h.ws.Uuid) - return false - } - h.systemUser = &systemUser - - ok = h.getTargetApp(systemUser.Protocol) } - logger.Infof("Ws[%s] check connect type %s: %t", h.ws.Uuid, h.targetType, ok) - return ok + return err } func (h *tty) HandleMessage(msg *Message) { switch msg.Type { - case TERMINALINIT: + case TerminalInit: if msg.Id != h.ws.Uuid { logger.Errorf("Ws[%s] terminal initial unknown message id %s", h.ws.Uuid, msg.Id) return @@ -95,49 +76,31 @@ func (h *tty) HandleMessage(msg *Message) { return } - var connectInfo TerminalConnectData - err := json.Unmarshal([]byte(msg.Data), &connectInfo) + connectInfo, err := h.validateAndInitSession(msg) if err != nil { - logger.Errorf("Ws[%s] terminal initial message data unmarshal err: %s", - h.ws.Uuid, err) return } - if h.targetType == TargetTypeShare { - code := connectInfo.Code - info, err2 := h.ValidateShareParams(h.targetId, code) - if err2 != nil { - logger.Errorf("Ws[%s] terminal initial validate share err: %s", - h.ws.Uuid, err2) - h.sendCloseMessage() - return - } - h.shareInfo = &info - sessionInfo, err3 := h.jmsService.GetSessionById(info.Record.SessionId) - if err3 != nil { - logger.Errorf("Ws[%s] terminal get session %s err: %s", - h.ws.Uuid, info.Record.SessionId, err3) - h.sendCloseMessage() - return - } - data, _ := json.Marshal(sessionInfo) - h.sendSessionMessage(string(data)) - } + h.initialed = true - win := ssh.Window{ - Width: connectInfo.Cols, - Height: connectInfo.Rows, + h.handleTerminalInit(connectInfo, "", "", "", "") + return + + case TerminalK8SInit: + if msg.Id != h.ws.Uuid { + logger.Errorf("Ws[%s] terminal initial unknown message id %s", h.ws.Uuid, msg.Id) + return } - userR, userW := io.Pipe() - h.backendClient = &Client{ - WinChan: make(chan ssh.Window, 100), Conn: h.ws, - UserRead: userR, UserWrite: userW, - pty: ssh.Pty{Term: "xterm", Window: win}, + + connectInfo, err := h.validateAndInitSession(msg) + if err != nil { + return } - h.wg.Add(1) - go h.proxy(&h.wg) + + h.handleTerminalInit(connectInfo, msg.KubernetesId, msg.Namespace, msg.Pod, msg.Container) return } - if h.initialed { + + if h.initialed || func() bool { _, ok := h.K8sClients[msg.KubernetesId]; return ok }() { h.handleTerminalMessage(msg) } } @@ -150,55 +113,239 @@ func (h *tty) sendCloseMessage() { h.ws.SendMessage(&closedMsg) } -func (h *tty) sendSessionMessage(data string) { +func (h *tty) sendK8SCloseMessage(KubernetesId string) { + closedMsg := Message{ + Id: h.ws.Uuid, + Type: K8SClose, + KubernetesId: KubernetesId, + } + h.ws.SendMessage(&closedMsg) +} + +func (h *tty) sendSessionMessage(data string, KubernetesId string) { msg := Message{ - Id: h.ws.Uuid, - Type: TERMINALSESSION, - Data: data, + Id: h.ws.Uuid, + Type: TerminalSession, + Data: data, + KubernetesId: KubernetesId, } h.ws.SendMessage(&msg) } +func (h *tty) validateAndInitSession(msg *Message) (TerminalConnectData, error) { + var connectInfo TerminalConnectData + err := json.Unmarshal([]byte(msg.Data), &connectInfo) + if err != nil { + logger.Errorf("Ws[%s] terminal initial message data unmarshal err: %s", + h.ws.Uuid, err) + return connectInfo, err + } + + params := h.ws.wsParams + + if params.TargetType == TargetTypeShare { + code := connectInfo.Code + info, err2 := h.ValidateShareParams(params.TargetId, code) + if err2 != nil { + logger.Errorf("Ws[%s] terminal initial validate share err: %s", + h.ws.Uuid, err2) + h.sendCloseMessage() + return connectInfo, err2 + } + h.shareInfo = &info + sessionDetail, err3 := h.ws.apiClient.GetSessionById(info.Record.Session.ID) + if err3 != nil { + logger.Errorf("Ws[%s] terminal get session %s err: %s", + h.ws.Uuid, info.Record.Session.ID, err3) + h.sendCloseMessage() + return connectInfo, err3 + } + sessionInfo := proxy.SessionInfo{ + Session: &sessionDetail, + } + data, _ := json.Marshal(sessionInfo) + h.sendSessionMessage(string(data), msg.KubernetesId) + } + return connectInfo, nil +} + +func (h *tty) handleTerminalInit(connectInfo TerminalConnectData, KubernetesId, namespace, pod, container string) { + win := ssh.Window{ + Width: connectInfo.Cols, + Height: connectInfo.Rows, + } + userR, userW := io.Pipe() + client := &Client{ + WinChan: make(chan ssh.Window, 100), Conn: h.ws, + UserRead: userR, UserWrite: userW, + pty: ssh.Pty{Term: "xterm", Window: win}, + KubernetesId: KubernetesId, Namespace: namespace, + Pod: pod, Container: container, + } + + if KubernetesId != "" { + if h.K8sClients == nil { + h.K8sClients = make(map[string]*Client) + } + h.K8sClients[KubernetesId] = client + } else { + h.backendClient = client + } + + h.wg.Add(1) + go h.proxy(&h.wg, client) +} + func (h *tty) handleTerminalMessage(msg *Message) { switch msg.Type { - case TERMINALDATA: - h.backendClient.WriteData([]byte(msg.Data)) - case TERMINALBINARY: - h.backendClient.WriteData(msg.Raw) - case TERMINALRESIZE: - var size WindowSize - err := json.Unmarshal([]byte(msg.Data), &size) + case TerminalData, TerminalBinary: + data := getDataBytes(msg) + h.backendClient.WriteData(data) + case TerminalResize, TerminalK8SResize: + h.handleResize(msg) + case TerminalK8SData, TerminalK8SBinary: + h.handleK8SMessage(msg) + case TerminalShare: + var shareData ShareRequestParams + + err := json.Unmarshal([]byte(msg.Data), &shareData) if err != nil { logger.Errorf("Ws[%s] message(%s) data unmarshal err: %s", h.ws.Uuid, msg.Type, msg.Data) return } - h.backendClient.SetWinSize(ssh.Window{ - Width: size.Cols, - Height: size.Rows, - }) - case TERMINALSHARE: - var shareData ShareRequestParams - - err := json.Unmarshal([]byte(msg.Data), &shareData) + logger.Debugf("Ws[%s] receive share request %s", h.ws.Uuid, msg.Data) + go h.createShareSession(&shareData) + return + case TerminalGetShareUser: + var query GetUserParams + err := json.Unmarshal([]byte(msg.Data), &query) if err != nil { logger.Errorf("Ws[%s] message(%s) data unmarshal err: %s", h.ws.Uuid, msg.Type, msg.Data) return } logger.Debugf("Ws[%s] receive share request %s", h.ws.Uuid, msg.Data) - go h.createShareSession(shareData) + go h.getShareUserInfo(query) + return + case TerminalShareUserRemove: + var query RemoveSharingUserParams + err := json.Unmarshal([]byte(msg.Data), &query) + if err != nil { + logger.Errorf("Ws[%s] message(%s) data unmarshal err: %s", h.ws.Uuid, + msg.Type, msg.Data) + return + } + logger.Debugf("Ws[%s] receive share remove user request %s", h.ws.Uuid, msg.Data) + go h.removeShareUser(&query) + return + case TerminalSyncUserPreference: + var preference UserKoKoPreferenceParam + err := json.Unmarshal([]byte(msg.Data), &preference) + if err != nil { + logger.Errorf("Ws[%s] message(%s) data unmarshal err: %s", h.ws.Uuid, + msg.Type, msg.Data) + return + } + logger.Debugf("Ws[%s] receive sync user preference request %s", h.ws.Uuid, msg.Data) + go h.syncUserPreference(&preference) return - case CLOSE: _ = h.backendClient.Close() + case K8SClose: + if k8sClient, ok := h.K8sClients[msg.KubernetesId]; ok { + _ = k8sClient.Close() + delete(h.K8sClients, msg.KubernetesId) + } default: logger.Infof("Ws[%s] handle unknown message(%s) data %s", h.ws.Uuid, msg.Type, msg.Data) } } -func (h *tty) createShareSession(shareData ShareRequestParams) { +func getDataBytes(msg *Message) []byte { + if msg.Type == TerminalData || msg.Type == TerminalK8SData { + return []byte(msg.Data) + } + return msg.Raw +} + +func (h *tty) handleK8SMessage(msg *Message) { + if k8sClient, ok := h.K8sClients[msg.KubernetesId]; ok { + k8sClient.WriteData(getDataBytes(msg)) + } +} + +func (h *tty) handleResize(msg *Message) { + var size WindowSize + err := json.Unmarshal([]byte(msg.Data), &size) + if err != nil { + logger.Errorf("Ws[%s] message(%s) data unmarshal err: %s", h.ws.Uuid, msg.Type, msg.Data) + return + } + if msg.Type == TerminalResize { + h.backendClient.SetWinSize(ssh.Window{ + Width: size.Cols, + Height: size.Rows, + }) + } else if msg.Type == TerminalK8SResize { + if k8sClient, ok := h.K8sClients[msg.KubernetesId]; ok { + k8sClient.SetWinSize(ssh.Window{Width: size.Cols, Height: size.Rows}) + } + } +} + +func (h *tty) removeShareUser(query *RemoveSharingUserParams) { + if room := exchange.GetRoom(query.SessionId); room != nil { + var data = make(map[string]interface{}) + data["primary_user"] = h.ws.user.String() + data["share_user"] = query.UserMeta.User + data["terminal_id"] = query.UserMeta.TerminalId + body, _ := json.Marshal(data) + room.Broadcast(&exchange.RoomMessage{ + Event: exchange.ShareRemoveUser, + Body: body, + Meta: query.UserMeta, + }) + } +} + +func (h *tty) syncUserPreference(preference *UserKoKoPreferenceParam) { + /* + {"basic":{"file_name_conflict_resolution":"replace","terminal_theme_name":"Flat"}} + */ + reqCookies := h.ws.ctx.Request.Cookies() + var cookies = make(map[string]string) + for _, cookie := range reqCookies { + cookies[cookie.Name] = cookie.Value + } + data := model.UserKokoPreference{ + Basic: model.KokoBasic{ + ThemeName: preference.ThemeName, + }, + } + var msg struct { + EventName string `json:"event_name"` + } + msg.EventName = "sync_user_preference" + errMsg := "" + err := h.ws.apiClient.SyncUserKokoPreference(cookies, data) + if err != nil { + logger.Errorf("Ws[%s] sync user preference err: %s", h.ws.Uuid, err) + errMsg = err.Error() + } + msgNotify, _ := json.Marshal(msg) + + h.ws.SendMessage(&Message{ + Id: h.ws.Uuid, + Type: MessageNotify, + Data: string(msgNotify), + Err: errMsg, + }) + +} + +func (h *tty) createShareSession(shareData *ShareRequestParams) { // 创建 共享连接 res, err := h.handleShareRequest(shareData) if err != nil { @@ -207,13 +354,39 @@ func (h *tty) createShareSession(shareData ShareRequestParams) { data, _ := json.Marshal(res) h.ws.SendMessage(&Message{ Id: h.ws.Uuid, - Type: TERMINALSHARE, + Type: TerminalShare, Data: string(data), }) } -func (h *tty) handleShareRequest(data ShareRequestParams) (res ShareResponse, err error) { - shareResp, err := h.jmsService.CreateShareRoom(data.SessionID, data.ExpireTime) +func (h *tty) getShareUserInfo(query GetUserParams) { + if h.sessionInfo == nil { + logger.Errorf("Ws[%s] get share User info without sessioninfo", h.ws.Uuid) + return + } + if h.sessionInfo.Perms == nil { + logger.Errorf("Ws[%s] get share User info without permissions", h.ws.Uuid) + return + } + if !h.sessionInfo.Perms.EnableShare() { + logger.Errorf("Ws[%s] get share User info without permissions", h.ws.Uuid) + return + } + shareUserResp, err := h.ws.apiClient.GetSuggestionUsers(query.Query) + if err != nil { + logger.Error(err) + return + } + data, _ := json.Marshal(shareUserResp) + h.ws.SendMessage(&Message{ + Id: h.ws.Uuid, + Type: TerminalGetShareUser, + Data: string(data), + }) +} + +func (h *tty) handleShareRequest(data *ShareRequestParams) (res ShareResponse, err error) { + shareResp, err := h.ws.apiClient.CreateShareRoom(data.SharingSessionRequest) if err != nil { logger.Error(err) return res, err @@ -224,14 +397,14 @@ func (h *tty) handleShareRequest(data ShareRequestParams) (res ShareResponse, er } func (h *tty) ValidateShareParams(shareId, code string) (info ShareInfo, err error) { - data := service.SharePostData{ + data := model.SharePostData{ ShareId: shareId, Code: code, UserId: h.ws.user.ID, RemoteAddr: h.ws.ClientIP(), } - recordRes, err := h.jmsService.JoinShareRoom(data) + recordRes, err := h.ws.apiClient.JoinShareRoom(data) if err != nil { logger.Errorf("Conn[%s] Validate Share err: %s", h.ws.Uuid, err) var errMsg string @@ -244,46 +417,18 @@ func (h *tty) ValidateShareParams(shareId, code string) (info ShareInfo, err err } h.ws.SendMessage(&Message{ Id: h.ws.Uuid, - Type: TERMINALERROR, - Data: errMsg, + Type: TerminalError, + Err: errMsg, }) return } return ShareInfo{recordRes}, nil } -func (h *tty) getTargetApp(protocol string) bool { - switch strings.ToLower(protocol) { - case srvconn.ProtocolMySQL, srvconn.ProtocolMariadb, - srvconn.ProtocolK8s, srvconn.ProtocolSQLServer, - srvconn.ProtocolRedis, srvconn.ProtocolMongoDB: - appAsset, err := h.jmsService.GetApplicationById(h.targetId) - if err != nil { - logger.Errorf("Get %s application failed; %s", protocol, err) - return false - } - if appAsset.ID != "" { - h.app = &appAsset - return true - } - default: - asset, err := h.jmsService.GetAssetById(h.targetId) - if err != nil { - logger.Errorf("Get asset failed; %s", err) - return false - } - if asset.ID != "" { - h.asset = &asset - return true - } - } - return false -} - -func (h *tty) getk8sContainerInfo() *proxy.ContainerInfo { - pod := h.extraParams.Get("pod") - namespace := h.extraParams.Get("namespace") - container := h.extraParams.Get("container") +func (h *tty) getK8sContainerInfo(client *Client) *proxy.ContainerInfo { + pod := client.Pod + namespace := client.Namespace + container := client.Container if pod == "" || namespace == "" || container == "" { return nil } @@ -296,7 +441,8 @@ func (h *tty) getk8sContainerInfo() *proxy.ContainerInfo { } func (h *tty) getConnectionParams() *proxy.ConnectionParams { - disableAutoHash := h.extraParams.Get("disableautohash") + wsParams := h.ws.wsParams + disableAutoHash := wsParams.DisableAutoHash if disableAutoHash == "" { return nil } @@ -306,72 +452,67 @@ func (h *tty) getConnectionParams() *proxy.ConnectionParams { return ¶ms } -func (h *tty) proxy(wg *sync.WaitGroup) { +func (h *tty) proxy(wg *sync.WaitGroup, client *Client) { defer wg.Done() - switch h.targetType { + params := h.ws.wsParams + switch params.TargetType { case TargetTypeMonitor: - h.Monitor(h.backendClient, h.targetId) + h.Monitor(h.backendClient, params.TargetId) case TargetTypeShare: - roomID := h.shareInfo.Record.SessionId + roomID := h.shareInfo.Record.Session.ID h.JoinRoom(h.backendClient, roomID) default: - proxyOpts := make([]proxy.ConnectionOption, 0, 4) - proxyOpts = append(proxyOpts, proxy.ConnectProtocolType(h.systemUser.Protocol)) - proxyOpts = append(proxyOpts, proxy.ConnectSystemUser(h.systemUser)) - proxyOpts = append(proxyOpts, proxy.ConnectUser(h.ws.user)) - if langCode, err := h.ws.ctx.Cookie("django_language"); err == nil { - proxyOpts = append(proxyOpts, proxy.ConnectI18nLang(langCode)) - } - if params := h.getConnectionParams(); params != nil { - proxyOpts = append(proxyOpts, proxy.ConnectParams(params)) - } - switch h.systemUser.Protocol { - case srvconn.ProtocolMySQL, srvconn.ProtocolMariadb, - srvconn.ProtocolSQLServer, - srvconn.ProtocolRedis, srvconn.ProtocolMongoDB: - proxyOpts = append(proxyOpts, proxy.ConnectApp(h.app)) - case srvconn.ProtocolK8s: - proxyOpts = append(proxyOpts, proxy.ConnectApp(h.app)) - if info := h.getk8sContainerInfo(); info != nil { - proxyOpts = append(proxyOpts, proxy.ConnectContainer(info)) - } - default: - proxyOpts = append(proxyOpts, proxy.ConnectAsset(h.asset)) - } - srv, err := proxy.NewServer(h.backendClient, h.jmsService, proxyOpts...) + connectToken := h.ws.ConnectToken + proxyOpts := make([]proxy.ConnectionOption, 0, 10) + proxyOpts = append(proxyOpts, proxy.ConnectTokenAuthInfo(connectToken)) + proxyOpts = append(proxyOpts, proxy.ConnectI18nLang(h.ws.langCode)) + proxyOpts = append(proxyOpts, proxy.ConnectParams(h.getConnectionParams())) + proxyOpts = append(proxyOpts, proxy.ConnectContainer(h.getK8sContainerInfo(client))) + srv, err := proxy.NewServer(client, h.ws.apiClient, proxyOpts...) if err != nil { logger.Errorf("Create proxy server failed: %s", err) h.sendCloseMessage() return } - srv.OnSessionInfo = func(info *model.Session) { + srv.OnSessionInfo = func(info *proxy.SessionInfo) { + h.sessionInfo = info data, _ := json.Marshal(info) - h.sendSessionMessage(string(data)) + h.sendSessionMessage(string(data), client.KubernetesId) } srv.Proxy() } + + if params.TargetType == srvconn.ProtocolK8s { + delete(h.K8sClients, client.KubernetesId) + h.sendK8SCloseMessage(client.KubernetesId) + return + } h.sendCloseMessage() logger.Info("Ws tty proxy end") } -func (h *tty) CheckShareRoomReadPerm(uerId, roomId string) bool { - ret, err := h.jmsService.ValidateJoinSessionPermission(uerId, roomId) +func (h *tty) CheckMonitorReadPerm(uerId, roomId string) error { + ret, err := h.ws.apiClient.ValidateJoinSessionPermission(uerId, roomId) if err != nil { logger.Errorf("Create share room %s failed: %s", roomId, err) - return false + return ErrPermissionDenied } if !ret.Ok { - return false + return ErrPermissionDenied } - return true + return nil } -func (h *tty) CheckEnableShare() bool { - termConf, err := h.jmsService.GetTerminalConfig() +func (h *tty) CheckEnableShare() error { + termConf, err := h.ws.apiClient.GetTerminalConfig() if err != nil { - logger.Error(err) + logger.Errorf("Get terminal config failed: %s", err) + return err } - return termConf.EnableSessionShare + if !termConf.EnableSessionShare { + return ErrDisableShare + } + return nil } /* @@ -381,13 +522,16 @@ func (h *tty) CheckEnableShare() bool { */ func (h *tty) JoinRoom(c *Client, roomID string) { - user := h.ws.user + writable := h.shareInfo.Record.Writeable() meta := exchange.MetaMessage{ UserId: user.ID, User: user.String(), Created: common.NewNowUTCTime().String(), RemoteAddr: c.RemoteAddr(), + TerminalId: h.ws.Uuid, + Primary: false, + Writable: writable, } if room := exchange.GetRoom(roomID); room != nil { conn := exchange.WrapperUserCon(c) @@ -398,10 +542,12 @@ func (h *tty) JoinRoom(c *Client, roomID string) { Body: nil, Meta: meta, }) + logObj := model.SessionLifecycleLog{User: h.ws.user.String()} + h.ws.RecordLifecycleLog(roomID, model.UserJoinSession, logObj) for { buf := make([]byte, 1024) nr, err := c.Read(buf) - if nr > 0 { + if nr > 0 && writable { room.Receive(&exchange.RoomMessage{ Event: exchange.DataEvent, Body: buf[:nr], Meta: meta}) @@ -416,8 +562,9 @@ func (h *tty) JoinRoom(c *Client, roomID string) { Body: nil, Meta: meta, }) + h.ws.RecordLifecycleLog(roomID, model.UserLeaveSession, logObj) logger.Infof("Conn[%s] user read end", c.ID()) - if err := h.jmsService.FinishShareRoom(h.shareInfo.Record.ID); err != nil { + if err := h.ws.apiClient.FinishShareRoom(h.shareInfo.Record.ID); err != nil { logger.Infof("Conn[%s] finish share room err: %s", c.ID(), err) } } @@ -428,6 +575,8 @@ func (h *tty) Monitor(c *Client, roomID string) { conn := exchange.WrapperUserCon(c) room.Subscribe(conn) defer room.UnSubscribe(conn) + logObj := model.SessionLifecycleLog{User: h.ws.user.String()} + h.ws.RecordLifecycleLog(roomID, model.AdminJoinMonitor, logObj) for { buf := make([]byte, 1024) _, err := c.Read(buf) @@ -438,5 +587,6 @@ func (h *tty) Monitor(c *Client, roomID string) { logger.Debugf("Conn[%s] user monitor", c.ID()) } logger.Infof("Conn[%s] user read end", c.ID()) + h.ws.RecordLifecycleLog(roomID, model.AdminExitMonitor, logObj) } } diff --git a/pkg/httpd/userwebsocket.go b/pkg/httpd/userwebsocket.go index 355793b4f..3239d5f03 100644 --- a/pkg/httpd/userwebsocket.go +++ b/pkg/httpd/userwebsocket.go @@ -3,26 +3,33 @@ package httpd import ( "context" "encoding/json" + "errors" + "fmt" + "io" "time" + "github.com/jumpserver/koko/pkg/i18n" + "github.com/jumpserver/koko/pkg/proxy" + "github.com/jumpserver/koko/pkg/srvconn" + "github.com/gin-gonic/gin" gorilla "github.com/gorilla/websocket" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" "github.com/jumpserver/koko/pkg/httpd/ws" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" "github.com/jumpserver/koko/pkg/logger" ) type Handler interface { Name() string - CheckValidation() bool + CheckValidation() error HandleMessage(*Message) CleanUp() } type UserWebsocket struct { Uuid string - webSrv *Server conn *ws.Socket ctx *gin.Context messageChannel chan *Message @@ -30,25 +37,97 @@ type UserWebsocket struct { user *model.User setting *model.PublicSetting handler Handler + + wsParams *WsRequestParams + + ConnectToken *model.ConnectToken + apiClient *service.JMService + k8sClient *proxy.KubernetesClient + langCode string +} + +func (userCon *UserWebsocket) initial() error { + var wsParams WsRequestParams + if err := userCon.ctx.ShouldBind(&wsParams); err != nil { + logger.Errorf("Ws miss required ws params (token or target) err: %s", err) + errMsg := "Miss required ws params (token or target)" + userCon.SendErrMessage(errMsg) + return err + } + userCon.wsParams = &wsParams + token := userCon.wsParams.Token + if token != "" { + connectToken, err := userCon.apiClient.GetConnectTokenInfo(token, true) + if err != nil { + logger.Errorf("Get connect token info %s error: %s", token, err) + errMsg := "Token invalid" + if connectToken.Detail != "" { + errMsg = connectToken.Detail + } + userCon.SendErrMessage(errMsg) + return err + } + + if userCon.user.ID != connectToken.User.ID { + logger.Errorf("No valid auth user found: %s vs %s", + userCon.user.String(), connectToken.User.String()) + errMsg := "no valid auth user found" + return errors.New(errMsg) + } + userCon.ConnectToken = &connectToken + } + return nil } func (userCon *UserWebsocket) Run() { + lang := i18n.NewLang(userCon.langCode) if userCon.handler == nil { return } + if err := userCon.initial(); err != nil { + logger.Errorf("Ws[%s] initial err: %s", userCon.Uuid, err) + return + } ctx, cancel := context.WithCancel(userCon.ctx.Request.Context()) defer cancel() errorsChan := make(chan error, 1) go userCon.writeMessageLoop(ctx) go func() { - select { - case errorsChan <- userCon.readMessageLoop(): - case <-ctx.Done(): + if err := userCon.readMessageLoop(); err != io.EOF { + logger.Errorf("Ws[%s] read message err: %s", userCon.Uuid, err) + errorsChan <- err } + logger.Infof("Ws[%s] read message done", userCon.Uuid) }() - if !userCon.handler.CheckValidation() { + if err := userCon.handler.CheckValidation(); err != nil { + logger.Errorf("Ws[%s] check validation err: %s", userCon.Uuid, err) + userCon.SendErrMessage(err.Error()) return } + + if userCon.ConnectToken != nil && userCon.ConnectToken.Protocol == srvconn.ProtocolK8s { + var err error + namespaceValue := "" + if k8sSettings, ok := userCon.ConnectToken.Platform.GetProtocolSetting(srvconn.ProtocolK8s); ok { + if v, ok := k8sSettings.Setting["namespace"]; ok { + if s, ok := v.(string); ok { + namespaceValue = s + } + } + } + userCon.k8sClient, err = proxy.NewKubernetesClient( + userCon.ConnectToken.Asset.Address, + namespaceValue, + userCon.ConnectToken.Account.Secret, + userCon.ConnectToken.Gateway, + ) + if err != nil { + logger.Errorf("Ws[%s] create k8s client err: %s", userCon.Uuid, err) + userCon.SendErrMessage(fmt.Sprintf(lang.T("Create k8s client err: %s"), err)) + return + } + } + userCon.sendConnectMessage() var errMsg string select { @@ -59,14 +138,25 @@ func (userCon *UserWebsocket) Run() { case <-ctx.Done(): } userCon.handler.CleanUp() + if userCon.k8sClient != nil { + userCon.k8sClient.Close() + } + logger.Infof("Ws[%s] done with exit %s", userCon.Uuid, errMsg) } func (userCon *UserWebsocket) writeMessageLoop(ctx context.Context) { active := time.Now() t := time.NewTicker(time.Minute) + maxErrCount := 10 + errCount := 0 defer t.Stop() for { + if errCount >= maxErrCount { + logger.Errorf("Ws[%s] send message err count more than %d and exit goroutine", + userCon.Uuid, maxErrCount) + return + } var msg *Message select { case <-ctx.Done(): @@ -81,18 +171,16 @@ func (userCon *UserWebsocket) writeMessageLoop(ctx context.Context) { _ = userCon.conn.Close() continue } - msg = &Message{ - Id: userCon.Uuid, - Type: PING, - } + msg = &Message{Id: userCon.Uuid, Type: PING} case msg = <-userCon.messageChannel: } switch msg.Type { - case TERMINALBINARY: + case TerminalBinary: err := userCon.conn.WriteBinary(msg.Raw, maxWriteTimeOut) if err != nil { logger.Errorf("Ws[%s] send %s message err: %s", userCon.Uuid, msg.Type, err) + errCount++ continue } default: @@ -100,24 +188,39 @@ func (userCon *UserWebsocket) writeMessageLoop(ctx context.Context) { err := userCon.conn.WriteText(p, maxWriteTimeOut) if err != nil { logger.Errorf("Ws[%s] send %s message err: %s", userCon.Uuid, msg.Type, err) + errCount++ continue } } + errCount = 0 active = time.Now() } } func (userCon *UserWebsocket) SendMessage(msg *Message) { - userCon.messageChannel <- msg + select { + case userCon.messageChannel <- msg: + case <-userCon.conn.Request().Context().Done(): + logger.Infof("Ws[%s] ctx done and ignore message type %s", + userCon.Uuid, msg.Type) + } } func (userCon *UserWebsocket) sendConnectMessage() { var connectInfo struct { User *model.User `json:"user"` Setting *model.PublicSetting `json:"setting"` + Asset *model.Asset `json:"asset,omitempty"` } connectInfo.User = userCon.user connectInfo.Setting = userCon.setting + + if userCon.ConnectToken != nil { + connectInfo.Asset = &userCon.ConnectToken.Asset + } else { + connectInfo.Asset = nil + } + info, _ := json.Marshal(connectInfo) msg := Message{ Id: userCon.Uuid, @@ -131,14 +234,13 @@ func (userCon *UserWebsocket) readMessageLoop() error { for { p, opCode, err := userCon.conn.ReadData(maxReadTimeout) if err != nil { - logger.Errorf("Ws[%s] read data err: %s", userCon.Uuid, err) return err } var msg Message switch opCode { case gorilla.BinaryMessage: msg.Raw = p - msg.Type = TERMINALBINARY + msg.Type = TerminalBinary userCon.handler.HandleMessage(&msg) continue case gorilla.CloseMessage: @@ -158,6 +260,20 @@ func (userCon *UserWebsocket) readMessageLoop() error { case PING, PONG: logger.Debugf("Ws[%s] receive %s message", userCon.Uuid, msg.Type) continue + case TerminalK8STree: + data, err := userCon.k8sClient.GetTreeData() + responseMsg := Message{ + Id: userCon.Uuid, + Type: TerminalK8STree, + Data: data, + KubernetesId: msg.KubernetesId, + } + if err != nil { + responseMsg.Err = err.Error() + } + + userCon.SendMessage(&responseMsg) + continue default: userCon.handler.HandleMessage(&msg) } @@ -175,3 +291,24 @@ func (userCon *UserWebsocket) ClientIP() string { func (userCon *UserWebsocket) CurrentUser() *model.User { return userCon.user } + +func (userCon *UserWebsocket) SendErrMessage(errMsg string) { + msg := Message{Id: userCon.Uuid, Type: ERROR, Err: errMsg} + data, _ := json.Marshal(msg) + if err := userCon.conn.WriteText(data, maxWriteTimeOut); err != nil { + logger.Errorf("Ws[%s] send error message err: %s", userCon.Uuid, err) + } +} + +var ( + ErrAssetIdInvalid = errors.New("asset id invalid") + ErrDisableShare = errors.New("disable share") + ErrPermissionDenied = errors.New("permission denied") +) + +func (userCon *UserWebsocket) RecordLifecycleLog(sid string, event model.LifecycleEvent, + logObj model.SessionLifecycleLog) { + if err := userCon.apiClient.RecordSessionLifecycleLog(sid, event, logObj); err != nil { + logger.Errorf("Record session lifecycle log err: %s", err) + } +} diff --git a/pkg/httpd/webfolder.go b/pkg/httpd/webfolder.go index 7a327d233..649185c28 100644 --- a/pkg/httpd/webfolder.go +++ b/pkg/httpd/webfolder.go @@ -1,8 +1,9 @@ package httpd import ( - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" - "strings" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver/koko/pkg/common" + "github.com/jumpserver/koko/pkg/logger" ) var _ Handler = (*webFolder)(nil) @@ -12,29 +13,20 @@ type webFolder struct { done chan struct{} - targetId string - volume *UserVolume - - jmsService *service.JMService } func (h *webFolder) Name() string { return WebFolderName } -func (h *webFolder) CheckValidation() bool { - jmsServiceCopy := h.jmsService.Copy() - if langCode, err := h.ws.ctx.Cookie("django_language"); err == nil { - jmsServiceCopy.SetCookie("django_language", langCode) - } - switch strings.TrimSpace(h.targetId) { - case "_": - h.volume = NewUserVolume(jmsServiceCopy, h.ws.CurrentUser(), h.ws.ClientIP(), "") - default: - h.volume = NewUserVolume(jmsServiceCopy, h.ws.CurrentUser(), h.ws.ClientIP(), strings.TrimSpace(h.targetId)) +func (h *webFolder) CheckValidation() error { + if volume, err := SftpCheckValidation(h.ws); err != nil { + return err + } else { + h.volume = volume } - return true + return nil } func (h *webFolder) HandleMessage(*Message) { @@ -53,3 +45,52 @@ func (h *webFolder) GetVolume() *UserVolume { return h.volume } } + +func SftpCheckValidation(ws *UserWebsocket) (*UserVolume, error) { + apiClient := ws.apiClient + user := ws.CurrentUser() + terminalCfg, err := ws.apiClient.GetTerminalConfig() + + uv := &UserVolume{} + if err != nil { + logger.Errorf("Get terminal config failed: %s", err) + return uv, err + } + volOpts := make([]VolumeOption, 0, 5) + volOpts = append(volOpts, WithUser(user)) + volOpts = append(volOpts, WithAddr(ws.ClientIP())) + volOpts = append(volOpts, WithTerminalCfg(&terminalCfg)) + params := ws.wsParams + targetId := params.TargetId + assetId := params.AssetId + if assetId == "" { + assetId = targetId + } + if ws.ConnectToken != nil { + connectToken := ws.ConnectToken + volOpts = append(volOpts, WithConnectToken(connectToken)) + } else { + if common.ValidUUIDString(assetId) { + detailAsset, err1 := apiClient.GetUserPermAssetDetailById(user.ID, assetId) + if err1 != nil { + logger.Errorf("Get user asset %s error: %s", assetId, err) + return uv, ErrAssetIdInvalid + } + permAsset := &model.PermAsset{ + ID: detailAsset.ID, + Name: detailAsset.Name, + Address: detailAsset.Address, + Comment: detailAsset.Comment, + Platform: detailAsset.Platform, + OrgID: detailAsset.OrgID, + OrgName: detailAsset.OrgName, + IsActive: detailAsset.IsActive, + Type: detailAsset.Type, + Category: detailAsset.Category, + } + volOpts = append(volOpts, WithAsset(permAsset)) + } + } + + return NewUserVolume(apiClient, volOpts...), nil +} diff --git a/pkg/httpd/webrouter.go b/pkg/httpd/webrouter.go new file mode 100644 index 000000000..f45574894 --- /dev/null +++ b/pkg/httpd/webrouter.go @@ -0,0 +1,169 @@ +package httpd + +import ( + "html/template" + "io/fs" + "net/http" + "net/http/pprof" + "time" + + "github.com/gin-gonic/gin" + + "github.com/jumpserver-dev/sdk-go/service" + assets "github.com/jumpserver/koko" + "github.com/jumpserver/koko/pkg/auth" + "github.com/jumpserver/koko/pkg/common" + "github.com/jumpserver/koko/pkg/config" + "github.com/jumpserver/koko/pkg/logger" +) + +func getStaticFS() http.FileSystem { + staticFs, err := fs.Sub(assets.StaticFs, "static") + if err != nil { + logger.Debugf("Get static fs error: %s", err) + staticDir := http.Dir("./static/") + return &StaticFSWrapper{ + FileSystem: staticDir, + FixedModTime: time.Now(), + } + } + return &StaticFSWrapper{ + FileSystem: http.FS(staticFs), + FixedModTime: time.Now(), + } + +} + +func getUIAssetFs() http.FileSystem { + uiAssetFs, err := fs.Sub(assets.UIFs, "ui/dist/assets") + if err != nil { + logger.Debugf("Get ui asset fs error: %s", err) + return &StaticFSWrapper{ + FileSystem: http.Dir("./ui/dist/assets"), + FixedModTime: time.Now(), + } + } + return &StaticFSWrapper{ + FileSystem: http.FS(uiAssetFs), + FixedModTime: time.Now(), + } +} + +func createRouter(jmsService *service.JMService, webSrv *Server) *gin.Engine { + if config.GlobalConfig.LogLevel != "DEBUG" { + gin.SetMode(gin.ReleaseMode) + } + eng := gin.New() + eng.Use(gin.Recovery()) + eng.Use(gin.Logger()) + kokoGroup := eng.Group("/koko") + templ := template.Must(template.New("").ParseFS(assets.TemplateFs, + "templates/elfinder/*.html")) + eng.SetHTMLTemplate(templ) + kokoGroup.StaticFS("/static/", getStaticFS()) + kokoGroup.StaticFS("/assets", getUIAssetFs()) + kokoGroup.StaticFileFS("/favicon.ico", "ui/dist/favicon.ico", http.FS(assets.UIFs)) + kokoGroup.GET("/health/", webSrv.HealthStatusHandler) + wsGroup := kokoGroup.Group("/ws/") + { + wsGroup.Group("/terminal").Use( + auth.HTTPMiddleSessionAuth(jmsService)).GET("/", webSrv.ProcessTerminalWebsocket) + + wsGroup.Group("/elfinder").Use( + auth.HTTPMiddleSessionAuth(jmsService)).GET("/", webSrv.ProcessElfinderWebsocket) + + wsGroup.Group("/chat/system").Use( + auth.HTTPMiddleSessionAuth(jmsService)).GET("/", webSrv.ChatAIWebsocket) + + wsGroup.Group("/sftp").Use( + auth.HTTPMiddleSessionAuth(jmsService)).GET("/", webSrv.ProcessSftpWebsocket) + + } + + connectGroup := kokoGroup.Group("/connect") + connectGroup.Use(auth.HTTPMiddleSessionAuth(jmsService)) + { + connectGroup.GET("/", func(ctx *gin.Context) { + // https://github.com/gin-gonic/gin/issues/2654 + ctx.FileFromFS("ui/dist/", http.FS(assets.UIFs)) + }) + } + sftpGroup := kokoGroup.Group("/sftp") + sftpGroup.Use(auth.HTTPMiddleSessionAuth(jmsService)) + { + sftpGroup.GET("/", func(ctx *gin.Context) { + ctx.FileFromFS("ui/dist/", http.FS(assets.UIFs)) + }) + } + shareGroup := kokoGroup.Group("/share") + shareGroup.Use(auth.HTTPMiddleSessionAuth(jmsService)) + { + shareGroup.GET("/:id/", func(ctx *gin.Context) { + ctx.FileFromFS("ui/dist/", http.FS(assets.UIFs)) + }) + } + + monitorGroup := kokoGroup.Group("/monitor") + monitorGroup.Use(auth.HTTPMiddleSessionAuth(jmsService)) + { + monitorGroup.GET("/:id/", func(ctx *gin.Context) { + ctx.FileFromFS("ui/dist/", http.FS(assets.UIFs)) + }) + } + + tokenGroup := kokoGroup.Group("/token") + { + tokenGroup.GET("/", func(ctx *gin.Context) { + ctx.FileFromFS("ui/dist/", http.FS(assets.UIFs)) + }) + + tokenGroup.GET("/:id/", func(ctx *gin.Context) { + ctx.FileFromFS("ui/dist/", http.FS(assets.UIFs)) + }) + } + elfinderGroup := kokoGroup.Group("/elfinder") + elfinderGroup.Use(auth.HTTPMiddleSessionAuth(jmsService)) + { + elfinderGroup.GET("/sftp/", func(ctx *gin.Context) { + metaData := webSrv.GenerateViewMeta("_") + ctx.HTML(http.StatusOK, "file_manager.html", metaData) + }) + elfinderGroup.GET("/sftp/:host/", func(ctx *gin.Context) { + hostId := ctx.Param("host") + if ok := common.ValidUUIDString(hostId); !ok { + ctx.AbortWithStatus(http.StatusBadRequest) + return + } + metaData := webSrv.GenerateViewMeta(hostId) + ctx.HTML(http.StatusOK, "file_manager.html", metaData) + }) + elfinderGroup.Any("/connector/:host/", webSrv.SftpHostConnectorView) + } + + k8sGroup := kokoGroup.Group("/k8s") + k8sGroup.Use(auth.HTTPMiddleSessionAuth(jmsService)) + { + k8sGroup.GET("/", func(ctx *gin.Context) { + // https://github.com/gin-gonic/gin/issues/2654 + ctx.FileFromFS("ui/dist/", http.FS(assets.UIFs)) + }) + } + + debugGroup := eng.Group("/debug/pprof") + debugGroup.Use(auth.HTTPMiddleDebugAuth()) + { + debugGroup.GET("/", gin.WrapF(pprof.Index)) + debugGroup.GET("/cmdline", gin.WrapF(pprof.Cmdline)) + debugGroup.GET("/profile", gin.WrapF(pprof.Profile)) + debugGroup.POST("/symbol", gin.WrapF(pprof.Symbol)) + debugGroup.GET("/symbol", gin.WrapF(pprof.Symbol)) + debugGroup.GET("/trace", gin.WrapF(pprof.Trace)) + debugGroup.GET("/allocs", gin.WrapF(pprof.Handler("allocs").ServeHTTP)) + debugGroup.GET("/block", gin.WrapF(pprof.Handler("block").ServeHTTP)) + debugGroup.GET("/goroutine", gin.WrapF(pprof.Handler("goroutine").ServeHTTP)) + debugGroup.GET("/heap", gin.WrapF(pprof.Handler("heap").ServeHTTP)) + debugGroup.GET("/mutex", gin.WrapF(pprof.Handler("mutex").ServeHTTP)) + debugGroup.GET("/threadcreate", gin.WrapF(pprof.Handler("threadcreate").ServeHTTP)) + } + return eng +} diff --git a/pkg/httpd/webserver.go b/pkg/httpd/webserver.go index 0549176b9..45875c6b6 100644 --- a/pkg/httpd/webserver.go +++ b/pkg/httpd/webserver.go @@ -3,48 +3,51 @@ package httpd import ( "context" "log" + "net" "net/http" "strconv" "strings" + "sync" "time" "github.com/LeeEirc/elfinder" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" "github.com/jumpserver/koko/pkg/auth" "github.com/jumpserver/koko/pkg/common" "github.com/jumpserver/koko/pkg/config" "github.com/jumpserver/koko/pkg/httpd/ws" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" "github.com/jumpserver/koko/pkg/logger" ) const ( defaultBufferSize = 1024 + WebsocketErrorf = "Websocket upgrade err: %s" ) var upGrader = websocket.Upgrader{ ReadBufferSize: defaultBufferSize, WriteBufferSize: defaultBufferSize, Subprotocols: []string{"JMS-KOKO"}, - CheckOrigin: func(r *http.Request) bool { - return true - }, + CheckOrigin: func(r *http.Request) bool { return true }, } func NewServer(jmsService *service.JMService) *Server { - return &Server{ - broadCaster: NewBroadcaster(), - JmsService: jmsService, - } + srv := &Server{broadCaster: NewBroadcaster(), apiClient: jmsService} + eng := createRouter(jmsService, srv) + conf := config.GetConf() + addr := net.JoinHostPort(conf.BindHost, conf.HTTPPort) + srv.Srv = &http.Server{Addr: addr, Handler: eng} + return srv } type Server struct { broadCaster *broadcaster Srv *http.Server - JmsService *service.JMService + apiClient *service.JMService } func (s *Server) Start() { @@ -62,23 +65,23 @@ func (s *Server) Stop() { } func (s *Server) SftpHostConnectorView(ctx *gin.Context) { - var sid string + var params struct { + Sid string `form:"sid"` + } switch ctx.Request.Method { - case http.MethodGet: - sid = ctx.Query("sid") - case http.MethodPost: - sid = ctx.PostForm("sid") + case http.MethodGet, http.MethodPost: + if err := ctx.ShouldBind(¶ms); err != nil { + logger.Errorf("Invalid elfinder request url %s from ip %s", + ctx.Request.URL, ctx.ClientIP()) + ctx.String(http.StatusBadRequest, "invalid elfinder request") + return + } default: ctx.AbortWithStatus(http.StatusMethodNotAllowed) return } - if sid == "" { - logger.Errorf("Invalid elfinder request url %s from ip %s", ctx.Request.URL, ctx.ClientIP()) - ctx.String(http.StatusBadRequest, "invalid elfinder request") - return - } var userV *UserVolume - if wsCon := s.broadCaster.GetUserWebsocket(sid); wsCon != nil { + if wsCon := s.broadCaster.GetUserWebsocket(params.Sid); wsCon != nil { handler := wsCon.GetHandler() switch handler.Name() { case WebFolderName: @@ -87,11 +90,11 @@ func (s *Server) SftpHostConnectorView(ctx *gin.Context) { } if userV == nil { logger.Errorf("Ws(%s) already closed request url %s from ip %s", - sid, ctx.Request.URL, ctx.ClientIP()) + params.Sid, ctx.Request.URL, ctx.ClientIP()) ctx.String(http.StatusBadRequest, "ws already disconnected") return } - logger.Infof("Elfinder %s connected again.", sid) + logger.Infof("Elfinder ws %s connected again.", params.Sid) conf := config.GetConf() maxSize := common.ConvertSizeToBytes(conf.ZipMaxSize) options := map[string]string{ @@ -103,169 +106,157 @@ func (s *Server) SftpHostConnectorView(ctx *gin.Context) { } func (s *Server) ProcessTerminalWebsocket(ctx *gin.Context) { - var targetParams struct { - TargetType string `form:"type"` - TargetId string `form:"target_id"` - } - if err := ctx.ShouldBind(&targetParams); err != nil { - logger.Errorf("Ws miss required params( type|target_id ) err: %s", err) - ctx.AbortWithStatus(http.StatusBadRequest) - return - } - userValue, ok := ctx.Get(auth.ContextKeyUser) - if !ok { - logger.Errorf("Ws has no valid user from ip %s", ctx.ClientIP()) - ctx.AbortWithStatus(http.StatusBadRequest) + userConn, err := s.UpgradeUserWsConn(ctx) + if err != nil { + logger.Errorf(WebsocketErrorf, err) return } - currentUser := userValue.(*model.User) - systemUserId, _ := ctx.GetQuery("system_user_id") - s.runTTY(ctx, currentUser, targetParams.TargetType, targetParams.TargetId, systemUserId) + s.runTTY(userConn) } -func (s *Server) ProcessTokenWebsocket(ctx *gin.Context) { - var targetParams struct { - TargetId string `form:"target_id"` - } - if err := ctx.ShouldBind(&targetParams); err != nil { - logger.Errorf("Ws miss required params(target_id ) err: %s", err) - ctx.AbortWithStatus(http.StatusBadRequest) - return - } - tokenUser, err := s.JmsService.GetTokenAsset(targetParams.TargetId) - if err != nil || tokenUser.UserID == "" { - logger.Errorf("Token is invalid: %s", targetParams.TargetId) - ctx.AbortWithStatus(http.StatusBadRequest) +func (s *Server) ProcessElfinderWebsocket(ctx *gin.Context) { + userConn, err := s.UpgradeUserWsConn(ctx) + if err != nil { + logger.Errorf(WebsocketErrorf, err) return } - currentUser, err := s.JmsService.GetUserById(tokenUser.UserID) - if err != nil || currentUser == nil { - logger.Errorf("Token userID is invalid: %s", tokenUser.UserID) - ctx.AbortWithStatus(http.StatusBadRequest) - return + userConn.handler = &webFolder{ + ws: userConn, + done: make(chan struct{}), } - targetType := TargetTypeAsset - targetId := strings.ToLower(tokenUser.AssetID) - systemUserId := tokenUser.SystemUserID - s.runTTY(ctx, currentUser, targetType, targetId, systemUserId) + s.broadCaster.EnterUserWebsocket(userConn) + defer s.broadCaster.LeaveUserWebsocket(userConn) + userConn.Run() } -func (s *Server) ProcessElfinderWebsocket(ctx *gin.Context) { - var ( - userValue interface{} - currentUser *model.User - targetId string - ok bool - ) - if userValue, ok = ctx.Get(auth.ContextKeyUser); !ok { - logger.Errorf("Ws has no valid user from ip %s", ctx.ClientIP()) - ctx.AbortWithStatus(http.StatusBadRequest) +func (s *Server) ProcessSftpWebsocket(ctx *gin.Context) { + userConn, err := s.UpgradeUserWsConn(ctx) + if err != nil { + logger.Errorf(WebsocketErrorf, err) return } - currentUser = userValue.(*model.User) - if targetId, ok = ctx.GetQuery("target_id"); !ok { - logger.Error("Ws miss required params (target_id).") - ctx.AbortWithStatus(http.StatusBadRequest) - return + userConn.handler = &webSftp{ + ws: userConn, + done: make(chan struct{}), } - wsSocket, err := s.Upgrade(ctx) + s.broadCaster.EnterUserWebsocket(userConn) + defer s.broadCaster.LeaveUserWebsocket(userConn) + userConn.Run() +} + +func (s *Server) ChatAIWebsocket(ctx *gin.Context) { + userConn, err := s.UpgradeUserWsConn(ctx) if err != nil { - logger.Errorf("Websocket upgrade err: %s", err) - ctx.String(http.StatusBadRequest, "Websocket upgrade err %s", err) + logger.Errorf(WebsocketErrorf, err) return } - defer wsSocket.Close() - setting := s.getPublicSetting() - userConn := UserWebsocket{ - Uuid: common.UUID(), - webSrv: s, - conn: wsSocket, - ctx: ctx.Copy(), - messageChannel: make(chan *Message, 10), - user: currentUser, - setting: &setting, - } - userConn.handler = &webFolder{ - ws: &userConn, - targetId: targetId, - done: make(chan struct{}), - jmsService: s.JmsService, + termConf, err := userConn.apiClient.GetTerminalConfig() + if err != nil { + logger.Errorf("Get terminal config failed: %s", err) + return } - s.broadCaster.EnterUserWebsocket(&userConn) - defer s.broadCaster.LeaveUserWebsocket(&userConn) + userConn.handler = &chat{ + ws: userConn, + conversations: sync.Map{}, + term: &termConf, + } + s.broadCaster.EnterUserWebsocket(userConn) + defer s.broadCaster.LeaveUserWebsocket(userConn) userConn.Run() } -func (s *Server) Upgrade(ctx *gin.Context) (*ws.Socket, error) { +func (s *Server) UpgradeUserWsConn(ctx *gin.Context) (*UserWebsocket, error) { underWsCon, err := upGrader.Upgrade(ctx.Writer, ctx.Request, ctx.Writer.Header()) if err != nil { return nil, err } wsSocket := ws.NewSocket(underWsCon, ctx.Request) + + apiClient := s.apiClient.Copy() + langCode := config.GetConf().LanguageCode + if acceptLang := ctx.GetHeader("Accept-Language"); acceptLang != "" { + apiClient.SetHeader("Accept-Language", acceptLang) + langCode = ParseAcceptLanguageCode(acceptLang) + } + if cookieLang, err2 := ctx.Cookie("django_language"); err2 == nil { + apiClient.SetCookie("django_language", cookieLang) + langCode = cookieLang + } + //设置 websocket 协议层面对应的ping和pong 处理方法 underWsCon.SetPingHandler(func(appData string) error { + logger.Debugf("Websocket ping %s", appData) return wsSocket.WritePong([]byte(appData), maxWriteTimeOut) }) underWsCon.SetPongHandler(func(appData string) error { + logger.Debugf("Websocket pong %s", appData) return wsSocket.WritePing([]byte(appData), maxWriteTimeOut) }) - return wsSocket, nil -} -func (s *Server) runTTY(ctx *gin.Context, currentUser *model.User, - targetType, targetId, SystemUserID string) { - wsSocket, err := s.Upgrade(ctx) - if err != nil { - logger.Errorf("Websocket upgrade err: %s", err) - ctx.String(http.StatusBadRequest, "Websocket upgrade err %s", err) - return - } - defer wsSocket.Close() + userValue := ctx.MustGet(auth.ContextKeyUser) + currentUser := userValue.(*model.User) setting := s.getPublicSetting() - userConn := UserWebsocket{ + userConn := &UserWebsocket{ Uuid: common.UUID(), - webSrv: s, conn: wsSocket, ctx: ctx.Copy(), messageChannel: make(chan *Message, 10), user: currentUser, setting: &setting, + apiClient: apiClient, + langCode: langCode, } - userConn.handler = &tty{ - ws: &userConn, - targetType: targetType, - targetId: targetId, - systemUserId: SystemUserID, - jmsService: s.JmsService, - extraParams: ctx.Request.Form, + return userConn, nil +} + +func (s *Server) runTTY(userConn *UserWebsocket) { + ttyHandler := &tty{ + ws: userConn, } - s.broadCaster.EnterUserWebsocket(&userConn) - defer s.broadCaster.LeaveUserWebsocket(&userConn) + userConn.handler = ttyHandler + s.broadCaster.EnterUserWebsocket(userConn) + defer s.broadCaster.LeaveUserWebsocket(userConn) userConn.Run() } +var upTime = time.Now() + func (s *Server) HealthStatusHandler(ctx *gin.Context) { status := make(map[string]interface{}) - status["timestamp"] = time.Now().UTC() + now := time.Now() + status["timestamp"] = now.UTC() + status["uptime"] = now.Sub(upTime).String() ctx.JSON(http.StatusOK, status) } func (s *Server) GenerateViewMeta(targetId string) (meta ViewPageMata) { meta.ID = targetId - setting, err := s.JmsService.GetPublicSetting() + setting, err := s.apiClient.GetPublicSetting() if err != nil { logger.Errorf("Get core api public setting err: %s", err) } - meta.IconURL = setting.LogoURLS.Favicon + meta.IconURL = setting.Interface.Favicon return } func (s *Server) getPublicSetting() model.PublicSetting { - setting, err := s.JmsService.GetPublicSetting() + setting, err := s.apiClient.GetPublicSetting() if err != nil { logger.Errorf("Get Public setting err: %s", err) } return setting } + +func ParseAcceptLanguageCode(language string) string { + // en,zh-TW;q=0.9,zh-CN;q=0.8,zh;q=0.7 + // 解析出第一个语言代码 + if language == "" { + return "zh-CN" + } + languages := strings.SplitN(language, ";", 2) + lang := strings.TrimSpace(languages[0]) + languages = strings.SplitN(lang, ",", 2) + return languages[0] +} diff --git a/pkg/httpd/websftp.go b/pkg/httpd/websftp.go new file mode 100644 index 000000000..3c324bae8 --- /dev/null +++ b/pkg/httpd/websftp.go @@ -0,0 +1,234 @@ +package httpd + +import ( + "bytes" + "encoding/json" + "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/session" + "io" + "strconv" +) + +var _ Handler = (*webSftp)(nil) + +type webSftp struct { + ws *UserWebsocket + + done chan struct{} + + volume *UserWebVolume + + currentPath string + + msg *Message + + started bool +} + +func (h *webSftp) Name() string { + return WebFolderName +} + +func (h *webSftp) CheckValidation() error { + volume, err := SftpCheckValidation(h.ws) + if err != nil { + return err + } + + h.volume = NewUserWebVolume(volume) + return nil +} + +func (h *webSftp) HandleMessage(msg *Message) { + h.msg = msg + go h.dispatch(*msg) +} + +func (h *webSftp) CleanUp() { + close(h.done) + h.volume.Close() +} + +type webSftpRequest struct { + Path string `json:"path"` + NewName string `json:"new_name"` + Chunk bool `json:"chunk"` + Merge bool `json:"merge"` + OffSet int64 `json:"offset"` + Size int64 `json:"size"` + IsDir bool `json:"is_dir"` +} + +func notInTokenIds(target string) bool { + for _, item := range session.GetAliveSessionTokenIds() { + if item == target { + return false + } + } + return true +} + +func (h *webSftp) dispatch(msg Message) { + message := Message{ + Id: msg.Id, + Cmd: msg.Cmd, + Type: SFTPData, + CurrentPath: h.currentPath, + } + + request := &webSftpRequest{} + err := json.Unmarshal([]byte(h.msg.Data), request) + if err != nil { + message.Err = err.Error() + h.ws.SendMessage(&message) + return + } + + if h.started && notInTokenIds(h.ws.ConnectToken.Id) { + message.Err = "Session expired or not found" + message.Type = CLOSE + h.ws.SendMessage(&message) + return + } + + h.started = true + + switch h.msg.Cmd { + case "list": + h.handleList(request, &message) + case "download": + if h.ws.ConnectToken.Actions.EnableDownload() { + h.handleDownload(request, &message) + } else { + message.Err = "Permission denied" + h.ws.SendMessage(&message) + return + } + + case "upload": + if h.ws.ConnectToken.Actions.EnableUpload() { + h.handleUpload(request, h.msg, &message) + } else { + message.Err = "Permission denied" + h.ws.SendMessage(&message) + return + } + + case "rm": + h.handleAction(h.rm, request, &message) + case "rename": + h.handleAction(h.rename, request, &message) + case "mkdir": + h.handleAction(h.mkdir, request, &message) + default: + message.Err = "Unknown command" + h.ws.SendMessage(&message) + } + +} + +func (h *webSftp) handleList(request *webSftpRequest, response *Message) { + response.Data = h.list(request.Path) + response.CurrentPath = h.currentPath + h.ws.SendMessage(response) +} + +func (h *webSftp) list(path string) string { + files := h.volume.List(path) + h.currentPath = h.volume.UserSftp.GetCurrentPath() + data, _ := json.Marshal(files) + return string(data) +} + +func (h *webSftp) handleDownload(request *webSftpRequest, response *Message) { + file, filename, err := h.volume.Download(request.Path, request.IsDir) + if err != nil { + response.Err = err.Error() + h.ws.SendMessage(response) + return + } + + if file.Reader != nil { + defer file.Reader.Close() + } + + h.streamFileContent(file, response) + response.Data = filename + response.Type = SFTPData + h.ws.SendMessage(response) +} + +func (h *webSftp) streamFileContent(file FileData, response *Message) { + response.Type = SFTPBinary + buf := make([]byte, 1024*1024*2) + for { + responseCopy := *response + n, err := file.Reader.Read(buf) + if err != nil { + if err != io.EOF { + logger.Errorf("Error reading file: %s", err) + responseCopy.Err = err.Error() + h.ws.SendMessage(&responseCopy) + } + responseCopy.Raw = append([]byte{}, buf[:n]...) + h.ws.SendMessage(&responseCopy) + return + } + + responseCopy.Raw = append([]byte{}, buf[:n]...) + h.ws.SendMessage(&responseCopy) + } +} + +func (h *webSftp) handleUpload(request *webSftpRequest, msg *Message, response *Message) { + reader := bytes.NewReader(msg.Raw) + var readerAt io.ReaderAt = reader + + id, idErr := strconv.Atoi(msg.Id) + if idErr != nil { + response.Err = idErr.Error() + h.ws.SendMessage(response) + return + } + var err error + if request.Merge { + err = h.volume.MergeChunk(id, request.Path) + response.Data = "ok" + } else if request.Chunk { + err = h.volume.UploadChunk(id, request.Path, request.OffSet, int64(reader.Len()), readerAt) + response.Data = request.Path + } else { + err = h.volume.UploadFile(request.Path, reader, request.Size) + response.Data = "ok" + } + if err != nil { + response.Err = err.Error() + h.ws.SendMessage(response) + return + } + h.ws.SendMessage(response) +} + +func (h *webSftp) handleAction(action func(*webSftpRequest) error, request *webSftpRequest, response *Message) { + err := action(request) + if err != nil { + response.Err = err.Error() + } else { + response.Data = "ok" + } + h.ws.SendMessage(response) +} + +func (h *webSftp) rm(request *webSftpRequest) error { + return h.volume.Remove(request.Path) +} + +func (h *webSftp) rename(request *webSftpRequest) error { + oldNamePath := request.Path + newName := request.NewName + return h.volume.Rename(oldNamePath, newName) +} + +func (h *webSftp) mkdir(request *webSftpRequest) error { + return h.volume.MakeDir(request.Path) +} diff --git a/pkg/httpd/ws/socket.go b/pkg/httpd/ws/socket.go index 0d6f5d2be..2c0ad0364 100644 --- a/pkg/httpd/ws/socket.go +++ b/pkg/httpd/ws/socket.go @@ -28,17 +28,17 @@ func (s *Socket) Request() *http.Request { // ReadData reads binary or text messages from the remote connection. func (s *Socket) ReadData(timeout time.Duration) ([]byte, int, error) { - for { - if timeout > 0 { - s.underConn.SetReadDeadline(time.Now().Add(timeout)) - } - - opCode, data, err := s.underConn.ReadMessage() - if err != nil { + if timeout > 0 { + if err := s.underConn.SetReadDeadline(time.Now().Add(timeout)); err != nil { return nil, 0, err } - return data, opCode, err } + + opCode, data, err := s.underConn.ReadMessage() + if err != nil { + return nil, 0, err + } + return data, opCode, err } // WriteBinary sends a binary message to the remote connection. @@ -53,7 +53,9 @@ func (s *Socket) WriteText(body []byte, timeout time.Duration) error { func (s *Socket) write(body []byte, opCode int, timeout time.Duration) error { if timeout > 0 { - s.underConn.SetWriteDeadline(time.Now().Add(timeout)) + if err := s.underConn.SetWriteDeadline(time.Now().Add(timeout)); err != nil { + return err + } } s.mu.Lock() diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go index fb75dcde4..a2cfe0cb4 100644 --- a/pkg/i18n/i18n.go +++ b/pkg/i18n/i18n.go @@ -12,18 +12,13 @@ import ( func Initial() { cf := config.GetConf() localePath := path.Join(cf.RootPath, "locale") - if strings.HasPrefix(strings.ToLower(cf.LanguageCode), "en") { - gotext.Configure(localePath, "en_US", "koko") - } else if strings.HasPrefix(strings.ToLower(cf.LanguageCode), "ja") { - gotext.Configure(localePath, "ja_JP", "koko") - } else { - gotext.Configure(localePath, "zh_CN", "koko") - } + lowerCode := strings.ToLower(cf.LanguageCode) + gotext.Configure(localePath, lowerCode, "koko") setupLangMap(localePath) } func setupLangMap(localePath string) { - for _, code := range []LanguageCode{EN, ZH, JA} { + for _, code := range allLangCodes { enLocal := gotext.NewLocale(localePath, code.String()) enLocal.AddDomain("koko") langMap[code] = enLocal @@ -37,7 +32,10 @@ func NewLang(code string) LanguageCode { } else if strings.Contains(code, "ja") { return JA } - return ZH + if i18nCode, ok := i18nCodeMap[code]; ok { + return i18nCode + } + return EN } func T(s string) string { diff --git a/pkg/i18n/lang.go b/pkg/i18n/lang.go index ce491a2bc..5f0328cac 100644 --- a/pkg/i18n/lang.go +++ b/pkg/i18n/lang.go @@ -5,15 +5,42 @@ import ( ) const ( - ZH LanguageCode = "zh_CN" - EN LanguageCode = "en_US" - JA LanguageCode = "ja_JP" + ZH LanguageCode = "zh" + EN LanguageCode = "en" + JA LanguageCode = "ja" + ZHHant LanguageCode = "zh_Hant" + PtBr LanguageCode = "pt_BR" + Ko LanguageCode = "ko" + Ru LanguageCode = "ru" + Es LanguageCode = "es" ) var ( langMap = make(map[LanguageCode]*gotext.Locale) + + allLangCodes = []LanguageCode{ZH, EN, JA, ZHHant, PtBr, Ko, Ru, Es} + + AllLangCodesStr = []string{"English", "中文", "繁體中文", "日本語", "Português", "한국어", "Русский", "Español"} + AllCodes = []LanguageCode{EN, ZH, ZHHant, JA, PtBr, Ko, Ru, Es} ) +var i18nCodeMap = map[string]LanguageCode{ + "zh": ZH, + "en": EN, + "ja": JA, + "pt-br": PtBr, + "pt_br": PtBr, + "pt": PtBr, + "zh-cn": ZH, + "zh_cn": ZH, + "zh-hans": ZH, + "zh-hant": ZHHant, + "zh_hant": ZHHant, + "ru": Ru, + "ko": Ko, + "es": Es, +} + type LanguageCode string func (l LanguageCode) String() string { diff --git a/pkg/jms-sdk-go/common/docker_status.go b/pkg/jms-sdk-go/common/docker_status.go deleted file mode 100644 index ba8ea3500..000000000 --- a/pkg/jms-sdk-go/common/docker_status.go +++ /dev/null @@ -1,164 +0,0 @@ -package common - -import ( - "bufio" - "errors" - "io" - "os" - "strconv" - "strings" -) - -/* -https://github.com/docker/cli/blob/9bc104eff0798097954f5d9bc25ca93f892e63f5/cli/command/container/stats_helpers.go#L251 -// calculateMemUsageUnixNoCache calculate memory usage of the container. -// Cache is intentionally excluded to avoid misinterpretation of the output. -// -// On cgroup v1 host, the result is `mem.Usage - mem.Stats["total_inactive_file"]` . -// On cgroup v2 host, the result is `mem.Usage - mem.Stats["inactive_file"] `. -// -// This definition is consistent with cadvisor and containerd/CRI. -// * https://github.com/google/cadvisor/commit/307d1b1cb320fef66fab02db749f07a459245451 -// * https://github.com/containerd/cri/commit/6b8846cdf8b8c98c1d965313d66bc8489166059a -// -// On Docker 19.03 and older, the result was `mem.Usage - mem.Stats["cache"]`. -// See https://github.com/moby/moby/issues/40727 for the background. - -*/ - -type Mem struct { - LimitUsage uint64 - Usage uint64 - - Stats MemStat -} - -func (m Mem) Percent() float64 { - if m.LimitUsage != 0 { - return m.MemUsageNoCache() / float64(m.LimitUsage) * 100 - } - return -1 -} - -func (m Mem) MemUsageNoCache() float64 { - // cgroup v1 - if v, isCgroup1 := m.Stats["total_inactive_file"]; isCgroup1 && v < m.Usage { - return float64(m.Usage - v) - } - // cgroup v2 - if v := m.Stats["inactive_file"]; v < m.Usage { - return float64(m.Usage - v) - } - return float64(m.Usage) -} - -type MemStat map[string]uint64 - -/* - /sys/fs/cgroup/memory/memory.limit_in_bytes - - /sys/fs/cgroup/memory/memory.usage_in_bytes - - /sys/fs/cgroup/memory/memory.stat -*/ - -func CGroupMem() (Mem, error) { - stat, err := parseMemStat() - if err != nil { - return Mem{}, err - } - limitUsage, err := parseMemLimit() - if err != nil { - return Mem{}, err - } - usage, err := parseMemUsage() - if err != nil { - return Mem{}, err - } - return Mem{ - LimitUsage: limitUsage, - Usage: usage, - Stats: stat, - }, nil -} - -var ( - ErrLines = errors.New("not correct line format") -) - -func parseMemStat() (MemStat, error) { - lines, err := ReadFileLines("/sys/fs/cgroup/memory/memory.stat") - if err != nil { - return nil, err - } - return ParseMemStat(lines) -} - -func parseMemLimit() (uint64, error) { - lines, err := ReadFileLines("/sys/fs/cgroup/memory/memory.limit_in_bytes") - if err != nil { - return 0, err - } - return ParseMemLimit(lines) -} - -func parseMemUsage() (uint64, error) { - lines, err := ReadFileLines("/sys/fs/cgroup/memory/memory.usage_in_bytes") - if err != nil { - return 0, err - } - return ParseMemUsage(lines) -} - -func ParseMemStat(lines []string) (MemStat, error) { - var mem = make(MemStat) - for i := range lines { - line := lines[i] - fields := strings.Split(line, " ") - if len(fields) != 2 { - continue - } - value, err2 := strconv.ParseUint(fields[1], 10, 64) - if err2 != nil { - return nil, err2 - } - name := fields[0] - mem[name] = value - } - return mem, nil -} - -func ParseMemLimit(lines []string) (uint64, error) { - if len(lines) != 1 { - return 0, ErrLines - } - return strconv.ParseUint(lines[0], 10, 64) -} - -func ParseMemUsage(lines []string) (uint64, error) { - if len(lines) != 1 { - return 0, ErrLines - } - return strconv.ParseUint(lines[0], 10, 64) -} - -func ReadFileLines(path string) ([]string, error) { - fd, err := os.Open(path) - if err != nil { - return nil, err - } - defer fd.Close() - reader := bufio.NewReader(fd) - lines := make([]string, 0, 10) - - for { - line, err := reader.ReadString('\n') - if err != nil { - if err == io.EOF { - return lines, nil - } - return nil, err - } - lines = append(lines, strings.TrimSpace(line)) - } -} diff --git a/pkg/jms-sdk-go/common/file.go b/pkg/jms-sdk-go/common/file.go deleted file mode 100644 index 614199eb8..000000000 --- a/pkg/jms-sdk-go/common/file.go +++ /dev/null @@ -1,17 +0,0 @@ -package common - -import "os" - -func haveDir(file string) bool { - fi, err := os.Stat(file) - return err == nil && fi.IsDir() -} - -func EnsureDirExist(path string) error { - if !haveDir(path) { - if err := os.MkdirAll(path, os.ModePerm); err != nil { - return err - } - } - return nil -} diff --git a/pkg/jms-sdk-go/common/gzip.go b/pkg/jms-sdk-go/common/gzip.go deleted file mode 100644 index 06648f920..000000000 --- a/pkg/jms-sdk-go/common/gzip.go +++ /dev/null @@ -1,30 +0,0 @@ -package common - -import ( - "compress/gzip" - "io" - "os" - "path/filepath" - "time" -) - -func CompressToGzipFile(srcPath, dstPath string) error { - sf, err := os.Open(srcPath) - if err != nil { - return err - } - defer sf.Close() - df, err := os.Create(dstPath) - if err != nil { - return err - } - defer df.Close() - writer := gzip.NewWriter(df) - writer.Name = filepath.Base(srcPath) - writer.ModTime = time.Now().UTC() - _, err = io.Copy(writer, sf) - if err != nil { - return err - } - return writer.Close() -} diff --git a/pkg/jms-sdk-go/common/sys_status.go b/pkg/jms-sdk-go/common/sys_status.go deleted file mode 100644 index 02505d18e..000000000 --- a/pkg/jms-sdk-go/common/sys_status.go +++ /dev/null @@ -1,55 +0,0 @@ -package common - -import ( - "fmt" - "os" - "strconv" - - "github.com/shirou/gopsutil/v3/cpu" - "github.com/shirou/gopsutil/v3/disk" - "github.com/shirou/gopsutil/v3/load" - "github.com/shirou/gopsutil/v3/mem" -) - -func CpuLoad1Usage() float64 { - var ( - err error - cpuCount int - avgLoadStat *load.AvgStat - ) - cpuCount, err = cpu.Counts(true) - if err != nil { - return -1 - } - avgLoadStat, err = load.Avg() - if err != nil { - return -1 - } - return convertFloatDecimal(avgLoadStat.Load1 / float64(cpuCount)) -} - -func DiskUsagePercent() float64 { - dir, _ := os.Getwd() - usage, err := disk.Usage(dir) - if err != nil { - return -1 - } - return convertFloatDecimal(usage.UsedPercent) -} - -func MemoryUsagePercent() float64 { - vmStatus, err := mem.VirtualMemory() - if err != nil { - return -1 - } - if cMem, err := CGroupMem(); err == nil && cMem.LimitUsage < vmStatus.Total { - // 由此可判断,程序运行在容器内,且有内存限制 - return convertFloatDecimal(cMem.Percent()) - } - return convertFloatDecimal(vmStatus.UsedPercent) -} - -func convertFloatDecimal(value float64) float64 { - result, _ := strconv.ParseFloat(fmt.Sprintf("%.2f", value), 64) - return result -} diff --git a/pkg/jms-sdk-go/common/time.go b/pkg/jms-sdk-go/common/time.go deleted file mode 100644 index 9829f6fbb..000000000 --- a/pkg/jms-sdk-go/common/time.go +++ /dev/null @@ -1,52 +0,0 @@ -package common - -import ( - "errors" - "fmt" - "time" -) - -const utcFormat = "2006-01-02 15:04:05 -0700" - -func NewUTCTime(now time.Time) UTCTime { - return UTCTime{now.UTC()} -} - -func NewNowUTCTime() UTCTime { - return UTCTime{time.Now().UTC()} -} - -type UTCTime struct { - time.Time -} - -func (t UTCTime) MarshalJSON() ([]byte, error) { - return []byte(fmt.Sprintf(`"%s"`, t.Format(utcFormat))), nil -} - -func (t *UTCTime) UnmarshalJSON(data []byte) (err error) { - t.Time, err = parseTimeFromSupportedFormat(data) - return err -} - -var ( - supportedTimeFormat = []string{ - "2006/01/02 15:04:05 -0700", - utcFormat, - time.RFC3339, - } -) - -var ( - ErrUnSupportFormat = errors.New("unsupported time format") -) - -func parseTimeFromSupportedFormat(data []byte) (time.Time, error) { - for _, format := range supportedTimeFormat { - if parseTime, err := time.Parse(fmt.Sprintf( - `"%s"`, format), string(data)); err == nil { - return parseTime, nil - } - } - return time.Time{}, fmt.Errorf("%w: %s", ErrUnSupportFormat, data) -} diff --git a/pkg/jms-sdk-go/common/time_test.go b/pkg/jms-sdk-go/common/time_test.go deleted file mode 100644 index 4227b2e29..000000000 --- a/pkg/jms-sdk-go/common/time_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package common - -import ( - "encoding/json" - "testing" - "time" -) - -func TestNewJSONTime(t *testing.T) { - jsonT := NewUTCTime(time.Now()) - var s struct { - T UTCTime - } - s.T = jsonT - j, _ := json.Marshal(s) - t.Logf("%s\n", j) - var s2 struct { - T UTCTime - } - err := json.Unmarshal(j, &s2) - if err != nil { - t.Fatal(err) - } - j2, _ := json.Marshal(s2) - t.Logf("%v %v", s2, s) - t.Logf("%s\n", j2) -} diff --git a/pkg/jms-sdk-go/httplib/client.go b/pkg/jms-sdk-go/httplib/client.go deleted file mode 100644 index abc7f9835..000000000 --- a/pkg/jms-sdk-go/httplib/client.go +++ /dev/null @@ -1,291 +0,0 @@ -package httplib - -import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "io/ioutil" - "mime/multipart" - "net/http" - "net/url" - "os" - "strings" - "time" -) - -type AuthSign interface { - Sign(req *http.Request) error -} - -const miniTimeout = time.Second * 30 - -func NewClient(baseUrl string, timeout time.Duration) (*Client, error) { - _, err := url.Parse(baseUrl) - if err != nil { - return nil, err - } - if timeout < miniTimeout { - timeout = miniTimeout - } - - jar := &customCookieJar{ - data: map[string]string{}, - } - con := http.Client{ - Timeout: timeout, - Jar: jar, - } - return &Client{ - Timeout: timeout, - baseUrl: baseUrl, - cookies: make(map[string]string), - headers: make(map[string]string), - http: &con, - }, nil -} - -type Client struct { - Timeout time.Duration - baseUrl string - cookies map[string]string - headers map[string]string - http *http.Client - authSign AuthSign -} - -func (c *Client) Clone() Client { - jar := &customCookieJar{ - data: map[string]string{}, - } - con := http.Client{ - Timeout: c.Timeout, - Jar: jar, - } - return Client{ - Timeout: c.Timeout, - baseUrl: c.baseUrl, - cookies: make(map[string]string), - headers: make(map[string]string), - http: &con, - } - -} - -func (c *Client) SetCookie(key string, value string) { - c.cookies[key] = value -} - -func (c *Client) SetHeader(key, value string) { - c.headers[key] = value -} - -func (c *Client) SetAuthSign(auth AuthSign) { - c.authSign = auth -} - -func (c *Client) setReqAuthHeader(r *http.Request) error { - if len(c.cookies) != 0 { - for k, v := range c.cookies { - co := http.Cookie{Name: k, Value: v} - r.AddCookie(&co) - } - } - if c.authSign != nil { - return c.authSign.Sign(r) - } - return nil -} - -func (c *Client) setReqHeaders(req *http.Request) error { - if len(c.headers) != 0 { - for k, v := range c.headers { - req.Header.Set(k, v) - } - } - if req.Header.Get("Content-Type") == "" { - req.Header.Set("Content-Type", "application/json") - } - return c.setReqAuthHeader(req) -} - -func (c *Client) parseQueryUrl(reqUrl string, params []map[string]string) string { - if len(params) < 1 { - return reqUrl - } - query := url.Values{} - for _, item := range params { - for k, v := range item { - query.Add(k, v) - } - } - if strings.Contains(reqUrl, "?") { - reqUrl += "&" + query.Encode() - } else { - reqUrl += "?" + query.Encode() - } - return reqUrl -} - -func (c *Client) parseUrl(reqUrl string, params []map[string]string) string { - reqUrl = c.parseQueryUrl(reqUrl, params) - if c.baseUrl != "" { - reqUrl = strings.TrimSuffix(c.baseUrl, "/") + reqUrl - } - return reqUrl -} - -func (c *Client) newRequest(method, reqUrl string, data interface{}, params []map[string]string) (*http.Request, error) { - reqUrl = c.parseUrl(reqUrl, params) - dataRaw, err := json.Marshal(data) - if err != nil { - return nil, err - } - reader := bytes.NewReader(dataRaw) - req, err := http.NewRequest(method, reqUrl, reader) - if err != nil { - return req, err - } - err = c.setReqHeaders(req) - return req, err -} - -func (c *Client) Do(method, reqUrl string, data, res interface{}, params ...map[string]string) (resp *http.Response, err error) { - req, err := c.newRequest(method, reqUrl, data, params) - if err != nil { - return - } - resp, err = c.http.Do(req) - if err != nil { - return - } - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return resp, err - } - - // If is buffer return the raw response body - if buf, ok := res.(*bytes.Buffer); ok { - buf.Write(body) - return - } - // Unmarshal response body to result struct - if res != nil { - switch { - case strings.Contains(resp.Header.Get("Content-Type"), "application/json"): - err = json.Unmarshal(body, res) - if err != nil { - msg := fmt.Sprintf("%s %s failed, unmarshal '%s' response failed: %s", req.Method, req.URL, body[:12], err) - err = errors.New(msg) - return - } - } - } - if resp.StatusCode >= 400 { - msg := fmt.Sprintf("%s %s failed, get code: %d, %s", req.Method, req.URL, resp.StatusCode, body) - err = errors.New(msg) - return - } - return -} - -func (c *Client) Get(reqUrl string, res interface{}, params ...map[string]string) (resp *http.Response, err error) { - return c.Do("GET", reqUrl, nil, res, params...) -} - -func (c *Client) Post(reqUrl string, data interface{}, res interface{}, params ...map[string]string) (resp *http.Response, err error) { - return c.Do("POST", reqUrl, data, res, params...) -} - -func (c *Client) Delete(reqUrl string, res interface{}, params ...map[string]string) (resp *http.Response, err error) { - return c.Do("DELETE", reqUrl, nil, res, params...) -} - -func (c *Client) Put(reqUrl string, data interface{}, res interface{}, params ...map[string]string) (resp *http.Response, err error) { - return c.Do("PUT", reqUrl, data, res, params...) -} - -func (c *Client) Patch(reqUrl string, data interface{}, res interface{}, params ...map[string]string) (resp *http.Response, err error) { - return c.Do("PATCH", reqUrl, data, res, params...) -} - -func (c *Client) UploadFile(reqUrl string, gFile string, res interface{}, params ...map[string]string) (err error) { - reqUrl = c.parseUrl(reqUrl, params) - return c.PostFileWithFields(reqUrl, gFile, nil, res) -} - -func (c *Client) PostFileWithFields(reqUrl string, gFile string, fields map[string]string, res interface{}) error { - fd, err := os.Open(gFile) - if err != nil { - return err - } - bufferFd := bufio.NewReader(fd) - defer fd.Close() - fi, err := fd.Stat() - if err != nil { - return err - } - var size = fi.Size() - startPartBuf := bytes.NewBufferString("") - partWriter := multipart.NewWriter(startPartBuf) - for name, value := range fields { - _ = partWriter.WriteField(name, value) - } - _, _ = partWriter.CreateFormFile("file", fi.Name()) - boundary := partWriter.Boundary() - endString := fmt.Sprintf("\r\n--%s--\r\n", boundary) - endPartBuf := bytes.NewBufferString(endString) - bodyReader := io.MultiReader(startPartBuf, bufferFd, endPartBuf) - contentLen := int64(startPartBuf.Len()) + size + int64(endPartBuf.Len()) - reqUrl = c.parseUrl(reqUrl, nil) - req, err := http.NewRequest(http.MethodPost, reqUrl, bodyReader) - if err != nil { - return err - } - if err = c.setReqHeaders(req); err != nil { - return err - } - req.ContentLength = contentLen - req.Header.Set("Content-Type", partWriter.FormDataContentType()) - - client := http.Client{ - Jar: c.http.Jar, - } - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - return c.handleResp(resp, res) -} - -func (c *Client) handleResp(resp *http.Response, res interface{}) (err error) { - req := resp.Request - // If is buffer return the raw response body - if buf, ok := res.(*bytes.Buffer); ok { - _, err = buf.ReadFrom(resp.Body) - return err - } - if res != nil { - switch { - case strings.Contains(resp.Header.Get("Content-Type"), "application/json"): - err = json.NewDecoder(resp.Body).Decode(res) - if err != nil { - msg := fmt.Sprintf("%s %s failed, json unmarshal failed: %s", req.Method, req.URL, err) - return fmt.Errorf("%w: %s", err, msg) - } - } - } - if resp.StatusCode >= 400 { - var buf bytes.Buffer - _, _ = buf.ReadFrom(resp.Body) - msg := fmt.Sprintf("%s %s failed, get code: %d %s", - req.Method, req.URL, resp.StatusCode, buf.String()) - err = errors.New(msg) - return - } - return nil -} diff --git a/pkg/jms-sdk-go/httplib/cookiejar.go b/pkg/jms-sdk-go/httplib/cookiejar.go deleted file mode 100644 index f2de28656..000000000 --- a/pkg/jms-sdk-go/httplib/cookiejar.go +++ /dev/null @@ -1,35 +0,0 @@ -package httplib - -import ( - "net/http" - "net/url" - "sync" -) - -var _ http.CookieJar = (*customCookieJar)(nil) - -type customCookieJar struct { - mu sync.Mutex - data map[string]string -} - -func (c *customCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) { - c.mu.Lock() - defer c.mu.Unlock() - for i := range cookies { - name := cookies[i].Name - value := cookies[i].Value - c.data[name] = value - } -} - -func (c *customCookieJar) Cookies(u *url.URL) []*http.Cookie { - c.mu.Lock() - defer c.mu.Unlock() - cookies := make([]*http.Cookie, 0, len(c.data)) - for k, v := range c.data { - cookie := http.Cookie{Value: v, Name: k} - cookies = append(cookies, &cookie) - } - return cookies -} diff --git a/pkg/jms-sdk-go/httplib/http_auth.go b/pkg/jms-sdk-go/httplib/http_auth.go deleted file mode 100644 index 17fe7eb92..000000000 --- a/pkg/jms-sdk-go/httplib/http_auth.go +++ /dev/null @@ -1,55 +0,0 @@ -package httplib - -import ( - "fmt" - "net/http" - - "gopkg.in/twindagger/httpsig.v1" -) - -var ( - _ AuthSign = (*SigAuth)(nil) - - _ AuthSign = (*BasicAuth)(nil) - - _ AuthSign = (*BearerTokenAuth)(nil) -) - -const ( - signHeaderRequestTarget = "(request-target)" - signHeaderDate = "date" - signAlgorithm = "hmac-sha256" -) - -type SigAuth struct { - KeyID string - SecretID string -} - -func (auth *SigAuth) Sign(r *http.Request) error { - headers := []string{signHeaderRequestTarget, signHeaderDate} - signer, err := httpsig.NewRequestSigner(auth.KeyID, auth.SecretID, signAlgorithm) - if err != nil { - return err - } - return signer.SignRequest(r, headers, nil) -} - -type BasicAuth struct { - Username string - Password string -} - -func (auth *BasicAuth) Sign(r *http.Request) error { - r.SetBasicAuth(auth.Username, auth.Password) - return nil -} - -type BearerTokenAuth struct { - Token string -} - -func (auth *BearerTokenAuth) Sign(r *http.Request) error { - r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", auth.Token)) - return nil -} diff --git a/pkg/jms-sdk-go/model/access_key.go b/pkg/jms-sdk-go/model/access_key.go deleted file mode 100644 index 23efa6fb9..000000000 --- a/pkg/jms-sdk-go/model/access_key.go +++ /dev/null @@ -1,66 +0,0 @@ -package model - -import ( - "errors" - "fmt" - "io/ioutil" - "os" - "strings" - "time" -) - -var ( - AccessKeyNotFound = errors.New("access key not found") - AccessKeyFileNotFound = errors.New("access key file not found") - AccessKeyInvalid = errors.New("access key not valid") -) - -type AccessKey struct { - ID string `json:"id"` - Secret string `json:"secret"` -} - -func (ak *AccessKey) LoadFromStr(key string) error { - if key == "" { - return AccessKeyNotFound - } - keySlice := strings.Split(strings.TrimSpace(key), ":") - if len(keySlice) != 2 { - return AccessKeyInvalid - } - ak.ID = keySlice[0] - ak.Secret = keySlice[1] - return nil -} - -func (ak *AccessKey) LoadFromFile(keyPath string) error { - if keyPath == "" { - return AccessKeyNotFound - } - if _, err := os.Stat(keyPath);err != nil{ - return AccessKeyFileNotFound - } - buf, err := ioutil.ReadFile(keyPath) - if err != nil { - msg := fmt.Sprintf("read file failed: %s", err) - return fmt.Errorf("%w: %s", AccessKeyInvalid, msg) - } - return ak.LoadFromStr(string(buf)) -} - -func (ak *AccessKey) SaveToFile(path string) error { - if _, err := os.Stat(path); err == nil { - bakPath := fmt.Sprintf("%s_%s", path, - time.Now().Format("2006-01-02_15-04-05")) - if err2 := os.Rename(path, bakPath); err2 != nil { - return err2 - } - } - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - _, err = f.WriteString(fmt.Sprintf("%s:%s", ak.ID, ak.Secret)) - return err -} diff --git a/pkg/jms-sdk-go/model/application.go b/pkg/jms-sdk-go/model/application.go deleted file mode 100644 index 3b4b95d8f..000000000 --- a/pkg/jms-sdk-go/model/application.go +++ /dev/null @@ -1,59 +0,0 @@ -package model - -import "fmt" - -type k8sAttrs struct { - Cluster string `json:"cluster"` -} - -type dbAttrs struct { - Host string `json:"host"` - Port int `json:"port"` - Database string `json:"database"` -} - -const ( - AppTypeMySQL = "mysql" - AppTypeK8s = "k8s" -) - -const AppType = "Application" - -type Application struct { - ID string `json:"id"` - Name string `json:"name"` - Category string `json:"category"` - TypeName string `json:"type"` - Domain string `json:"domain"` - Comment string `json:"comment"` - OrgID string `json:"org_id"` - OrgName string `json:"org_name"` - - Attrs Attrs `json:"attrs"` -} - -type Attrs struct { - k8sAttrs - - dbAttrs -} - -func (app Application) String() string { - switch app.Category { - case categoryDB: - return fmt.Sprintf("%s://%s:%d/%s", - app.TypeName, - app.Attrs.Host, - app.Attrs.Port, - app.Attrs.Database) - case categoryCloud: - } - return fmt.Sprintf("%s://%s", - app.TypeName, - app.Name) -} - -const ( - categoryDB = "db" - categoryCloud = "cloud" -) diff --git a/pkg/jms-sdk-go/model/application_test.go b/pkg/jms-sdk-go/model/application_test.go deleted file mode 100644 index 47b19a070..000000000 --- a/pkg/jms-sdk-go/model/application_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package model - -import ( - "encoding/json" - "testing" -) - -func TestSortAssetNodesByKey(t *testing.T) { - var jsonString = ` - { - "id": "2b8f37ad-1580-4275-962a-7ea0f53c40b3", - "name": "www", - "domain": null, - "category": "db", - "type": "mysql", - "attrs": { - "host": "www", - "port": 32342, - "database": null - }, - "comment": "", - "org_id": "", - "category_display": "数据库", - "type_display": "MySQL", - "org_name": "DEFAULT" - } -` - var app Application - err := json.Unmarshal([]byte(jsonString), &app) - t.Log(err) - t.Logf("%+v", app) -} diff --git a/pkg/jms-sdk-go/model/asset.go b/pkg/jms-sdk-go/model/asset.go deleted file mode 100644 index 7d9d47e08..000000000 --- a/pkg/jms-sdk-go/model/asset.go +++ /dev/null @@ -1,72 +0,0 @@ -package model - -import ( - "fmt" - "strconv" - "strings" -) - -type Asset struct { - ID string `json:"id"` - Hostname string `json:"hostname"` - IP string `json:"ip"` - Os string `json:"os"` - Domain string `json:"domain"` // 是否需要走网域 - Comment string `json:"comment"` - Protocols []string `json:"protocols"` - OrgID string `json:"org_id"` - OrgName string `json:"org_name"` - Platform string `json:"platform"` - IsActive bool `json:"is_active"` // 判断资产是否禁用 -} - -func (a *Asset) String() string { - return fmt.Sprintf("%s(%s)", a.Hostname, a.IP) -} - -func (a *Asset) ProtocolPort(protocol string) int { - for _, item := range a.Protocols { - if strings.Contains(strings.ToLower(item), strings.ToLower(protocol)) { - proAndPort := strings.Split(item, "/") - if len(proAndPort) == 2 { - if port, err := strconv.Atoi(proAndPort[1]); err == nil { - return port - } - } - } - } - return 0 -} - -func (a *Asset) IsSupportProtocol(protocol string) bool { - for _, item := range a.Protocols { - if strings.Contains(strings.ToLower(item), strings.ToLower(protocol)) { - return true - } - } - return false -} - -type Gateway struct { - ID string `json:"id"` - Name string `json:"Name"` - IP string `json:"ip"` - Port int `json:"port"` - Protocol string `json:"protocol"` - Username string `json:"username"` - Password string `json:"password"` - PrivateKey string `json:"private_key"` -} - -type Domain struct { - ID string `json:"id"` - Gateways []Gateway `json:"gateways"` - Name string `json:"name"` -} - -const ( - ProtocolSSH = "ssh" - ProtocolTelnet = "telnet" - ProtocolK8S = "k8s" - ProtocolMysql = "mysql" -) diff --git a/pkg/jms-sdk-go/model/asset_list.go b/pkg/jms-sdk-go/model/asset_list.go deleted file mode 100644 index 17179accb..000000000 --- a/pkg/jms-sdk-go/model/asset_list.go +++ /dev/null @@ -1,71 +0,0 @@ -package model - -import ( - "sort" - "strings" -) - -type AssetList []Asset - -func (a AssetList) SortBy(tp string) AssetList { - var sortedAssets = make(AssetList, len(a)) - copy(sortedAssets, a) - switch tp { - case "ip": - sorter := &assetSorter{ - data: sortedAssets, - sortBy: assetSortByIP, - } - sort.Sort(sorter) - default: - sorter := &assetSorter{ - data: sortedAssets, - sortBy: assetSortByHostName, - } - sort.Sort(sorter) - } - return sortedAssets -} - -type assetSorter struct { - data []Asset - sortBy func(asset1, asset2 *Asset) bool -} - -func (s *assetSorter) Len() int { - return len(s.data) -} - -func (s *assetSorter) Swap(i, j int) { - s.data[i], s.data[j] = s.data[j], s.data[i] -} - -func (s *assetSorter) Less(i, j int) bool { - return s.sortBy(&s.data[i], &s.data[j]) -} - -func assetSortByIP(asset1, asset2 *Asset) bool { - iIPs := strings.Split(asset1.IP, ".") - jIPs := strings.Split(asset2.IP, ".") - for i := 0; i < len(iIPs); i++ { - if i >= len(jIPs) { - return false - } - if len(iIPs[i]) == len(jIPs[i]) { - if iIPs[i] == jIPs[i] { - continue - } else { - return iIPs[i] < jIPs[i] - } - } else { - return len(iIPs[i]) < len(jIPs[i]) - } - - } - return true - -} - -func assetSortByHostName(asset1, asset2 *Asset) bool { - return asset1.Hostname < asset2.Hostname -} diff --git a/pkg/jms-sdk-go/model/audit.go b/pkg/jms-sdk-go/model/audit.go deleted file mode 100644 index 0862f5014..000000000 --- a/pkg/jms-sdk-go/model/audit.go +++ /dev/null @@ -1,30 +0,0 @@ -package model - -import ( - "github.com/jumpserver/koko/pkg/jms-sdk-go/common" -) - -type FTPLog struct { - User string `json:"user"` - Hostname string `json:"asset"` - OrgID string `json:"org_id"` - SystemUser string `json:"system_user"` - RemoteAddr string `json:"remote_addr"` - Operate string `json:"operate"` - Path string `json:"filename"` - DateStart common.UTCTime `json:"date_start"` - IsSuccess bool `json:"is_success"` -} - -const ( - OperateDownload = "Download" - OperateUpload = "Upload" -) - -const ( - OperateRemoveDir = "Rmdir" - OperateRename = "Rename" - OperateMkdir = "Mkdir" - OperateDelete = "Delete" - OperateSymlink = "Symlink" -) diff --git a/pkg/jms-sdk-go/model/audit_command.go b/pkg/jms-sdk-go/model/audit_command.go deleted file mode 100644 index c53971d55..000000000 --- a/pkg/jms-sdk-go/model/audit_command.go +++ /dev/null @@ -1,27 +0,0 @@ -package model - -import "time" - -type Command struct { - SessionID string `json:"session"` - OrgID string `json:"org_id"` - Input string `json:"input"` - Output string `json:"output"` - User string `json:"user"` - Server string `json:"asset"` - SystemUser string `json:"system_user"` - Timestamp int64 `json:"timestamp"` - RiskLevel int64 `json:"risk_level"` - - DateCreated time.Time `json:"@timestamp"` -} - -const ( - HighRiskFlag = "1" - LessRiskFlag = "0" -) - -const ( - DangerLevel = 5 - NormalLevel = 0 -) diff --git a/pkg/jms-sdk-go/model/const.go b/pkg/jms-sdk-go/model/const.go deleted file mode 100644 index 8b5379070..000000000 --- a/pkg/jms-sdk-go/model/const.go +++ /dev/null @@ -1 +0,0 @@ -package model diff --git a/pkg/jms-sdk-go/model/expire.go b/pkg/jms-sdk-go/model/expire.go deleted file mode 100644 index 51dc1a25d..000000000 --- a/pkg/jms-sdk-go/model/expire.go +++ /dev/null @@ -1,14 +0,0 @@ -package model - -import ( - "time" -) - -type ExpireInfo struct { - HasPermission bool `json:"has_permission"` - ExpireAt int64 `json:"expire_at"` -} - -func (e *ExpireInfo) IsExpired(now time.Time) bool { - return e.ExpireAt < now.Unix() -} diff --git a/pkg/jms-sdk-go/model/heartbeat.go b/pkg/jms-sdk-go/model/heartbeat.go deleted file mode 100644 index 62986047a..000000000 --- a/pkg/jms-sdk-go/model/heartbeat.go +++ /dev/null @@ -1,9 +0,0 @@ -package model - -type HeartbeatData struct { - SessionOnlineIds []string `json:"sessions"` - SessionOnline int `json:"session_online"` - CpuUsed float64 `json:"cpu_load"` - MemoryUsed float64 `json:"memory_used"` - DiskUsed float64 `json:"disk_used"` -} diff --git a/pkg/jms-sdk-go/model/node.go b/pkg/jms-sdk-go/model/node.go deleted file mode 100644 index 935e54884..000000000 --- a/pkg/jms-sdk-go/model/node.go +++ /dev/null @@ -1,77 +0,0 @@ -package model - -import ( - "sort" - "strconv" - "strings" -) - -type NodeList []Node - -type Node struct { - ID string `json:"id"` - Key string `json:"key"` - Name string `json:"name"` - Value string `json:"value"` - Parent string `json:"parent"` - AssetsAmount int `json:"assets_amount"` - OrgID string `json:"org_id"` -} - -type nodeSortBy func(node1, node2 *Node) bool - -func (by nodeSortBy) Sort(nodes []Node) { - nodeSorter := &AssetNodeSorter{ - nodes: nodes, - sortBy: by, - } - sort.Sort(nodeSorter) -} - -type AssetNodeSorter struct { - nodes []Node - sortBy func(node1, node2 *Node) bool -} - -func (a *AssetNodeSorter) Len() int { - return len(a.nodes) -} - -func (a *AssetNodeSorter) Swap(i, j int) { - a.nodes[i], a.nodes[j] = a.nodes[j], a.nodes[i] -} - -func (a *AssetNodeSorter) Less(i, j int) bool { - return a.sortBy(&a.nodes[i], &a.nodes[j]) -} - -/* -key的排列顺序: -1 1:3 1:3:0 1:4 1:5 1:8 -*/ -func keySort(node1, node2 *Node) bool { - node1Keys := strings.Split(node1.Key, ":") - node2Keys := strings.Split(node2.Key, ":") - for i := 0; i < len(node1Keys); i++ { - if i >= len(node2Keys) { - return false - } - if node1Keys[i] == node2Keys[i] { - continue - } - node1num, err := strconv.Atoi(node1Keys[i]) - if err != nil { - return true - } - node2num, err := strconv.Atoi(node2Keys[i]) - if err != nil { - return false - } - return node1num < node2num - } - return true -} - -func SortNodesByKey(nodes []Node) { - nodeSortBy(keySort).Sort(nodes) -} \ No newline at end of file diff --git a/pkg/jms-sdk-go/model/node_tree.go b/pkg/jms-sdk-go/model/node_tree.go deleted file mode 100644 index 48f125eb6..000000000 --- a/pkg/jms-sdk-go/model/node_tree.go +++ /dev/null @@ -1,56 +0,0 @@ -package model - -import "strings" - -type NodeTreeList []NodeTree - -type NodeTree struct { - ID string `json:"id"` - Name string `json:"name"` - Title string `json:"title"` - Pid string `json:"pId"` - IsParent bool `json:"isParent"` - Meta TreeMeta `json:"meta"` - - ChkDisabled bool `json:"chkDisabled"` // 判断资产是否禁用 -} - -type TreeMeta struct { - Type string `json:"type"` - Data NodeTreeMeta `json:"data"` -} - -type NodeTreeMeta struct { - ID string `json:"id"` - - NodeMeta - AssetMeta -} - -func (n NodeTreeMeta) IsSupportProtocol(protocol string) bool { - for _, item := range n.Protocols { - if strings.Contains(strings.ToLower(item), - strings.ToLower(protocol)) { - return true - } - } - return false -} - -type NodeMeta struct { - Key string `json:"key"` - Value string `json:"value"` -} - -type AssetMeta struct { - Hostname string `json:"hostname"` - IP string `json:"ip"` - Protocols []string `json:"protocols"` - Platform string `json:"platform"` - OrgName string `json:"org_name"` -} - -const ( - TreeTypeNode = "node" - TreeTypeAsset = "asset" -) diff --git a/pkg/jms-sdk-go/model/pagination.go b/pkg/jms-sdk-go/model/pagination.go deleted file mode 100644 index b608818d9..000000000 --- a/pkg/jms-sdk-go/model/pagination.go +++ /dev/null @@ -1,15 +0,0 @@ -package model - -type PaginationResponse struct { - Total int `json:"count"` - NextURL string `json:"next"` - PreviousURL string `json:"previous"` - Data []map[string]interface{} `json:"results"` -} - -type PaginationParam struct { - PageSize int - Offset int - Searches []string - Refresh bool -} diff --git a/pkg/jms-sdk-go/model/permission.go b/pkg/jms-sdk-go/model/permission.go deleted file mode 100644 index 6df607a92..000000000 --- a/pkg/jms-sdk-go/model/permission.go +++ /dev/null @@ -1,55 +0,0 @@ -package model - -type Permission struct { - Actions []string `json:"actions"` -} - -func (p *Permission) EnableConnect() bool { - return p.haveAction(ActionConnect) -} - -func (p *Permission) EnableDrive() bool { - return p.EnableDownload() || p.EnableUpload() -} - -func (p *Permission) EnableDownload() bool { - return p.haveAction(ActionDownload) -} - -func (p *Permission) EnableUpload() bool { - return p.haveAction(ActionUpload) -} - -func (p *Permission) EnableCopy() bool { - return p.haveAction(ActionCopy) -} - -func (p *Permission) EnablePaste() bool { - return p.haveAction(ActionPaste) -} - -func (p *Permission) haveAction(action string) bool { - for _, value := range p.Actions { - if action == ActionALL || action == value { - return true - } - } - return false -} - -const ( - ActionALL = "all" - ActionConnect = "connect" - ActionUpload = "upload_file" - ActionDownload = "download_file" - ActionUploadDownLoad = "updownload" - ActionCopy = "clipboard_copy" - ActionPaste = "clipboard_paste" - ActionCopyPaste = "clipboard_copy_paste" -) - -type ValidateResult struct { - Ok bool `json:"ok"` - Msg string `json:"msg"` - Err string `json:"error"` -} \ No newline at end of file diff --git a/pkg/jms-sdk-go/model/platform.go b/pkg/jms-sdk-go/model/platform.go deleted file mode 100644 index 9f34acaa7..000000000 --- a/pkg/jms-sdk-go/model/platform.go +++ /dev/null @@ -1,8 +0,0 @@ -package model - -type Platform struct { - Name string `json:"name"` - BaseOs string `json:"base"` - Charset string `json:"charset"` - MetaData map[string]interface{} `json:"meta"` -} diff --git a/pkg/jms-sdk-go/model/remote_app.go b/pkg/jms-sdk-go/model/remote_app.go deleted file mode 100644 index be06a1322..000000000 --- a/pkg/jms-sdk-go/model/remote_app.go +++ /dev/null @@ -1,14 +0,0 @@ -package model - -type RemoteAPP struct { - ID string `json:"id"` - Name string `json:"name"` - AssetId string `json:"asset"` - Parameters RemoteAppParameter `json:"parameter_remote_app"` -} - -type RemoteAppParameter struct { - Parameters string `json:"parameters"` - Program string `json:"program"` - WorkingDirectory string `json:"working_directory"` -} diff --git a/pkg/jms-sdk-go/model/session.go b/pkg/jms-sdk-go/model/session.go deleted file mode 100644 index ed77f9504..000000000 --- a/pkg/jms-sdk-go/model/session.go +++ /dev/null @@ -1,52 +0,0 @@ -package model - -import ( - "strings" - - "github.com/jumpserver/koko/pkg/jms-sdk-go/common" -) - -type Session struct { - ID string `json:"id"` - // "%s(%s)" Name Username - User string `json:"user"` - Asset string `json:"asset"` - SystemUser string `json:"system_user"` - LoginFrom string `json:"login_from"` - RemoteAddr string `json:"remote_addr"` - Protocol string `json:"protocol"` - DateStart common.UTCTime `json:"date_start"` - OrgID string `json:"org_id"` - UserID string `json:"user_id"` - AssetID string `json:"asset_id"` - SystemUserID string `json:"system_user_id"` -} - -type ReplayVersion string - -const ( - UnKnown ReplayVersion = "" - Version2 ReplayVersion = "2" - Version3 ReplayVersion = "3" -) - -const ( - SuffixReplayGz = ".replay.gz" - SuffixCastGz = ".cast.gz" - SuffixCast = ".cast" - SuffixGz = ".gz" -) - -var SuffixMap = map[ReplayVersion]string{ - Version2: SuffixReplayGz, - Version3: SuffixCastGz, -} - -func ParseReplayVersion(gzFile string, defaultValue ReplayVersion) ReplayVersion { - for version, suffix := range SuffixMap { - if strings.HasSuffix(gzFile, suffix) { - return version - } - } - return defaultValue -} diff --git a/pkg/jms-sdk-go/model/setting.go b/pkg/jms-sdk-go/model/setting.go deleted file mode 100644 index d95318d74..000000000 --- a/pkg/jms-sdk-go/model/setting.go +++ /dev/null @@ -1,51 +0,0 @@ -package model - -type PublicSetting struct { - LoginTitle string `json:"LOGIN_TITLE"` - LogoURLS struct { - LogOut string `json:"logo_logout"` - Index string `json:"logo_index"` - Image string `json:"login_image"` - Favicon string `json:"favicon"` - } `json:"LOGO_URLS"` - EnableWatermark bool `json:"SECURITY_WATERMARK_ENABLED"` - EnableSessionShare bool `json:"SECURITY_SESSION_SHARE"` -} - -/* -{ - "WINDOWS_SKIP_ALL_MANUAL_PASSWORD": false, - "SECURITY_MAX_IDLE_TIME": 3, - "XPACK_ENABLED": true, - "LOGIN_CONFIRM_ENABLE": true, - "SECURITY_VIEW_AUTH_NEED_MFA": true, - "SECURITY_MFA_VERIFY_TTL": 60, - "OLD_PASSWORD_HISTORY_LIMIT_COUNT": 2, - "SECURITY_COMMAND_EXECUTION": true, - "SECURITY_PASSWORD_EXPIRATION_TIME": 10000, - "SECURITY_LUNA_REMEMBER_AUTH": true, - "XPACK_LICENSE_IS_VALID": true, - "LOGIN_TITLE": "欢迎使用JumpServer开源堡垒机", - "LOGO_URLS": { - "logo_logout": "/static/img/logo.png", - "logo_index": "/static/img/logo_text.png", - "login_image": "/static/img/login_image.jpg", - "favicon": "/static/img/facio.ico" - }, - "TICKETS_ENABLED": true, - "PASSWORD_RULE": { - "SECURITY_PASSWORD_MIN_LENGTH": 6, - "SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH": 6, - "SECURITY_PASSWORD_UPPER_CASE": false, - "SECURITY_PASSWORD_LOWER_CASE": false, - "SECURITY_PASSWORD_NUMBER": false, - "SECURITY_PASSWORD_SPECIAL_CHAR": false - }, - "AUTH_WECOM": true, - "AUTH_DINGTALK": true, - "AUTH_FEISHU": true, - "SECURITY_WATERMARK_ENABLED": true, - "SECURITY_SESSION_SHARE": true, - "XRDP_ENABLED": true -} -*/ diff --git a/pkg/jms-sdk-go/model/share.go b/pkg/jms-sdk-go/model/share.go deleted file mode 100644 index 2a7449516..000000000 --- a/pkg/jms-sdk-go/model/share.go +++ /dev/null @@ -1,22 +0,0 @@ -package model - -type SharingSession struct { - ID string `json:"id"` - IsActive bool `json:"is_active"` - ExpiredTime int `json:"expired_time"` - Session string `json:"session"` - OrgId string `json:"org_id"` - OrgName string `json:"org_name"` - Code string `json:"verify_code"` -} - -type ShareRecord struct { - ID string `json:"id"` - Code string `json:"verify_code"` - SessionId string `json:"session"` - ShareId string `json:"sharing"` - OrgId string `json:"org_id"` - OrgName string `json:"org_name"` - Joiner string `json:"joiner"` - Err interface{} `json:"error"` -} diff --git a/pkg/jms-sdk-go/model/system_user.go b/pkg/jms-sdk-go/model/system_user.go deleted file mode 100644 index 47aac184a..000000000 --- a/pkg/jms-sdk-go/model/system_user.go +++ /dev/null @@ -1,101 +0,0 @@ -package model - -import ( - "fmt" - "sort" - "strings" -) - -const LoginModeManual = "manual" - -const ( - AllAction = "all" - ConnectAction = "connect" - UploadAction = "upload_file" - DownloadAction = "download_file" -) - -type SystemUser struct { - ID string `json:"id"` - Name string `json:"name"` - Username string `json:"username"` - Priority int `json:"priority"` - Protocol string `json:"protocol"` - AdDomain string `json:"ad_domain"` - Comment string `json:"comment"` - LoginMode string `json:"login_mode"` - Password string `json:"-"` - PrivateKey string `json:"-"` - Actions []string `json:"actions"` - SftpRoot string `json:"sftp_root"` - OrgId string `json:"org_id"` - OrgName string `json:"org_name"` - UsernameSameWithUser bool `json:"username_same_with_user"` - Token string `json:"-"` - SuEnabled bool `json:"su_enabled"` - SuFrom string `json:"su_from"` -} - -func (s *SystemUser) String() string { - return fmt.Sprintf("%s(%s)", s.Name, s.Username) -} - -func (s *SystemUser) IsProtocol(p string) bool { - return strings.EqualFold(s.Protocol, p) -} - -type SystemUserAuthInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Username string `json:"username"` - Protocol string `json:"protocol"` - LoginMode string `json:"login_mode"` - Password string `json:"password"` - PrivateKey string `json:"private_key"` - AdDomain string `json:"ad_domain"` - Token string `json:"token"` - OrgId string `json:"org_id"` - OrgName string `json:"org_name"` - PublicKey string `json:"public_key"` - - UsernameSameWithUser bool `json:"username_same_with_user"` -} - -func (s *SystemUserAuthInfo) String() string { - return fmt.Sprintf("%s(%s)", s.Name, s.Username) -} - -type systemUserSortBy func(sys1, sys2 *SystemUser) bool - -func (by systemUserSortBy) Sort(sysUsers []SystemUser) { - nodeSorter := &systemUserSorter{ - users: sysUsers, - sortBy: by, - } - sort.Sort(nodeSorter) -} - -type systemUserSorter struct { - users []SystemUser - sortBy systemUserSortBy -} - -func (s *systemUserSorter) Len() int { - return len(s.users) -} - -func (s *systemUserSorter) Swap(i, j int) { - s.users[i], s.users[j] = s.users[j], s.users[i] -} - -func (s *systemUserSorter) Less(i, j int) bool { - return s.sortBy(&s.users[i], &s.users[j]) -} - -func systemUserPrioritySort(use1, user2 *SystemUser) bool { - return use1.Priority < user2.Priority -} - -func SortSystemUserByPriority(sysUsers []SystemUser) { - systemUserSortBy(systemUserPrioritySort).Sort(sysUsers) -} diff --git a/pkg/jms-sdk-go/model/system_user_filter_rule.go b/pkg/jms-sdk-go/model/system_user_filter_rule.go deleted file mode 100644 index 45a0b4411..000000000 --- a/pkg/jms-sdk-go/model/system_user_filter_rule.go +++ /dev/null @@ -1,100 +0,0 @@ -package model - -import ( - "regexp" - "regexp/syntax" - "sort" -) - -type RuleAction int - -const ( - ActionDeny RuleAction = 0 - ActionAllow RuleAction = 9 - ActionConfirm RuleAction = 2 - ActionUnknown RuleAction = 3 - - TypeRegex = "regex" - TypeCmd = "command" -) - -type SystemUserFilterRule struct { - ID string `json:"id"` - Priority int `json:"priority"` - Type string `json:"type"` - Content string `json:"content"` - Action RuleAction `json:"action"` - OrgId string `json:"org_id"` - RePattern string `json:"pattern"` // 已经处理过的正则字符 - IgnoreCase bool `json:"ignore_case"` - - pattern *regexp.Regexp - compiled bool -} - -func (sf *SystemUserFilterRule) Pattern() *regexp.Regexp { - if sf.compiled { - return sf.pattern - } - syntaxFlag := syntax.Perl - if sf.IgnoreCase { - syntaxFlag = syntax.Perl | syntax.FoldCase - } - syntaxReg, err := syntax.Parse(sf.RePattern, syntaxFlag) - if err != nil { - return nil - } - pattern, err := regexp.Compile(syntaxReg.String()) - if err == nil { - sf.pattern = pattern - sf.compiled = true - } - return pattern -} - -func (sf *SystemUserFilterRule) Match(cmd string) (RuleAction, string) { - pattern := sf.Pattern() - if pattern == nil { - return ActionUnknown, "" - } - found := pattern.FindString(cmd) - if found == "" { - return ActionUnknown, "" - } - return sf.Action, found -} - -var _ sort.Interface = FilterRules{} - -type FilterRules []SystemUserFilterRule - -func (f FilterRules) Swap(i, j int) { - f[i], f[j] = f[j], f[i] -} - -func (f FilterRules) Len() int { - return len(f) -} - -/* - core 优先级的值越小,优先级越高,因此按此排序,第一个是优先级最高的 - 优先级相同则 Action Deny 的优先级更高 -*/ - -func (f FilterRules) Less(i, j int) bool { - switch { - case f[i].Priority == f[j].Priority: - return actionPriorityMap[f[i].Action] < actionPriorityMap[f[j].Action] - default: - return f[i].Priority < f[j].Priority - } -} - -var ( - actionPriorityMap = map[RuleAction]int{ - ActionDeny: 0, - ActionConfirm: 1, - ActionAllow: 2, - ActionUnknown: 3, - } -) diff --git a/pkg/jms-sdk-go/model/terminal.go b/pkg/jms-sdk-go/model/terminal.go deleted file mode 100644 index 08c474983..000000000 --- a/pkg/jms-sdk-go/model/terminal.go +++ /dev/null @@ -1,69 +0,0 @@ -package model - -type TerminalConfig struct { - AssetListPageSize string `json:"TERMINAL_ASSET_LIST_PAGE_SIZE"` - AssetListSortBy string `json:"TERMINAL_ASSET_LIST_SORT_BY"` - HeaderTitle string `json:"TERMINAL_HEADER_TITLE"` - PasswordAuth bool `json:"TERMINAL_PASSWORD_AUTH"` - PublicKeyAuth bool `json:"TERMINAL_PUBLIC_KEY_AUTH"` - ReplayStorage ReplayConfig `json:"TERMINAL_REPLAY_STORAGE"` - CommandStorage map[string]interface{} `json:"TERMINAL_COMMAND_STORAGE"` - SessionKeepDuration int `json:"TERMINAL_SESSION_KEEP_DURATION"` - TelnetRegex string `json:"TERMINAL_TELNET_REGEX"` - MaxIdleTime int `json:"SECURITY_MAX_IDLE_TIME"` - HeartbeatDuration int `json:"TERMINAL_HEARTBEAT_INTERVAL"` - HostKey string `json:"TERMINAL_HOST_KEY"` - EnableSessionShare bool `json:"SECURITY_SESSION_SHARE"` -} - -type Terminal struct { - Name string `json:"name"` - Comment string `json:"comment"` - ServiceAccount struct { - ID string `json:"id"` - Name string `json:"name"` - AccessKey AccessKey `json:"access_key"` - } `json:"service_account"` -} - -type TerminalTask struct { - ID string `json:"id"` - Name string `json:"name"` - Args string `json:"args"` - Kwargs TaskKwargs `json:"kwargs"` - IsFinished bool -} - -const ( - TaskKillSession = "kill_session" -) - -type TaskKwargs struct { - TerminatedBy string `json:"terminated_by"` -} - -type ReplayConfig struct { - TypeName string `json:"TYPE"` - - /* - obs oss - */ - Endpoint string `json:"ENDPOINT,omitempty"` - Bucket string `json:"BUCKET,omitempty"` - AccessKey string `json:"ACCESS_KEY,omitempty"` - SecretKey string `json:"SECRET_KEY,omitempty"` - - /* - s3、 swift cos - */ - - Region string `json:"REGION,omitempty"` - - /* - azure 专属 - */ - AccountName string `json:"ACCOUNT_NAME,omitempty"` - AccountKey string `json:"ACCOUNT_KEY,omitempty"` - EndpointSuffix string `json:"ENDPOINT_SUFFIX,omitempty"` - ContainerName string `json:"CONTAINER_NAME,omitempty"` -} diff --git a/pkg/jms-sdk-go/model/ticket.go b/pkg/jms-sdk-go/model/ticket.go deleted file mode 100644 index 63c187778..000000000 --- a/pkg/jms-sdk-go/model/ticket.go +++ /dev/null @@ -1,37 +0,0 @@ -package model - -type CommandTicketInfo struct { - TicketInfo -} - -type ReqInfo struct { - Method string `json:"method"` - URL string `json:"url"` -} - -type TicketState struct { - ID string `json:"id"` - Processor string `json:"processor,omitempty"` - State string `json:"state"` - Status string `json:"status"` -} - -const ( - TicketOpen = "open" - TicketApproved = "approved" - TicketRejected = "rejected" - TicketClosed = "closed" -) - -type AssetLoginTicketInfo struct { - TicketId string `json:"ticket_id"` - NeedConfirm bool `json:"need_confirm"` - TicketInfo -} - -type TicketInfo struct { - CheckReq ReqInfo `json:"check_confirm_status"` - CloseReq ReqInfo `json:"close_confirm"` - TicketDetailUrl string `json:"ticket_detail_url"` - Reviewers []string `json:"reviewers"` -} diff --git a/pkg/jms-sdk-go/model/user.go b/pkg/jms-sdk-go/model/user.go deleted file mode 100644 index 24902fdf7..000000000 --- a/pkg/jms-sdk-go/model/user.go +++ /dev/null @@ -1,46 +0,0 @@ -package model - -import ( - "fmt" -) - -/* - {'id': '1f8e54a8-d99d-4074-b35d-45264adb4e34', - 'name': 'EricdeMBP.lan', - 'username': 'EricdeMBP.lan', - 'email': 'EricdeMBP.lan@serviceaccount.local', - 'groups': [], - 'groups_display': '', - 'role': 'App','role_display': '应用程序', - 'avatar_url': '/static/img/avatar/user.png', - 'wechat': '','phone': None, 'otp_level': 0, - 'comment': '', 'source': 'local', - 'source_display': 'Local', - 'is_valid': True, 'is_expired': False, - 'is_active': True, 'created_by': '', - 'is_first_login': True, 'date_password_last_updated': '2019-04-08 18:18:24 +0800', - 'date_expired': '2089-03-21 18:18:24 +0800'} -*/ -type User struct { - ID string `json:"id"` - Name string `json:"name"` - Username string `json:"username"` - Email string `json:"email"` - Role string `json:"role"` - IsValid bool `json:"is_valid"` - IsActive bool `json:"is_active"` - OTPLevel int `json:"otp_level"` -} - -func (u *User) String() string { - return fmt.Sprintf("%s(%s)", u.Name, u.Username) -} - -type TokenUser struct { - UserID string `json:"user"` - UserName string `json:"username"` - AssetID string `json:"asset"` - Hostname string `json:"hostname"` - SystemUserID string `json:"system_user"` - SystemUserName string `json:"system_user_name"` -} diff --git a/pkg/jms-sdk-go/service/jms.go b/pkg/jms-sdk-go/service/jms.go deleted file mode 100644 index f9862e0c1..000000000 --- a/pkg/jms-sdk-go/service/jms.go +++ /dev/null @@ -1,95 +0,0 @@ -package service - -import ( - "errors" - "fmt" - "net/http" - "sync" - "time" - - "github.com/jumpserver/koko/pkg/jms-sdk-go/httplib" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -var AccessKeyUnauthorized = errors.New("access key unauthorized") - -var ConnectErr = errors.New("api connect err") - -const ( - minTimeOut = time.Second * 30 - - orgHeaderKey = "X-JMS-ORG" - orgHeaderValue = "ROOT" -) - -func NewAuthJMService(opts ...Option) (*JMService, error) { - opt := option{ - CoreHost: "http://127.0.0.1:8080", - TimeOut: time.Minute, - } - for _, setter := range opts { - setter(&opt) - } - if opt.TimeOut < minTimeOut { - opt.TimeOut = minTimeOut - } - httpClient, err := httplib.NewClient(opt.CoreHost, opt.TimeOut) - if err != nil { - return nil, err - } - if opt.sign != nil { - httpClient.SetAuthSign(opt.sign) - } - httpClient.SetHeader(orgHeaderKey, orgHeaderValue) - return &JMService{authClient: httpClient, opt: &opt}, nil -} - -type JMService struct { - authClient *httplib.Client - opt *option - - sync.Mutex -} - -func (s *JMService) GetUserById(userID string) (user *model.User, err error) { - url := fmt.Sprintf(UserDetailURL, userID) - _, err = s.authClient.Get(url, &user) - return -} - -func (s *JMService) GetProfile() (user *model.User, err error) { - var res *http.Response - res, err = s.authClient.Get(UserProfileURL, &user) - if res == nil && err != nil { - return nil, fmt.Errorf("%w:%s", ConnectErr, err.Error()) - } - if res != nil && res.StatusCode == http.StatusUnauthorized { - return user, AccessKeyUnauthorized - } - return user, err -} - -func (s *JMService) GetTerminalConfig() (conf model.TerminalConfig, err error) { - _, err = s.authClient.Get(TerminalConfigURL, &conf) - return -} - -func (s *JMService) CloneClient() httplib.Client { - return s.authClient.Clone() -} - -func (s *JMService) Copy() *JMService { - client := s.authClient.Clone() - if s.opt.sign != nil { - client.SetAuthSign(s.opt.sign) - } - client.SetHeader(orgHeaderKey, orgHeaderValue) - return &JMService{ - authClient: &client, - opt: s.opt, - } -} - -func (s *JMService) SetCookie(name, value string) { - s.authClient.SetCookie(name, value) -} diff --git a/pkg/jms-sdk-go/service/jms_application.go b/pkg/jms-sdk-go/service/jms_application.go deleted file mode 100644 index 5ad9898ec..000000000 --- a/pkg/jms-sdk-go/service/jms_application.go +++ /dev/null @@ -1,31 +0,0 @@ -package service - -import ( - "fmt" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) GetApplicationById(appId string) (app model.Application, err error) { - reqUrl := fmt.Sprintf(ApplicationDetailURL, appId) - _, err = s.authClient.Get(reqUrl, &app) - return -} - -func (s *JMService) GetUserApplicationAuthInfo(systemUserID, appID, userID, username string) (info model.SystemUserAuthInfo, err error) { - Url := fmt.Sprintf(SystemUserAppAuthURL, systemUserID, appID) - params := make(map[string]string) - if username != "" { - params["username"] = username - } - if userID != "" { - params["user_id"] = userID - } - _, err = s.authClient.Get(Url, &info, params) - return -} - -func (s *JMService) GetUserApplicationSystemUsers(userId, appId string) (res []model.SystemUser, err error) { - reqUrl := fmt.Sprintf(UserPermsApplicationSystemUsersURL, userId, appId) - _, err = s.authClient.Get(reqUrl, &res) - return -} diff --git a/pkg/jms-sdk-go/service/jms_asset.go b/pkg/jms-sdk-go/service/jms_asset.go deleted file mode 100644 index edce5dc1f..000000000 --- a/pkg/jms-sdk-go/service/jms_asset.go +++ /dev/null @@ -1,45 +0,0 @@ -package service - -import ( - "fmt" - - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) GetAssetById(assetId string) (asset model.Asset, err error) { - url := fmt.Sprintf(AssetDetailURL, assetId) - _, err = s.authClient.Get(url, &asset) - return -} - -func (s *JMService) GetAssetPlatform(assetId string) (platform model.Platform, err error) { - url := fmt.Sprintf(AssetPlatFormURL, assetId) - _, err = s.authClient.Get(url, &platform) - return -} - -func (s *JMService) GetDomainGateways(domainId string) (domain model.Domain, err error) { - Url := fmt.Sprintf(DomainDetailWithGateways, domainId) - _, err = s.authClient.Get(Url, &domain) - return -} - -func (s *JMService) GetSystemUserById(systemUserId string) (sysUser model.SystemUser, err error) { - url := fmt.Sprintf(SystemUserDetailURL, systemUserId) - _, err = s.authClient.Get(url, &sysUser) - return -} - -func (s *JMService) GetSystemUserAuthById(systemUserId, assetId, userId, - username string) (sysUser model.SystemUserAuthInfo, err error) { - url := fmt.Sprintf(SystemUserAuthURL, systemUserId) - if assetId != "" { - url = fmt.Sprintf(SystemUserAssetAuthURL, systemUserId, assetId) - } - params := map[string]string{ - "username": username, - "user_id": userId, - } - _, err = s.authClient.Get(url, &sysUser, params) - return -} diff --git a/pkg/jms-sdk-go/service/jms_audit.go b/pkg/jms-sdk-go/service/jms_audit.go deleted file mode 100644 index 74ebe7654..000000000 --- a/pkg/jms-sdk-go/service/jms_audit.go +++ /dev/null @@ -1,21 +0,0 @@ -package service - -import ( - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) CreateFileOperationLog(data model.FTPLog) (err error) { - _, err = s.authClient.Post(FTPLogListURL, data, nil) - return -} - -func (s *JMService) PushSessionCommand(commands []*model.Command) (err error) { - _, err = s.authClient.Post(SessionCommandURL, commands, nil) - return -} - - -func (s *JMService) NotifyCommand(commands []*model.Command) (err error) { - _, err = s.authClient.Post(NotificationCommandURL, commands, nil) - return -} \ No newline at end of file diff --git a/pkg/jms-sdk-go/service/jms_filter_rules.go b/pkg/jms-sdk-go/service/jms_filter_rules.go deleted file mode 100644 index fa45063ae..000000000 --- a/pkg/jms-sdk-go/service/jms_filter_rules.go +++ /dev/null @@ -1,67 +0,0 @@ -package service - -import ( - "fmt" - - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) GetSystemUserFilterRules(systemUserID string) (rules []model.SystemUserFilterRule, err error) { - Url := fmt.Sprintf(SystemUserCmdFilterRulesListURL, systemUserID) - _, err = s.authClient.Get(Url, &rules) - return -} - -func (s *JMService) GetCommandFilterRules(userId, sysId, assetId, appId string) (rules []model.SystemUserFilterRule, err error) { - param := make(map[string]string) - if userId != "" { - param["user_id"] = userId - } - if sysId != "" { - param["system_user_id"] = sysId - } - if assetId != "" { - param["asset_id"] = assetId - } - if appId != "" { - param["application_id"] = appId - } - - _, err = s.authClient.Get(CommandFilterRulesListURL, &rules, param) - return -} - -/*[ - { - "id": "12ae03a4-81b7-43d9-b356-2db4d5d63927", - "org_id": "", - "type": "command", - "type_display": "命令", - "priority": 50, - "content": "reboot\r\nrm", - "pattern": "", - action_display: "拒绝", - action: 0, - "comment": "", - "date_created": "2019-04-29 11:32:12 +0800", - "date_updated": "2019-04-29 11:32:12 +0800", - "created_by": "Administrator", - "filter": "de7693ca-75d5-4639-986b-44ed390260a0" - }, - { - "id": "c1fe1ebf-8fdc-4477-b2cf-dd9bc12de832", - "org_id": "", - "type": "regex", - "type_display": "正则表达式", - "priority": 49, - "content": "shutdown|echo|df", - "pattern": "", - "action_display": "允许" - "action": 1, - "comment": "", - "date_created": "2019-04-29 11:32:39 +0800", - "date_updated": "2019-04-29 11:32:50 +0800", - "created_by": "Administrator", - "filter": "de7693ca-75d5-4639-986b-44ed390260a0" - } -]`*/ diff --git a/pkg/jms-sdk-go/service/jms_heartbeat.go b/pkg/jms-sdk-go/service/jms_heartbeat.go deleted file mode 100644 index 13e8927c6..000000000 --- a/pkg/jms-sdk-go/service/jms_heartbeat.go +++ /dev/null @@ -1,19 +0,0 @@ -package service - -import ( - - "github.com/jumpserver/koko/pkg/jms-sdk-go/common" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) TerminalHeartBeat(sIds []string) (res []model.TerminalTask, err error) { - data := model.HeartbeatData{ - SessionOnlineIds: sIds, - CpuUsed: common.CpuLoad1Usage(), - MemoryUsed: common.MemoryUsagePercent(), - DiskUsed: common.DiskUsagePercent(), - SessionOnline: len(sIds), - } - _, err = s.authClient.Post(TerminalHeartBeatURL, data, &res) - return -} diff --git a/pkg/jms-sdk-go/service/jms_perm_application.go b/pkg/jms-sdk-go/service/jms_perm_application.go deleted file mode 100644 index 973d8eef0..000000000 --- a/pkg/jms-sdk-go/service/jms_perm_application.go +++ /dev/null @@ -1,72 +0,0 @@ -package service - -import ( - "fmt" - "strconv" - "strings" - - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) GetAllUserPermMySQLs(userId string) ([]map[string]interface{}, error) { - var param model.PaginationParam - res, err := s.GetUserPermsDatabase(userId, param) - if err != nil { - return nil, err - } - return res.Data, err -} - -func (s *JMService) GetAllUserPermK8s(userId string) ([]map[string]interface{}, error) { - var param model.PaginationParam - res, err := s.GetUserPermsK8s(userId, param) - if err != nil { - return nil, err - } - return res.Data, err -} - -func (s *JMService) GetUserPermsMySQL(userId string, param model.PaginationParam) (resp model.PaginationResponse, err error) { - reqUrl := fmt.Sprintf(UserPermsApplicationsURL, userId, model.AppTypeMySQL) - return s.getPaginationResult(reqUrl, param) -} - -func (s *JMService) GetUserPermsDatabase(userId string, param model.PaginationParam) (resp model.PaginationResponse, err error) { - reqUrl := fmt.Sprintf(UserPermsDatabaseURL, userId) - return s.getPaginationResult(reqUrl, param) -} - -func (s *JMService) GetUserPermsK8s(userId string, param model.PaginationParam) (resp model.PaginationResponse, err error) { - reqUrl := fmt.Sprintf(UserPermsApplicationsURL, userId, model.AppTypeK8s) - return s.getPaginationResult(reqUrl, param) -} - -func (s *JMService) getPaginationResult(reqUrl string, param model.PaginationParam) (resp model.PaginationResponse, err error) { - if param.PageSize < 0 { - param.PageSize = 0 - } - paramsArray := make([]map[string]string, 0, len(param.Searches)+2) - for i := 0; i < len(param.Searches); i++ { - paramsArray = append(paramsArray, map[string]string{ - "search": strings.TrimSpace(param.Searches[i]), - }) - } - - params := map[string]string{ - "limit": strconv.Itoa(param.PageSize), - "offset": strconv.Itoa(param.Offset), - } - if param.Refresh { - params["rebuild_tree"] = "1" - } - paramsArray = append(paramsArray, params) - if param.PageSize > 0 { - _, err = s.authClient.Get(reqUrl, &resp, paramsArray...) - } else { - var data []map[string]interface{} - _, err = s.authClient.Get(reqUrl, &data, paramsArray...) - resp.Data = data - resp.Total = len(data) - } - return -} diff --git a/pkg/jms-sdk-go/service/jms_perm_asset.go b/pkg/jms-sdk-go/service/jms_perm_asset.go deleted file mode 100644 index 520326b24..000000000 --- a/pkg/jms-sdk-go/service/jms_perm_asset.go +++ /dev/null @@ -1,61 +0,0 @@ -package service - -import ( - "fmt" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) SearchPermAsset(userId, key string) (res model.AssetList, err error) { - Url := fmt.Sprintf(UserPermsAssetsURL, userId) - payload := map[string]string{"search": key} - _, err = s.authClient.Get(Url, &res, payload) - return -} - -func (s *JMService) GetSystemUsersByUserIdAndAssetId(userId, assetId string) (sysUsers []model.SystemUser, err error) { - Url := fmt.Sprintf(UserPermsAssetSystemUsersURL, userId, assetId) - _, err = s.authClient.Get(Url, &sysUsers) - return -} - -func (s *JMService) GetAllUserPermsAssets(userId string) ([]map[string]interface{}, error) { - var params model.PaginationParam - res, err := s.GetUserPermsAssets(userId, params) - if err != nil { - return nil, err - } - return res.Data, nil -} - -func (s *JMService) GetUserPermsAssets(userID string, params model.PaginationParam) (resp model.PaginationResponse, err error) { - Url := fmt.Sprintf(UserPermsAssetsURL, userID) - return s.getPaginationResult(Url, params) -} - -func (s *JMService) RefreshUserAllPermsAssets(userId string) ([]map[string]interface{}, error) { - var params model.PaginationParam - params.Refresh = true - res, err := s.GetUserPermsAssets(userId, params) - if err != nil { - return nil, err - } - return res.Data, nil -} - -func (s *JMService) GetUserAssetByID(userId, assetId string) (assets []model.Asset, err error) { - params := map[string]string{ - "id": assetId, - } - Url := fmt.Sprintf(UserPermsAssetsURL, userId) - _, err = s.authClient.Get(Url, &assets, params) - return -} - -func (s *JMService) GetUserPermAssetsByIP(userId, assetIP string) (assets []model.Asset, err error) { - params := map[string]string{ - "ip": assetIP, - } - reqUrl := fmt.Sprintf(UserPermsAssetsURL, userId) - _, err = s.authClient.Get(reqUrl, &assets, params) - return -} diff --git a/pkg/jms-sdk-go/service/jms_perm_node.go b/pkg/jms-sdk-go/service/jms_perm_node.go deleted file mode 100644 index 3cbe9ec39..000000000 --- a/pkg/jms-sdk-go/service/jms_perm_node.go +++ /dev/null @@ -1,27 +0,0 @@ -package service - -import ( - "fmt" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) GetUserNodeAssets(userID, nodeID string, - params model.PaginationParam) (resp model.PaginationResponse, err error) { - Url := fmt.Sprintf(UserPermsNodeAssetsListURL, userID, nodeID) - return s.getPaginationResult(Url, params) -} - -func (s *JMService) GetUserNodes(userId string) (nodes model.NodeList, err error) { - Url := fmt.Sprintf(UserPermsNodesListURL, userId) - _, err = s.authClient.Get(Url, &nodes) - return -} - -func (s *JMService) RefreshUserNodes(userId string) (nodes model.NodeList, err error) { - params := map[string]string{ - "rebuild_tree": "1", - } - Url := fmt.Sprintf(UserPermsNodesListURL, userId) - _, err = s.authClient.Get(Url, &nodes, params) - return -} diff --git a/pkg/jms-sdk-go/service/jms_perm_node_tree.go b/pkg/jms-sdk-go/service/jms_perm_node_tree.go deleted file mode 100644 index 3f3c21e5d..000000000 --- a/pkg/jms-sdk-go/service/jms_perm_node_tree.go +++ /dev/null @@ -1,16 +0,0 @@ -package service - -import ( - "fmt" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) GetNodeTreeByUserAndNodeKey(userID, nodeKey string) (nodeTrees model.NodeTreeList, err error) { - payload := map[string]string{} - if nodeKey != "" { - payload["key"] = nodeKey - } - apiURL := fmt.Sprintf(UserPermsNodeTreeWithAssetURL, userID) - _, err = s.authClient.Get(apiURL, &nodeTrees, payload) - return -} diff --git a/pkg/jms-sdk-go/service/jms_permission.go b/pkg/jms-sdk-go/service/jms_permission.go deleted file mode 100644 index 9233ec555..000000000 --- a/pkg/jms-sdk-go/service/jms_permission.go +++ /dev/null @@ -1,51 +0,0 @@ -package service - -import ( - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) GetPermission(userId, assetId, systemUserId string) (perms model.Permission, err error) { - params := map[string]string{ - "user_id": userId, - "asset_id": assetId, - "system_user_id": systemUserId, - } - _, err = s.authClient.Get(PermissionURL, &perms, params) - return -} - -func (s *JMService) ValidateRemoteAppPermission(userId, remoteAppId, systemUserId string) (info model.ExpireInfo, err error) { - return s.ValidateApplicationPermission(userId, remoteAppId, systemUserId) -} - -func (s *JMService) ValidateApplicationPermission(userId, appId, systemUserId string) (info model.ExpireInfo, err error) { - params := map[string]string{ - "user_id": userId, - "application_id": appId, - "system_user_id": systemUserId, - } - _, err = s.authClient.Get(ValidateApplicationPermissionURL, &info, params) - return -} - -const actionConnect = "connect" - -func (s *JMService) ValidateAssetConnectPermission(userId, assetId, systemUserId string) (info model.ExpireInfo, err error) { - params := map[string]string{ - "user_id": userId, - "asset_id": assetId, - "system_user_id": systemUserId, - "action_name": actionConnect, - } - _, err = s.authClient.Get(ValidateUserAssetPermissionURL, &info, params) - return -} - -func (s *JMService) ValidateJoinSessionPermission(userId, sessionId string) (result model.ValidateResult, err error) { - data := map[string]string{ - "user_id": userId, - "session_id": sessionId, - } - _, err = s.authClient.Post(JoinRoomValidateURL, data, &result) - return -} diff --git a/pkg/jms-sdk-go/service/jms_public_setting.go b/pkg/jms-sdk-go/service/jms_public_setting.go deleted file mode 100644 index da5e66cc6..000000000 --- a/pkg/jms-sdk-go/service/jms_public_setting.go +++ /dev/null @@ -1,13 +0,0 @@ -package service - -import "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - -func (s *JMService) GetPublicSetting() (result model.PublicSetting, err error) { - var response struct { - Data model.PublicSetting `json:"data"` - } - client := s.authClient.Clone() - _, err = client.Get(PublicSettingURL, &response) - result = response.Data - return -} diff --git a/pkg/jms-sdk-go/service/jms_remote_app.go b/pkg/jms-sdk-go/service/jms_remote_app.go deleted file mode 100644 index 448518f45..000000000 --- a/pkg/jms-sdk-go/service/jms_remote_app.go +++ /dev/null @@ -1,12 +0,0 @@ -package service - -import ( - "fmt" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) GetRemoteApp(remoteAppId string) (remoteApp model.RemoteAPP, err error) { - Url := fmt.Sprintf(RemoteAPPURL, remoteAppId) - _, err = s.authClient.Get(Url, &remoteApp) - return -} diff --git a/pkg/jms-sdk-go/service/jms_session.go b/pkg/jms-sdk-go/service/jms_session.go deleted file mode 100644 index d5d56fd95..000000000 --- a/pkg/jms-sdk-go/service/jms_session.go +++ /dev/null @@ -1,77 +0,0 @@ -package service - -import ( - "fmt" - - "github.com/jumpserver/koko/pkg/jms-sdk-go/common" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) Upload(sessionID, gZipFile string) error { - version := model.ParseReplayVersion(gZipFile, model.Version3) - return s.UploadReplay(sessionID, gZipFile, version) -} - -func (s *JMService) UploadReplay(sid, gZipFile string, version model.ReplayVersion) error { - var res map[string]interface{} - Url := fmt.Sprintf(SessionReplayURL, sid) - fields := make(map[string]string) - fields["version"] = string(version) - return s.authClient.PostFileWithFields(Url, gZipFile, fields, &res) -} - -func (s *JMService) FinishReply(sid string) error { - data := map[string]bool{"has_replay": true} - return s.sessionPatch(sid, data) -} - -func (s *JMService) CreateSession(sess model.Session) error { - _, err := s.authClient.Post(SessionListURL, sess, nil) - return err -} - -func (s *JMService) SessionSuccess(sid string) error { - data := map[string]bool{ - "is_success": true, - } - return s.sessionPatch(sid, data) -} - -func (s *JMService) SessionFailed(sid string, err error) error { - data := map[string]bool{ - "is_success": false, - } - return s.sessionPatch(sid, data) -} -func (s *JMService) SessionDisconnect(sid string) error { - return s.SessionFinished(sid, common.NewNowUTCTime()) -} - -func (s *JMService) SessionFinished(sid string, time common.UTCTime) error { - data := map[string]interface{}{ - "is_finished": true, - "date_end": time, - } - return s.sessionPatch(sid, data) -} - -func (s *JMService) sessionPatch(sid string, data interface{}) error { - Url := fmt.Sprintf(SessionDetailURL, sid) - _, err := s.authClient.Patch(Url, data, nil) - return err -} - -func (s *JMService) GetSessionById(sid string) (data model.Session, err error) { - reqURL := fmt.Sprintf(SessionDetailURL, sid) - _, err = s.authClient.Get(reqURL, &data) - return -} - -func (s *JMService) CreateSessionTicketRelation(sid, ticketId string) (err error) { - data := map[string]string{ - "session": sid, - "ticket": ticketId, - } - _, err = s.authClient.Post(TicketSessionURL, data, nil) - return -} diff --git a/pkg/jms-sdk-go/service/jms_share.go b/pkg/jms-sdk-go/service/jms_share.go deleted file mode 100644 index 65f653ba5..000000000 --- a/pkg/jms-sdk-go/service/jms_share.go +++ /dev/null @@ -1,35 +0,0 @@ -package service - -import ( - "fmt" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) CreateShareRoom(sessionId string, expired int) (res model.SharingSession, err error) { - var postData struct { - Session string `json:"session"` - ExpiredTime int `json:"expired_time"` - } - postData.Session = sessionId - postData.ExpiredTime = expired - _, err = s.authClient.Post(ShareCreateURL, postData, &res) - return -} - -func (s *JMService) JoinShareRoom(data SharePostData) (res model.ShareRecord, err error) { - _, err = s.authClient.Post(ShareSessionJoinURL, data, &res) - return -} - -func (s *JMService) FinishShareRoom(recordId string) (err error) { - reqUrl := fmt.Sprintf(ShareSessionFinishURL, recordId) - _, err = s.authClient.Patch(reqUrl, nil, nil) - return -} - -type SharePostData struct { - ShareId string `json:"sharing"` - Code string `json:"verify_code"` - UserId string `json:"joiner"` - RemoteAddr string `json:"remote_addr"` -} diff --git a/pkg/jms-sdk-go/service/jms_task.go b/pkg/jms-sdk-go/service/jms_task.go deleted file mode 100644 index e9ec6d015..000000000 --- a/pkg/jms-sdk-go/service/jms_task.go +++ /dev/null @@ -1,12 +0,0 @@ -package service - -import ( - "fmt" -) - -func (s *JMService) FinishTask(tid string) error { - data := map[string]bool{"is_finished": true} - Url := fmt.Sprintf(FinishTaskURL, tid) - _, err := s.authClient.Patch(Url, data, nil) - return err -} diff --git a/pkg/jms-sdk-go/service/jms_test.go b/pkg/jms-sdk-go/service/jms_test.go deleted file mode 100644 index ad3026d2d..000000000 --- a/pkg/jms-sdk-go/service/jms_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package service - -import ( - "os" - "testing" - - "github.com/jumpserver/koko/pkg/jms-sdk-go/httplib" -) - -func setup() *JMService { - /* - 从环境变量获取 认证信息 - CORE_HOST - access_key_id - access_key_secret - */ - auth := httplib.SigAuth{ - KeyID: os.Getenv("access_key_id"), - SecretID: os.Getenv("access_key_secret"), - } - jms, err := NewAuthJMService(JMSAccessKey(auth.KeyID, auth.SecretID), - JMSCoreHost(os.Getenv("CORE_HOST"))) - if err != nil { - panic(err) - } - return jms -} - -func TestJMService_GetProfile(t *testing.T) { - jms := setup() - user, err := jms.GetProfile() - if err != nil { - t.Fatal(err) - } - t.Logf("%+v", user) -} - -func TestJMService_GetTerminalConfig(t *testing.T) { - jms := setup() - conf, err := jms.GetTerminalConfig() - if err != nil { - t.Fatal(err) - } - t.Logf("%+v", conf) -} - -func TestJMService_GetSystemUserById(t *testing.T) { - jms := setup() - systemId := "33511e29-3058-49c5-85da-56a296494714" - sysUser, err := jms.GetSystemUserById(systemId) - if err != nil { - t.Fatal(err) - } - t.Logf("%+v", sysUser) - -} - -func TestJMService_GetSystemUserAuthById(t *testing.T) { - jms := setup() - systemId := "33511e29-3058-49c5-85da-56a296494714" - sysUser, err := jms.GetSystemUserAuthById(systemId, "", "", "") - if err != nil { - t.Fatal(err) - } - t.Logf("%+v", sysUser) - -} - -func TestJMService_GetAssetById(t *testing.T) { - jms := setup() - assetIds := []string{ - "2e73f0e4-13ec-4f64-b03e-4ecbadab7233", // 有网域 - "bd87e0b9-9a94-48df-9fa1-4aab4c9f49a5", // 无网域 - } - for i := range assetIds { - asset, err := jms.GetAssetById(assetIds[i]) - if err != nil { - t.Fatal(err) - } - t.Logf("%+v\n", asset) - } - -} - -func TestJMService_GetDomainGateways(t *testing.T) { - jms := setup() - domains := []string{ - "aad81461-5f10-40f6-9064-ed6de855d0c7", - } - for i := range domains { - asset, err := jms.GetDomainGateways(domains[i]) - if err != nil { - t.Fatal(err) - } - t.Logf("%+v\n", asset) - } -} - -func TestJMService_GetPermission(t *testing.T) { - jms := setup() - assetId := "bd87e0b9-9a94-48df-9fa1-4aab4c9f49a5" - sysId := "33511e29-3058-49c5-85da-56a296494714" - userId := "68f1648b-5c6c-4f47-97a1-c47c192458e3" - perms, err := jms.GetPermission(userId, assetId, sysId) - t.Logf("%+v,%+v", perms, err) -} - -func TestJMService_ValidateRemoteApp(t *testing.T) { - jms := setup() - remoteId := "9f2313df-bd54-4428-9708-b9e54eba735a" - sysId := "d9341b5a-426c-4d3a-8a10-2c23a7e06997" - userId := "68f1648b-5c6c-4f47-97a1-c47c192458e3" - info, err := jms.ValidateRemoteAppPermission(userId, remoteId, sysId) - t.Logf("%+v,%+v", info, err) - -} - -func TestJMService_SubmitCommandConfirm(t *testing.T) { - jms := setup() - sid := "8e7df6b6-795c-4904-bd17-f3bf2855ae9f" - ruleId := "0fd1112f-1c14-4457-bff8-62e21b1a64a2" - command := "ls" - res, err := jms.SubmitCommandConfirm(sid, ruleId, command) - t.Log(res, err) -} - -func TestJMService_GetPublicSetting(t *testing.T) { - jms := setup() - setting, err := jms.GetPublicSetting() - if err != nil { - t.Fatal(err) - } - t.Logf("%+v\n", setting) -} diff --git a/pkg/jms-sdk-go/service/jms_ticket.go b/pkg/jms-sdk-go/service/jms_ticket.go deleted file mode 100644 index a1850933f..000000000 --- a/pkg/jms-sdk-go/service/jms_ticket.go +++ /dev/null @@ -1,60 +0,0 @@ -package service - -import ( - "fmt" - "net/http" - "strings" - - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) SubmitCommandConfirm(sid string, ruleId string, cmd string) (res model.CommandTicketInfo, err error) { - data := map[string]string{ - "session_id": sid, - "cmd_filter_rule_id": ruleId, - "run_command": cmd, - } - _, err = s.authClient.Post(CommandConfirmURL, data, &res) - return -} - -func (s *JMService) CheckIfNeedAssetLoginConfirm(userId, assetId, systemUserId, - sysUsername string) (res model.AssetLoginTicketInfo, err error) { - data := map[string]string{ - "user_id": userId, - "asset_id": assetId, - "system_user_id": systemUserId, - "system_user_username": sysUsername, - } - - _, err = s.authClient.Post(AssetLoginConfirmURL, data, &res) - return -} - -func (s *JMService) CheckIfNeedAppConnectionConfirm(userID, assetID, systemUserID string) (bool, error) { - - return false, nil -} - -func (s *JMService) CancelConfirmByRequestInfo(req model.ReqInfo) (err error) { - res := make(map[string]interface{}) - err = s.sendRequestByRequestInfo(req, &res) - return -} - -func (s *JMService) CheckConfirmStatusByRequestInfo(req model.ReqInfo) (res model.TicketState, err error) { - err = s.sendRequestByRequestInfo(req, &res) - return -} - -func (s *JMService) sendRequestByRequestInfo(req model.ReqInfo, res interface{}) (err error) { - switch strings.ToUpper(req.Method) { - case http.MethodGet: - _, err = s.authClient.Get(req.URL, res) - case http.MethodDelete: - _, err = s.authClient.Delete(req.URL, res) - default: - err = fmt.Errorf("unsupport method %s", req.Method) - } - return -} diff --git a/pkg/jms-sdk-go/service/jms_token.go b/pkg/jms-sdk-go/service/jms_token.go deleted file mode 100644 index 6bb1468d0..000000000 --- a/pkg/jms-sdk-go/service/jms_token.go +++ /dev/null @@ -1,13 +0,0 @@ -package service - -import ( - "fmt" - - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) GetTokenAsset(token string) (tokenUser model.TokenUser, err error) { - Url := fmt.Sprintf(TokenAssetURL, token) - _, err = s.authClient.Get(Url, &tokenUser) - return -} diff --git a/pkg/jms-sdk-go/service/jms_user.go b/pkg/jms-sdk-go/service/jms_user.go deleted file mode 100644 index 822964ab2..000000000 --- a/pkg/jms-sdk-go/service/jms_user.go +++ /dev/null @@ -1,14 +0,0 @@ -package service - -import ( - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func (s *JMService) CheckUserCookie(cookies map[string]string) (user *model.User, err error) { - client := s.authClient.Clone() - for k, v := range cookies { - client.SetCookie(k, v) - } - _, err = client.Get(UserProfileURL, &user) - return -} diff --git a/pkg/jms-sdk-go/service/options.go b/pkg/jms-sdk-go/service/options.go deleted file mode 100644 index fa2b14b0f..000000000 --- a/pkg/jms-sdk-go/service/options.go +++ /dev/null @@ -1,37 +0,0 @@ -package service - -import ( - "time" - - "github.com/jumpserver/koko/pkg/jms-sdk-go/httplib" -) - -type option struct { - // default http://127.0.0.1:8080 - CoreHost string - TimeOut time.Duration - sign httplib.AuthSign -} - -type Option func(*option) - -func JMSCoreHost(coreHost string) Option { - return func(o *option) { - o.CoreHost = coreHost - } -} - -func JMSTimeOut(t time.Duration) Option { - return func(o *option) { - o.TimeOut = t - } -} - -func JMSAccessKey(keyID, secretID string) Option { - return func(o *option) { - o.sign = &httplib.SigAuth{ - KeyID: keyID, - SecretID: secretID, - } - } -} diff --git a/pkg/jms-sdk-go/service/register.go b/pkg/jms-sdk-go/service/register.go deleted file mode 100644 index f71e606ee..000000000 --- a/pkg/jms-sdk-go/service/register.go +++ /dev/null @@ -1,63 +0,0 @@ -package service - -import ( - "errors" - "fmt" - "net/http" - "time" - - "github.com/jumpserver/koko/pkg/jms-sdk-go/httplib" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -const ComponentName = "koko" - -func RegisterTerminalAccount(coreHost, name, token string) (res model.Terminal, err error) { - client, err := httplib.NewClient(coreHost, time.Second*30) - if err != nil { - return model.Terminal{}, err - } - client.SetHeader("Authorization", fmt.Sprintf("BootstrapToken %s", token)) - data := map[string]string{"name": name, - "comment": ComponentName, - "type": ComponentName} - _, err = client.Post(TerminalRegisterURL, data, &res) - return -} - -func ValidAccessKey(coreHost string, key model.AccessKey) error { - client, err := httplib.NewClient(coreHost, time.Second*30) - if err != nil { - return err - } - sign := httplib.SigAuth{ - KeyID: key.ID, - SecretID: key.Secret, - } - client.SetAuthSign(&sign) - var ( - user model.User - res *http.Response - ) - - res, err = client.Get(UserProfileURL, &user) - if err != nil { - if res == nil { - return fmt.Errorf("%w:%s", ErrConnect, err.Error()) - } - if res.StatusCode == http.StatusUnauthorized { - return ErrUnauthorized - } - return fmt.Errorf("%w: %s", ErrInvalid, err.Error()) - } - if user.ID == "" { - return ErrInvalid - } - return nil -} - -var ( - ErrConnect = errors.New("connect failed") - ErrUnauthorized = errors.New("unauthorized") - ErrInvalid = errors.New("invalid user") -) diff --git a/pkg/jms-sdk-go/service/url.go b/pkg/jms-sdk-go/service/url.go deleted file mode 100644 index 9f5fc95fc..000000000 --- a/pkg/jms-sdk-go/service/url.go +++ /dev/null @@ -1,102 +0,0 @@ -package service - -// 与Core交互的API -const ( - UserProfileURL = "/api/v1/users/profile/" // 获取当前用户的基本信息 - TerminalRegisterURL = "/api/v1/terminal/terminal-registrations/" // 注册 - TerminalConfigURL = "/api/v1/terminal/terminals/config/" // 获取配置 - TerminalHeartBeatURL = "/api/v1/terminal/terminals/status/" -) - -// 用户登陆认证使用的API -const ( - TokenAssetURL = "/api/v1/authentication/connection-token/?token=%s" // Token name - UserTokenAuthURL = "/api/v1/authentication/tokens/" // 用户登录验证 - UserConfirmAuthURL = "/api/v1/authentication/login-confirm-ticket/status/" -) - -// Session相关API -const ( - SessionListURL = "/api/v1/terminal/sessions/" //上传创建的资产会话session id - SessionDetailURL = "/api/v1/terminal/sessions/%s/" // finish session的时候发送 - SessionReplayURL = "/api/v1/terminal/sessions/%s/replay/" //上传录像 - SessionCommandURL = "/api/v1/terminal/commands/" //上传批量命令 - FinishTaskURL = "/api/v1/terminal/tasks/%s/" - JoinRoomValidateURL = "/api/v1/terminal/sessions/join/validate/" - FTPLogListURL = "/api/v1/audits/ftp-logs/" // 上传 ftp日志 -) - -// 授权相关API -const ( - UserPermsAssetsURL = "/api/v1/perms/users/%s/assets/" - UserPermsNodesListURL = "/api/v1/perms/users/%s/nodes/" - UserPermsNodeAssetsListURL = "/api/v1/perms/users/%s/nodes/%s/assets/" - UserPermsNodeTreeWithAssetURL = "/api/v1/perms/users/%s/nodes/children-with-assets/tree/" // 资产树 - UserPermsApplicationsURL = "/api/v1/perms/users/%s/applications/?type=%s" - UserPermsAssetSystemUsersURL = "/api/v1/perms/users/%s/assets/%s/system-users/" - UserPermsApplicationSystemUsersURL = "/api/v1/perms/users/%s/applications/%s/system-users/" - ValidateUserAssetPermissionURL = "/api/v1/perms/asset-permissions/user/validate/" - ValidateApplicationPermissionURL = "/api/v1/perms/application-permissions/user/validate/" - - UserPermsDatabaseURL = "/api/v1/perms/users/%s/applications/?type__in=mysql,mariadb,sqlserver,redis,mongodb" -) - -// 系统用户密码相关API -const ( - SystemUserAuthURL = "/api/v1/assets/system-users/%s/auth-info/" - SystemUserAppAuthURL = "/api/v1/assets/system-users/%s/applications/%s/auth-info/" // 该系统用户对某应用的授权 - SystemUserAssetAuthURL = "/api/v1/assets/system-users/%s/assets/%s/auth-info/" // 该系统用户对某资产的授权 -) - -// 各资源详情相关API -const ( - UserDetailURL = "/api/v1/users/users/%s/" - AssetDetailURL = "/api/v1/assets/assets/%s/" - AssetPlatFormURL = "/api/v1/assets/assets/%s/platform/" - SystemUserDetailURL = "/api/v1/assets/system-users/%s/" - ApplicationDetailURL = "/api/v1/applications/applications/%s/" - - SystemUserCmdFilterRulesListURL = "/api/v1/assets/system-users/%s/cmd-filter-rules/" // 过滤规则url - - CommandFilterRulesListURL = "/api/v1/assets/cmd-filter-rules/" - - DomainDetailWithGateways = "/api/v1/assets/domains/%s/?gateway=1" -) - -const ( - NotificationCommandURL = "/api/v1/terminal/commands/insecure-command/" -) - -const ( - PermissionURL = "/api/v1/perms/asset-permissions/user/actions/" - - RemoteAPPURL = "/api/v1/applications/remote-apps/%s/connection-info/" -) - -const ( - AssetLoginConfirmURL = "/api/v1/acls/login-asset/check/" -) - -// 命令复核 - -const ( - CommandConfirmURL = "/api/v1/assets/cmd-filters/command-confirm/" -) - -const ( - ShareCreateURL = "/api/v1/terminal/session-sharings/" - ShareSessionJoinURL = "/api/v1/terminal/session-join-records/" - ShareSessionFinishURL = "/api/v1/terminal/session-join-records/%s/finished/" -) - -const ( - AuthMFASelectURL = "/api/v1/authentication/mfa/select/" -) - -const ( - PublicSettingURL = "/api/v1/settings/public/" -) - -const ( - TicketSessionURL = "/api/v1/tickets/ticket-session-relation/" -) diff --git a/pkg/jms-sdk-go/service/user_client.go b/pkg/jms-sdk-go/service/user_client.go deleted file mode 100644 index 3fca8639f..000000000 --- a/pkg/jms-sdk-go/service/user_client.go +++ /dev/null @@ -1,139 +0,0 @@ -package service - -import ( - "github.com/jumpserver/koko/pkg/jms-sdk-go/httplib" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" -) - -func NewUserClient(setters ...UserClientOption) *UserClient { - opts := &UserClientOptions{} - for _, setter := range setters { - setter(opts) - } - if opts.RemoteAddr != "" { - opts.client.SetHeader("X-Forwarded-For", opts.RemoteAddr) - } - if opts.LoginType != "" { - opts.client.SetHeader("X-JMS-LOGIN-TYPE", opts.LoginType) - } - return &UserClient{ - client: opts.client, - Opts: opts, - } -} - -type UserClient struct { - client *httplib.Client - Opts *UserClientOptions -} - -func (u *UserClient) SetOption(setters ...UserClientOption) { - for _, setter := range setters { - setter(u.Opts) - } -} - -func (u *UserClient) GetAPIToken() (resp AuthResponse, err error) { - data := map[string]string{ - "username": u.Opts.Username, - "password": u.Opts.Password, - "public_key": u.Opts.PublicKey, - "remote_addr": u.Opts.RemoteAddr, - "login_type": u.Opts.LoginType, - } - _, err = u.client.Post(UserTokenAuthURL, data, &resp) - return -} - -func (u *UserClient) CheckConfirmAuthStatus() (resp AuthResponse, err error) { - _, err = u.client.Get(UserConfirmAuthURL, &resp) - return -} - -func (u *UserClient) CancelConfirmAuth() (err error) { - _, err = u.client.Delete(UserConfirmAuthURL, nil) - return -} - -func (u *UserClient) SendOTPRequest(optReq *OTPRequest) (resp AuthResponse, err error) { - _, err = u.client.Post(optReq.ReqURL, optReq.ReqBody, &resp) - return -} - -func (u *UserClient) SelectMFAChoice(mfaType string) (err error) { - data := map[string]string{ - "type": mfaType, - } - _, err = u.client.Post(AuthMFASelectURL, data, nil) - return -} - -type OTPRequest struct { - ReqURL string - ReqBody map[string]interface{} -} - -type DataResponse struct { - Choices []string `json:"choices,omitempty"` - Url string `json:"url,omitempty"` -} - -type AuthResponse struct { - Err string `json:"error,omitempty"` - Msg string `json:"msg,omitempty"` - Data DataResponse `json:"data,omitempty"` - - Username string `json:"username,omitempty"` - Token string `json:"token,omitempty"` - Keyword string `json:"keyword,omitempty"` - DateExpired string `json:"date_expired,omitempty"` - - User model.User `json:"user,omitempty"` -} - -type UserClientOption func(*UserClientOptions) - -func UserClientUsername(username string) UserClientOption { - return func(args *UserClientOptions) { - args.Username = username - } -} - -func UserClientPassword(password string) UserClientOption { - return func(args *UserClientOptions) { - args.Password = password - } -} - -func UserClientPublicKey(publicKey string) UserClientOption { - return func(args *UserClientOptions) { - args.PublicKey = publicKey - } -} - -func UserClientRemoteAddr(remoteAddr string) UserClientOption { - return func(args *UserClientOptions) { - args.RemoteAddr = remoteAddr - } -} - -func UserClientLoginType(loginType string) UserClientOption { - return func(args *UserClientOptions) { - args.LoginType = loginType - } -} - -func UserClientHttpClient(con *httplib.Client) UserClientOption { - return func(args *UserClientOptions) { - args.client = con - } -} - -type UserClientOptions struct { - Username string - Password string - PublicKey string - RemoteAddr string - LoginType string - client *httplib.Client -} diff --git a/pkg/koko/koko.go b/pkg/koko/koko.go index a193123ea..40a486438 100644 --- a/pkg/koko/koko.go +++ b/pkg/koko/koko.go @@ -2,7 +2,6 @@ package koko import ( "errors" - "fmt" "os" "os/signal" "syscall" @@ -15,27 +14,16 @@ import ( "github.com/jumpserver/koko/pkg/logger" "github.com/jumpserver/koko/pkg/sshd" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" ) -var Version = "unknown" - type Koko struct { webSrv *httpd.Server sshSrv *sshd.Server } -const ( - timeFormat = "2006-01-02 15:04:05" - startWelcomeMsg = `%s -KoKo Version %s, more see https://www.jumpserver.org -Quit the server with CONTROL-C. -` -) - func (k *Koko) Start() { - fmt.Printf(startWelcomeMsg, time.Now().Format(timeFormat), Version) go k.webSrv.Start() go k.sshSrv.Start() } @@ -49,13 +37,12 @@ func (k *Koko) Stop() { func RunForever(confPath string) { config.Setup(confPath) bootstrap() + jmsService := MustJMService() gracefulStop := make(chan os.Signal, 1) signal.Notify(gracefulStop, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT) - jmsService := MustJMService() - srv := NewServer(jmsService) + bootstrapWithJMService(jmsService) webSrv := httpd.NewServer(jmsService) - registerWebHandlers(jmsService, webSrv) - sshSrv := sshd.NewSSHServer(srv) + sshSrv := sshd.NewSSHServer(jmsService) app := &Koko{ webSrv: webSrv, sshSrv: sshSrv, @@ -69,34 +56,48 @@ func RunForever(confPath string) { func bootstrap() { i18n.Initial() logger.Initial() +} + +func bootstrapWithJMService(jmsService *service.JMService) { + updateEncryptConfigValue(jmsService) exchange.Initial() } +func updateEncryptConfigValue(jmsService *service.JMService) { + cfg := config.GlobalConfig + encryptKey := cfg.SecretEncryptKey + if encryptKey != "" { + redisPassword := cfg.RedisPassword + ret, err := jmsService.GetEncryptedConfigValue(encryptKey, redisPassword) + if err != nil { + logger.Error("Get encrypted config value failed: " + err.Error()) + return + } + if ret.Value != "" { + cfg.UpdateRedisPassword(ret.Value) + } else { + logger.Error("Get encrypted config value failed: empty value") + } + } +} + func runTasks(jmsService *service.JMService) { if config.GetConf().UploadFailedReplay { go uploadRemainReplay(jmsService) } + if config.GetConf().UploadFailedFTPFile { + go uploadRemainFTPFile(jmsService) + } go keepHeartbeat(jmsService) -} -func NewServer(jmsService *service.JMService) *server { - terminalConf, err := jmsService.GetTerminalConfig() - if err != nil { - logger.Fatal(err) - } - app := server{ - jmsService: jmsService, - vscodeClients: make(map[string]*vscodeReq), - } - app.UpdateTerminalConfig(terminalConf) - go app.run() - return &app + go RunConnectTokensCheck(jmsService) } func MustJMService() *service.JMService { key := MustLoadValidAccessKey() - jmsService, err := service.NewAuthJMService(service.JMSCoreHost( - config.GlobalConfig.CoreHost), service.JMSTimeOut(30*time.Second), + jmsService, err := service.NewAuthJMService( + service.JMSCoreHost(config.GlobalConfig.CoreHost), + service.JMSTimeOut(time.Duration(config.GlobalConfig.HttpRequestTimeout)*time.Second), service.JMSAccessKey(key.ID, key.Secret), ) if err != nil { @@ -119,7 +120,7 @@ func MustLoadValidAccessKey() model.AccessKey { func MustRegisterTerminalAccount() (key model.AccessKey) { conf := config.GlobalConfig for i := 0; i < 10; i++ { - terminal, err := service.RegisterTerminalAccount(conf.CoreHost, + terminal, err := service.RegisterTerminalAccount(conf.CoreHost, string(model.Koko), conf.Name, conf.BootstrapToken) if err != nil { logger.Error(err.Error()) diff --git a/pkg/koko/server.go b/pkg/koko/server.go deleted file mode 100644 index aa00db604..000000000 --- a/pkg/koko/server.go +++ /dev/null @@ -1,65 +0,0 @@ -package koko - -import ( - "sync" - "sync/atomic" - "time" - - "github.com/jumpserver/koko/pkg/logger" - "github.com/jumpserver/koko/pkg/srvconn" - - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" -) - -type server struct { - terminalConf atomic.Value - jmsService *service.JMService - sync.Mutex - - vscodeClients map[string]*vscodeReq -} - -func (s *server) run() { - for { - time.Sleep(time.Minute) - conf, err := s.jmsService.GetTerminalConfig() - if err != nil { - logger.Errorf("Update terminal config failed: %s", err) - continue - } - s.UpdateTerminalConfig(conf) - } -} - -func (s *server) UpdateTerminalConfig(conf model.TerminalConfig) { - s.terminalConf.Store(conf) -} - -func (s *server) GetTerminalConfig() model.TerminalConfig { - return s.terminalConf.Load().(model.TerminalConfig) -} - -func (s *server) getVSCodeReq(reqId string) *vscodeReq { - s.Lock() - defer s.Unlock() - return s.vscodeClients[reqId] -} - -func (s *server) addVSCodeReq(vsReq *vscodeReq) { - s.Lock() - defer s.Unlock() - s.vscodeClients[vsReq.reqId] = vsReq -} - -func (s *server) deleteVSCodeReq(vsReq *vscodeReq) { - s.Lock() - defer s.Unlock() - delete(s.vscodeClients, vsReq.reqId) -} - -type vscodeReq struct { - reqId string - user *model.User - client *srvconn.SSHClient -} diff --git a/pkg/koko/server_ssh.go b/pkg/koko/server_ssh.go deleted file mode 100644 index a31e46591..000000000 --- a/pkg/koko/server_ssh.go +++ /dev/null @@ -1,389 +0,0 @@ -package koko - -import ( - "fmt" - "io" - "net" - "strconv" - "time" - - "github.com/gliderlabs/ssh" - "github.com/pkg/sftp" - gossh "golang.org/x/crypto/ssh" - - "github.com/jumpserver/koko/pkg/auth" - "github.com/jumpserver/koko/pkg/common" - "github.com/jumpserver/koko/pkg/config" - "github.com/jumpserver/koko/pkg/handler" - "github.com/jumpserver/koko/pkg/i18n" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/logger" - "github.com/jumpserver/koko/pkg/srvconn" - "github.com/jumpserver/koko/pkg/sshd" - "github.com/jumpserver/koko/pkg/utils" -) - -const ( - nextAuthMethod = "keyboard-interactive" -) - -func (s *server) GetSSHAddr() string { - cf := config.GlobalConfig - return net.JoinHostPort(cf.BindHost, cf.SSHPort) -} -func (s *server) GetSSHSigner() ssh.Signer { - conf := s.GetTerminalConfig() - singer, err := sshd.ParsePrivateKeyFromString(conf.HostKey) - if err != nil { - logger.Fatal(err) - } - return singer -} - -func (s *server) KeyboardInteractiveAuth(ctx ssh.Context, - challenger gossh.KeyboardInteractiveChallenge) sshd.AuthStatus { - return auth.SSHKeyboardInteractiveAuth(ctx, challenger) -} - -const ctxID = "ctxID" - -func (s *server) PasswordAuth(ctx ssh.Context, password string) sshd.AuthStatus { - ctx.SetValue(ctxID, ctx.SessionID()) - tConfig := s.GetTerminalConfig() - if !tConfig.PasswordAuth { - logger.Info("Core API disable password auth auth") - return sshd.AuthFailed - } - sshAuthHandler := auth.SSHPasswordAndPublicKeyAuth(s.jmsService) - return sshAuthHandler(ctx, password, "") -} - -func (s *server) PublicKeyAuth(ctx ssh.Context, key ssh.PublicKey) sshd.AuthStatus { - ctx.SetValue(ctxID, ctx.SessionID()) - tConfig := s.GetTerminalConfig() - if !tConfig.PublicKeyAuth { - logger.Info("Core API disable publickey auth") - return sshd.AuthFailed - } - publicKey := common.Base64Encode(string(key.Marshal())) - sshAuthHandler := auth.SSHPasswordAndPublicKeyAuth(s.jmsService) - return sshAuthHandler(ctx, "", publicKey) -} - -func (s *server) NextAuthMethodsHandler(ctx ssh.Context) []string { - return []string{nextAuthMethod} -} - -func (s *server) SFTPHandler(sess ssh.Session) { - currentUser, ok := sess.Context().Value(auth.ContextKeyUser).(*model.User) - if !ok || currentUser.ID == "" { - logger.Errorf("SFTP User not found, exit.") - return - } - host, _, _ := net.SplitHostPort(sess.RemoteAddr().String()) - userSftp := handler.NewSFTPHandler(s.jmsService, currentUser, host) - handlers := sftp.Handlers{ - FileGet: userSftp, - FilePut: userSftp, - FileCmd: userSftp, - FileList: userSftp, - } - reqID := common.UUID() - logger.Infof("SFTP request %s: Handler start", reqID) - req := sftp.NewRequestServer(sess, handlers) - if err := req.Serve(); err == io.EOF { - logger.Debugf("SFTP request %s: Exited session.", reqID) - } else if err != nil { - logger.Errorf("SFTP request %s: Server completed with error %s", reqID, err) - } - _ = req.Close() - userSftp.Close() - logger.Infof("SFTP request %s: Handler exit.", reqID) -} - -func (s *server) LocalPortForwardingPermission(ctx ssh.Context, destinationHost string, destinationPort uint32) bool { - return config.GlobalConfig.EnableLocalPortForward -} -func (s *server) DirectTCPIPChannelHandler(ctx ssh.Context, newChan gossh.NewChannel, destAddr string) { - if !config.GetConf().EnableVscodeSupport { - _ = newChan.Reject(gossh.Prohibited, "port forwarding is disabled") - return - } - reqId, ok := ctx.Value(ctxID).(string) - if !ok { - _ = newChan.Reject(gossh.Prohibited, "port forwarding is disabled") - return - } - vsReq := s.getVSCodeReq(reqId) - if vsReq == nil { - _ = newChan.Reject(gossh.Prohibited, "port forwarding is disabled") - return - } - dConn, err := vsReq.client.Dial("tcp", destAddr) - if err != nil { - _ = newChan.Reject(gossh.ConnectionFailed, err.Error()) - return - } - defer dConn.Close() - ch, reqs, err := newChan.Accept() - if err != nil { - _ = dConn.Close() - _ = newChan.Reject(gossh.ConnectionFailed, err.Error()) - return - } - logger.Infof("User %s start port forwarding from (%s) to (%s)", vsReq.user, - vsReq.client, destAddr) - defer ch.Close() - go gossh.DiscardRequests(reqs) - go func() { - defer ch.Close() - defer dConn.Close() - _, _ = io.Copy(ch, dConn) - }() - _, _ = io.Copy(dConn, ch) - logger.Infof("User %s end port forwarding from (%s) to (%s)", vsReq.user, - vsReq.client, destAddr) -} - -func (s *server) SessionHandler(sess ssh.Session) { - user, ok := sess.Context().Value(auth.ContextKeyUser).(*model.User) - if !ok || user.ID == "" { - logger.Errorf("SSH User %s not found, exit.", sess.User()) - utils.IgnoreErrWriteString(sess, "Not auth user.\n") - return - } - termConf := s.GetTerminalConfig() - directReq := sess.Context().Value(auth.ContextKeyDirectLoginFormat) - if pty, winChan, isPty := sess.Pty(); isPty { - if directRequest, ok3 := directReq.(*auth.DirectLoginAssetReq); ok3 { - opts := make([]handler.DirectOpt, 0, 5) - opts = append(opts, handler.DirectTargetAsset(directRequest.AssetInfo)) - opts = append(opts, handler.DirectUser(user)) - opts = append(opts, handler.DirectTerminalConf(&termConf)) - opts = append(opts, handler.DirectTargetSystemUser(directRequest.SysUserInfo)) - if directRequest.IsUUIDString() { - opts = append(opts, handler.DirectFormatType(handler.FormatUUID)) - } - directSrv, err := handler.NewDirectHandler(sess, s.jmsService, opts...) - if err != nil { - logger.Errorf("User %s direct request err: %s", user.Name, err) - return - } - directSrv.Dispatch() - return - } - - interactiveSrv := handler.NewInteractiveHandler(sess, user, s.jmsService, termConf) - logger.Infof("User %s request pty %s", sess.User(), pty.Term) - go interactiveSrv.WatchWinSizeChange(winChan) - interactiveSrv.Dispatch() - utils.IgnoreErrWriteWindowTitle(sess, termConf.HeaderTitle) - return - } - if !config.GetConf().EnableVscodeSupport { - utils.IgnoreErrWriteString(sess, "No PTY requested.\n") - return - } - if directRequest, ok3 := directReq.(*auth.DirectLoginAssetReq); ok3 { - selectedAssets, err := s.getMatchedAssetsByDirectReq(user, directRequest) - if err != nil { - logger.Error(err) - utils.IgnoreErrWriteString(sess, err.Error()) - return - } - if len(selectedAssets) != 1 { - msg := fmt.Sprintf(i18n.T("Must be unique asset for %s"), directRequest.AssetInfo) - utils.IgnoreErrWriteString(sess, msg) - logger.Error(msg) - return - } - selectSysUsers, err := s.getMatchedSystemUsers(user, directRequest, selectedAssets[0]) - if err != nil { - logger.Error(err) - utils.IgnoreErrWriteString(sess, err.Error()) - return - } - if len(selectSysUsers) != 1 { - msg := fmt.Sprintf(i18n.T("Must be unique system user for %s"), directRequest.SysUserInfo) - utils.IgnoreErrWriteString(sess, msg) - logger.Error(msg) - return - } - s.proxyVscode(sess, user, selectedAssets[0], selectSysUsers[0]) - } - -} - -func (s *server) proxyVscode(sess ssh.Session, user *model.User, asset model.Asset, - systemUser model.SystemUser) { - ctxId, ok := sess.Context().Value(ctxID).(string) - if !ok { - logger.Error("Not found ctxID") - return - } - systemUserAuthInfo, err := s.jmsService.GetSystemUserAuthById(systemUser.ID, asset.ID, - user.ID, user.Username) - if err != nil { - logger.Errorf("Get system user auth failed: %s", err) - return - } - permInfo, err := s.jmsService.ValidateAssetConnectPermission(user.ID, - asset.ID, systemUser.ID) - if err != nil { - logger.Errorf("Get asset Permission info err: %s", err) - return - } - var domainGateways *model.Domain - if asset.Domain != "" { - domainInfo, err := s.jmsService.GetDomainGateways(asset.Domain) - if err != nil { - logger.Errorf("Get system user auth failed: %s", err) - return - } - domainGateways = &domainInfo - } - timeout := config.GlobalConfig.SSHTimeout - sshAuthOpts := make([]srvconn.SSHClientOption, 0, 7) - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientUsername(systemUserAuthInfo.Username)) - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientHost(asset.IP)) - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPort(asset.ProtocolPort(systemUserAuthInfo.Protocol))) - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPassword(systemUserAuthInfo.Password)) - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientTimeout(timeout)) - if systemUserAuthInfo.PrivateKey != "" { - // 先使用 password 解析 PrivateKey - if signer, err1 := gossh.ParsePrivateKeyWithPassphrase([]byte(systemUserAuthInfo.PrivateKey), - []byte(systemUserAuthInfo.Password)); err1 == nil { - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPrivateAuth(signer)) - } else { - // 如果之前使用password解析失败,则去掉 password, 尝试直接解析 PrivateKey 防止错误的passphrase - if signer, err1 = gossh.ParsePrivateKey([]byte(systemUserAuthInfo.PrivateKey)); err1 == nil { - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPrivateAuth(signer)) - } - } - } - - if domainGateways != nil && len(domainGateways.Gateways) > 0 { - proxyArgs := make([]srvconn.SSHClientOptions, 0, len(domainGateways.Gateways)) - for i := range domainGateways.Gateways { - gateway := domainGateways.Gateways[i] - proxyArg := srvconn.SSHClientOptions{ - Host: gateway.IP, - Port: strconv.Itoa(gateway.Port), - Username: gateway.Username, - Password: gateway.Password, - Passphrase: gateway.Password, // 兼容 带密码的private_key, - PrivateKey: gateway.PrivateKey, - Timeout: timeout, - } - proxyArgs = append(proxyArgs, proxyArg) - } - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientProxyClient(proxyArgs...)) - } - sshClient, err := srvconn.NewSSHClient(sshAuthOpts...) - if err != nil { - logger.Errorf("Get SSH Client failed: %s", err) - return - } - defer sshClient.Close() - goSess, err := sshClient.AcquireSession() - if err != nil { - logger.Errorf("Get SSH session failed: %s", err) - return - } - defer goSess.Close() - defer sshClient.ReleaseSession(goSess) - stdOut, err := goSess.StdoutPipe() - if err != nil { - logger.Errorf("Get SSH session StdoutPipe failed: %s", err) - return - } - stdin, err := goSess.StdinPipe() - if err != nil { - logger.Errorf("Get SSH session StdinPipe failed: %s", err) - return - } - err = goSess.Shell() - if err != nil { - logger.Errorf("Get SSH session shell failed: %s", err) - return - } - logger.Infof("User %s start vscode request to %s", user, sshClient) - vsReq := &vscodeReq{ - reqId: ctxId, - user: user, - client: sshClient, - } - s.addVSCodeReq(vsReq) - defer s.deleteVSCodeReq(vsReq) - go func() { - _, _ = io.Copy(stdin, sess) - logger.Infof("User %s vscode request %s stdin end", user, sshClient) - }() - go func() { - _, _ = io.Copy(sess, stdOut) - logger.Infof("User %s vscode request %s stdOut end", user, sshClient) - }() - ticker := time.NewTicker(time.Minute) - defer ticker.Stop() - for { - select { - case <-sess.Context().Done(): - logger.Infof("SSH conn[%s] User %s end vscode request %s as session done", ctxId, user, sshClient) - return - case now := <-ticker.C: - if permInfo.IsExpired(now) { - logger.Infof("SSH conn[%s] User %s end vscode request %s as permission has expired", - ctxId, user, sshClient) - return - } - logger.Debugf("SSH conn[%s] user %s vscode request still alive", ctxId, user) - } - } -} - -func (s *server) getMatchedAssetsByDirectReq(user *model.User, req *auth.DirectLoginAssetReq) ([]model.Asset, error) { - if req.IsUUIDString() { - asset, err := s.jmsService.GetAssetById(req.AssetInfo) - if err != nil { - logger.Errorf("Get asset failed: %s", err) - return nil, fmt.Errorf("match asset failed: %s", i18n.T("Core API failed")) - } - return []model.Asset{asset}, nil - } - assets, err := s.jmsService.GetUserPermAssetsByIP(user.ID, req.AssetInfo) - if err != nil { - logger.Errorf("Get asset failed: %s", err) - return nil, fmt.Errorf("match asset failed: %s", i18n.T("Core API failed")) - } - return assets, nil -} - -func (s *server) getMatchedSystemUsers(user *model.User, req *auth.DirectLoginAssetReq, - asset model.Asset) ([]model.SystemUser, error) { - if req.IsUUIDString() { - systemUser, err := s.jmsService.GetSystemUserById(req.SysUserInfo) - if err != nil { - logger.Errorf("Get systemUser failed: %s", err) - return nil, fmt.Errorf("match systemuser failed: %s", i18n.T("Core API failed")) - } - return []model.SystemUser{systemUser}, nil - } - systemUsers, err := s.jmsService.GetSystemUsersByUserIdAndAssetId(user.ID, asset.ID) - if err != nil { - logger.Errorf("Get systemUser failed: %s", err) - return nil, fmt.Errorf("match systemuser failed: %s", i18n.T("Core API failed")) - } - matched := make([]model.SystemUser, 0, len(systemUsers)) - for i := range systemUsers { - compareUsername := systemUsers[i].Username - - if systemUsers[i].UsernameSameWithUser { - // 此为动态系统用户,系统用户名和登录用户名相同 - compareUsername = user.Username - } - if compareUsername == req.SysUserInfo { - matched = append(matched, systemUsers[i]) - } - } - return matched, nil -} diff --git a/pkg/koko/task.go b/pkg/koko/task.go index 46d69bb49..92ff976ee 100644 --- a/pkg/koko/task.go +++ b/pkg/koko/task.go @@ -1,17 +1,22 @@ package koko import ( + "encoding/json" "os" "path/filepath" "strings" "time" + "github.com/gorilla/websocket" + + "github.com/jumpserver-dev/sdk-go/common" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" + "github.com/jumpserver/koko/pkg/config" - "github.com/jumpserver/koko/pkg/jms-sdk-go/common" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" "github.com/jumpserver/koko/pkg/logger" "github.com/jumpserver/koko/pkg/proxy" + "github.com/jumpserver/koko/pkg/session" ) // uploadRemainReplay 上传遗留的录像 @@ -30,7 +35,7 @@ func uploadRemainReplay(jmsService *service.JMService) { } if replayInfo, ok := parseReplayFilename(info.Name()); ok { finishedTime := common.NewUTCTime(info.ModTime()) - if err2 := jmsService.SessionFinished(replayInfo.Id, finishedTime); err2 != nil { + if _, err2 := jmsService.SessionFinished(replayInfo.Id, finishedTime); err2 != nil { logger.Error(err2) return nil } @@ -39,6 +44,20 @@ func uploadRemainReplay(jmsService *service.JMService) { return nil }) + recordLifecycleLog := func(id string, event model.LifecycleEvent, reason string) { + logObj := model.SessionLifecycleLog{Reason: reason} + if err1 := jmsService.RecordSessionLifecycleLog(id, event, logObj); err1 != nil { + logger.Errorf("Update session %s activity log failed: %s", id, err1) + } + } + if len(allRemainFiles) == 0 { + logger.Info("No remain replay file to upload") + return + } + + logger.Infof("Start upload remain %d replay files 10 min later ", len(allRemainFiles)) + time.Sleep(10 * time.Minute) + for absPath, remainReplay := range allRemainFiles { absGzPath := absPath if !remainReplay.IsGzip { @@ -60,14 +79,29 @@ func uploadRemainReplay(jmsService *service.JMService) { } _ = os.Remove(absPath) } - Target, _ := filepath.Rel(replayDir, absGzPath) + absFileInfo, err := os.Stat(absGzPath) + if err != nil { + logger.Errorf("Session %s: Replay file %s stat error: %s", remainReplay.Id, absGzPath, err) + continue + } + target, _ := filepath.Rel(replayDir, absGzPath) + + recordLifecycleLog(remainReplay.Id, model.ReplayUploadStart, "") logger.Infof("Upload replay file: %s, type: %s", absGzPath, replayStorage.TypeName()) - if err2 := replayStorage.Upload(absGzPath, Target); err2 != nil { + if err2 := replayStorage.Upload(absGzPath, target); err2 != nil { logger.Errorf("Upload remain replay file %s failed: %s", absGzPath, err2) + reason := model.SessionReplayErrUploadFailed + if _, err3 := jmsService.SessionReplayFailed(remainReplay.Id, reason); err3 != nil { + logger.Errorf("Update session %s status %s failed: %s", remainReplay.Id, reason, err3) + } + failureMsg := strings.ReplaceAll(err2.Error(), ",", " ") + recordLifecycleLog(remainReplay.Id, model.ReplayUploadFailure, failureMsg) continue } - if err := jmsService.FinishReply(remainReplay.Id); err != nil { - logger.Errorf("Notify session %s upload failed: %s", remainReplay.Id, err) + replaySize := absFileInfo.Size() + recordLifecycleLog(remainReplay.Id, model.ReplayUploadSuccess, "") + if _, err1 := jmsService.FinishReplyWithSize(remainReplay.Id, replaySize); err1 != nil { + logger.Errorf("Notify session %s upload failed: %s", remainReplay.Id, err1) continue } _ = os.Remove(absGzPath) @@ -76,37 +110,151 @@ func uploadRemainReplay(jmsService *service.JMService) { logger.Info("Upload remain replay done") } +// uploadRemainFTPFile 上传遗留的上传下载文件 +func uploadRemainFTPFile(jmsService *service.JMService) { + ftpFileDir := config.GetConf().FTPFileFolderPath + conf, err := jmsService.GetTerminalConfig() + if err != nil { + logger.Error(err) + return + } + ftpFileStorage := proxy.NewFTPFileStorage(jmsService, &conf) + allRemainFiles := make(map[string]RemainFTPFile) + _ = filepath.Walk(ftpFileDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if ftpFileInfo, ok := parseFTPFilename(info.Name()); ok { + allRemainFiles[path] = ftpFileInfo + } + return nil + }) + if len(allRemainFiles) == 0 { + logger.Info("No remain ftp file to upload") + return + } + logger.Infof("Start upload remain %d ftp files 10 min later ", len(allRemainFiles)) + time.Sleep(10 * time.Minute) + + for absPath, remainFTPFile := range allRemainFiles { + absGzPath := absPath + dateTarget, _ := filepath.Rel(ftpFileDir, absGzPath) + targetName := strings.Join([]string{proxy.FtpTargetPrefix, dateTarget}, "/") + logger.Infof("Upload FTP file: %s, target: %s, type: %s", absGzPath, + targetName, ftpFileStorage.TypeName()) + if err = ftpFileStorage.Upload(absGzPath, targetName); err != nil { + logger.Errorf("Upload remain FTP file %s failed: %s", absGzPath, err) + continue + } + if err := jmsService.FinishFTPFile(remainFTPFile.Id); err != nil { + logger.Errorf("Notify FTP file %s upload failed: %s", remainFTPFile.Id, err) + continue + } + _ = os.Remove(absGzPath) + logger.Infof("Upload remain FTP file %s success", absGzPath) + } + logger.Info("Upload remain FTP file done") +} + // keepHeartbeat 保持心跳 func keepHeartbeat(jmsService *service.JMService) { - for { - time.Sleep(30 * time.Second) - data := proxy.GetAliveSessions() - tasks, err := jmsService.TerminalHeartBeat(data) - if err != nil { - logger.Error(err) + KeepWsHeartbeat(jmsService) +} + +func handleTerminalTask(jmsService *service.JMService, tasks []model.TerminalTask) { + for _, task := range tasks { + sess, ok := session.GetSessionById(task.Args) + if !ok { + logger.Infof("Task %s session %s not found", task.ID, task.Args) + continue + } + logger.Infof("Handle task %s for session %s", task.Name, task.Args) + if err := sess.HandleTask(&task); err != nil { + logger.Errorf("Handle task %s failed: %s", task.Name, err) + continue + } + if err := jmsService.FinishTask(task.ID); err != nil { + logger.Errorf("Finish task %s failed: %s", task.ID, err) continue } - if len(tasks) != 0 { - for _, task := range tasks { - switch task.Name { - case TaskKillSession: - if sw, ok := proxy.GetSessionById(task.Args); ok { - sw.Terminate(task.Kwargs.TerminatedBy) - if err = jmsService.FinishTask(task.ID); err != nil { - logger.Error(err) - } - } - default: + logger.Infof("Handle task %s for session %s success", task.Name, task.Args) - } + } +} + +func KeepWsHeartbeat(jmsService *service.JMService) { + ws, err := jmsService.GetWsClient() + if err != nil { + logger.Errorf("Start ws client failed: %s", err) + time.Sleep(10 * time.Second) + go KeepWsHeartbeat(jmsService) + return + } + logger.Info("Start ws client success") + done := make(chan struct{}, 2) + go func() { + defer close(done) + for { + msgType, message, err2 := ws.ReadMessage() + if err2 != nil { + logger.Errorf("Ws client read err: %s", err2) + return + } + switch msgType { + case websocket.PingMessage, + websocket.PongMessage: + logger.Debug("Ws client ping/pong Message") + continue + case websocket.CloseMessage: + logger.Debug("Ws client close Message") + return + } + var tasks []model.TerminalTask + if err = json.Unmarshal(message, &tasks); err != nil { + logger.Errorf("Ws client Unmarshal failed: %s", err) + continue } + if len(tasks) != 0 { + handleTerminalTask(jmsService, tasks) + } + } + }() + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + if err1 := ws.WriteJSON(GetStatusData()); err1 != nil { + logger.Errorf("Ws client send heartbeat data failed: %s", err1) + } + for { + select { + case <-done: + logger.Info("Ws client closed") + time.Sleep(10 * time.Second) + go KeepWsHeartbeat(jmsService) + return + case <-ticker.C: + if err1 := ws.WriteJSON(GetStatusData()); err1 != nil { + logger.Errorf("Ws client write stat data failed: %s", err1) + continue + } + logger.Debug("Ws client send heartbeat success") } } } -const ( - TaskKillSession = "kill_session" -) +func GetStatusData() interface{} { + ids := session.GetAliveSessionIds() + payload := model.HeartbeatData{ + SessionOnlineIds: ids, + CpuUsed: common.CpuLoad1Usage(), + MemoryUsed: common.MemoryUsagePercent(), + DiskUsed: common.DiskUsagePercent(), + SessionOnline: len(ids), + } + return map[string]interface{}{ + "type": "status", + "payload": payload, + } +} func ValidateRemainReplayFile(path string) error { f, err := os.OpenFile(path, os.O_RDWR|os.O_APPEND, os.ModePerm) @@ -140,6 +288,10 @@ type RemainReplay struct { Version model.ReplayVersion } +type RemainFTPFile struct { + Id string // FTP log id +} + func parseReplayFilename(filename string) (replay RemainReplay, ok bool) { // 未压缩的旧录像文件名格式是一个 UUID if len(filename) == 36 { @@ -154,6 +306,15 @@ func parseReplayFilename(filename string) (replay RemainReplay, ok bool) { return } +func parseFTPFilename(filename string) (ftpFile RemainFTPFile, ok bool) { + if len(filename) == 36 { + ftpFile.Id = filename + ok = true + return + } + return +} + func isGzipFile(filename string) bool { return strings.HasSuffix(filename, model.SuffixGz) } diff --git a/pkg/koko/token_check.go b/pkg/koko/token_check.go new file mode 100644 index 000000000..897fe50a9 --- /dev/null +++ b/pkg/koko/token_check.go @@ -0,0 +1,56 @@ +package koko + +import ( + "time" + + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" + + "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/session" +) + +// RunConnectTokensCheck every 5 minutes check token status +func RunConnectTokensCheck(jmsService *service.JMService) { + apiClient := jmsService.Copy() + for { + time.Sleep(5 * time.Minute) + sessions := session.GetSessions() + tokens := make(map[string]model.TokenCheckStatus, len(sessions)) + for _, s := range sessions { + ret, ok := tokens[s.TokenId] + if ok { + handleTokenCheck(s, &ret) + continue + } + apiClient.SetCookie("django_language", s.LangCode) + ret, err := apiClient.CheckTokenStatus(s.TokenId) + if err != nil && ret.Code == "" { + logger.Errorf("Check token status failed: %s", err) + continue + } + tokens[s.TokenId] = ret + handleTokenCheck(s, &ret) + } + } +} + +func handleTokenCheck(session *session.Session, tokenStatus *model.TokenCheckStatus) { + var task model.TerminalTask + switch tokenStatus.Code { + case model.CodePermOk: + task = model.TerminalTask{ + Name: model.TaskPermValid, + Args: tokenStatus.Detail, + } + default: + task = model.TerminalTask{ + Name: model.TaskPermExpired, + Args: tokenStatus.Detail, + } + } + if err := session.HandleTask(&task); err != nil { + logger.Errorf("Handle token check task failed: %s", err) + } + +} diff --git a/pkg/koko/web_router.go b/pkg/koko/web_router.go deleted file mode 100644 index 7cf5388ca..000000000 --- a/pkg/koko/web_router.go +++ /dev/null @@ -1,120 +0,0 @@ -package koko - -import ( - "log" - "net" - "net/http" - "net/http/pprof" - - "github.com/gin-gonic/gin" - "github.com/jumpserver/koko/pkg/auth" - "github.com/jumpserver/koko/pkg/common" - "github.com/jumpserver/koko/pkg/config" - "github.com/jumpserver/koko/pkg/httpd" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" -) - -func registerWebHandlers(jmsService *service.JMService, webSrv *httpd.Server) { - if config.GlobalConfig.LogLevel != "DEBUG" { - gin.SetMode(gin.ReleaseMode) - } - eng := gin.New() - trustedProxies := []string{"0.0.0.0/0", "::/0"} - if err := eng.SetTrustedProxies(trustedProxies); err != nil { - log.Fatal(err) - } - eng.Use(gin.Recovery()) - eng.Use(gin.Logger()) - rootGroup := eng.Group("") - kokoGroup := rootGroup.Group("/koko") - kokoGroup.Static("/static/", "./static") - kokoGroup.Static("/assets", "./ui/dist/assets") - kokoGroup.StaticFile("/favicon.ico", "./ui/dist/favicon.ico") - kokoGroup.GET("/health/", webSrv.HealthStatusHandler) - eng.LoadHTMLFiles("./templates/elfinder/file_manager.html") - wsGroup := kokoGroup.Group("/ws/") - { - wsGroup.Group("/terminal").Use( - auth.HTTPMiddleSessionAuth(jmsService)).GET("/", webSrv.ProcessTerminalWebsocket) - - wsGroup.Group("/elfinder").Use( - auth.HTTPMiddleSessionAuth(jmsService)).GET("/", webSrv.ProcessElfinderWebsocket) - - wsGroup.Group("/token").GET("/", webSrv.ProcessTokenWebsocket) - } - - terminalGroup := kokoGroup.Group("/terminal") - terminalGroup.Use(auth.HTTPMiddleSessionAuth(jmsService)) - { - terminalGroup.GET("/", func(ctx *gin.Context) { - ctx.File("./ui/dist/index.html") - }) - } - shareGroup := kokoGroup.Group("/share") - shareGroup.Use(auth.HTTPMiddleSessionAuth(jmsService)) - { - shareGroup.GET("/:id/", func(ctx *gin.Context) { - ctx.File("./ui/dist/index.html") - }) - } - - monitorGroup := kokoGroup.Group("/monitor") - monitorGroup.Use(auth.HTTPMiddleSessionAuth(jmsService)) - { - monitorGroup.GET("/:id/", func(ctx *gin.Context) { - ctx.File("./ui/dist/index.html") - }) - } - - tokenGroup := kokoGroup.Group("/token") - { - tokenGroup.GET("/", func(ctx *gin.Context) { - ctx.File("./ui/dist/index.html") - }) - - tokenGroup.GET("/:id/", func(ctx *gin.Context) { - ctx.File("./ui/dist/index.html") - }) - } - elfindlerGroup := kokoGroup.Group("/elfinder") - elfindlerGroup.Use(auth.HTTPMiddleSessionAuth(jmsService)) - { - elfindlerGroup.GET("/sftp/", func(ctx *gin.Context) { - metaData := webSrv.GenerateViewMeta("_") - ctx.HTML(http.StatusOK, "file_manager.html", metaData) - }) - elfindlerGroup.GET("/sftp/:host/", func(ctx *gin.Context) { - hostId := ctx.Param("host") - if ok := common.ValidUUIDString(hostId); !ok { - ctx.AbortWithStatus(http.StatusBadRequest) - return - } - metaData := webSrv.GenerateViewMeta(hostId) - ctx.HTML(http.StatusOK, "file_manager.html", metaData) - }) - elfindlerGroup.Any("/connector/:host/", webSrv.SftpHostConnectorView) - } - - debugGroup := rootGroup.Group("/debug/pprof") - debugGroup.Use(auth.HTTPMiddleDebugAuth()) - { - debugGroup.GET("/", gin.WrapF(pprof.Index)) - debugGroup.GET("/cmdline", gin.WrapF(pprof.Cmdline)) - debugGroup.GET("/profile", gin.WrapF(pprof.Profile)) - debugGroup.POST("/symbol", gin.WrapF(pprof.Symbol)) - debugGroup.GET("/symbol", gin.WrapF(pprof.Symbol)) - debugGroup.GET("/trace", gin.WrapF(pprof.Trace)) - debugGroup.GET("/allocs", gin.WrapF(pprof.Handler("allocs").ServeHTTP)) - debugGroup.GET("/block", gin.WrapF(pprof.Handler("block").ServeHTTP)) - debugGroup.GET("/goroutine", gin.WrapF(pprof.Handler("goroutine").ServeHTTP)) - debugGroup.GET("/heap", gin.WrapF(pprof.Handler("heap").ServeHTTP)) - debugGroup.GET("/mutex", gin.WrapF(pprof.Handler("mutex").ServeHTTP)) - debugGroup.GET("/threadcreate", gin.WrapF(pprof.Handler("threadcreate").ServeHTTP)) - } - conf := config.GetConf() - addr := net.JoinHostPort(conf.BindHost, conf.HTTPPort) - webSrv.Srv = &http.Server{ - Addr: addr, - Handler: eng, - } -} diff --git a/pkg/localcommand/local_command.go b/pkg/localcommand/local_command.go index 7dc210907..91384902f 100644 --- a/pkg/localcommand/local_command.go +++ b/pkg/localcommand/local_command.go @@ -12,6 +12,7 @@ import ( type LocalCommand struct { command string argv []string + workDir string env []string cmdCredential *syscall.Credential @@ -42,6 +43,9 @@ func New(command string, argv []string, options ...Option) (*LocalCommand, error cmd.SysProcAttr = &syscall.SysProcAttr{} cmd.SysProcAttr.Credential = lcmd.cmdCredential } + if lcmd.workDir != "" { + cmd.Dir = lcmd.workDir + } ptyFd, err := pty.StartWithSize(cmd, lcmd.ptyWin) if err != nil { return nil, fmt.Errorf("%w", err) diff --git a/pkg/localcommand/options.go b/pkg/localcommand/options.go index a1b559d33..124ceee10 100644 --- a/pkg/localcommand/options.go +++ b/pkg/localcommand/options.go @@ -29,3 +29,9 @@ func WithPtyWin(width, height int) Option { lcmd.ptyWin = &win } } + +func WithWorkDir(dir string) Option { + return func(lcmd *LocalCommand) { + lcmd.workDir = dir + } +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 07706966a..f32d90569 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -93,3 +93,7 @@ func Panic(args ...interface{}) { func Fatal(args ...interface{}) { logrus.Fatal(args...) } + +func Fatalf(format string, args ...interface{}) { + logrus.Fatalf(format, args...) +} diff --git a/pkg/proxy/command_check.go b/pkg/proxy/command_check.go index b09c5384d..7d4923b41 100644 --- a/pkg/proxy/command_check.go +++ b/pkg/proxy/command_check.go @@ -2,9 +2,10 @@ package proxy import ( "context" + "strings" "sync" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" + "github.com/jumpserver-dev/sdk-go/model" ) const ( @@ -16,7 +17,7 @@ const ( type commandConfirmStatus struct { Status string data string - Rule model.SystemUserFilterRule + Rule CommandRule Cmd string sync.Mutex wg sync.WaitGroup @@ -24,7 +25,7 @@ type commandConfirmStatus struct { ctx context.Context cancelFunc context.CancelFunc - action model.RuleAction + action model.CommandAction Processor string } @@ -34,13 +35,13 @@ func (c *commandConfirmStatus) SetStatus(status string) { c.Status = status } -func (c *commandConfirmStatus) SetAction(action model.RuleAction) { +func (c *commandConfirmStatus) SetAction(action model.CommandAction) { c.Lock() defer c.Unlock() c.action = action } -func (c *commandConfirmStatus) GetAction() model.RuleAction { +func (c *commandConfirmStatus) GetAction() model.CommandAction { c.Lock() defer c.Unlock() return c.action @@ -58,7 +59,7 @@ func (c *commandConfirmStatus) GetProcessor() string { return c.Processor } -func (c *commandConfirmStatus) SetRule(rule model.SystemUserFilterRule) { +func (c *commandConfirmStatus) SetRule(rule CommandRule) { c.Lock() defer c.Unlock() c.Rule = rule @@ -118,3 +119,9 @@ const ( CtrlC = 3 CtrlD = 4 ) + +func stripNewLine(cmd string) string { + cmd = strings.ReplaceAll(cmd, "\r", "") + cmd = strings.ReplaceAll(cmd, "\n", "") + return cmd +} diff --git a/pkg/proxy/domain_gateway.go b/pkg/proxy/domain_gateway.go index 96681f6e5..2a52dd102 100644 --- a/pkg/proxy/domain_gateway.go +++ b/pkg/proxy/domain_gateway.go @@ -10,13 +10,12 @@ import ( gossh "golang.org/x/crypto/ssh" + "github.com/jumpserver-dev/sdk-go/model" "github.com/jumpserver/koko/pkg/config" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" "github.com/jumpserver/koko/pkg/logger" ) type domainGateway struct { - domain *model.Domain dstIP string dstPort int @@ -32,12 +31,12 @@ func (d *domainGateway) run() { for { con, err := d.ln.Accept() if err != nil { - logger.Errorf("Domain %s accept conn err: %s", d.domain.Name, err) + logger.Errorf("Domain gateway %s accept conn err: %s", d.selectedGateway.Name, err) break } go d.handlerConn(con) } - logger.Infof("Domain %s stop listen on %s", d.domain.Name, d.ln.Addr()) + logger.Infof("Domain gateway %s stop listen on %s", d.selectedGateway.Name, d.ln.Addr()) } func (d *domainGateway) handlerConn(srcCon net.Conn) { @@ -79,7 +78,7 @@ func (d *domainGateway) Start() (err error) { return err } go d.run() - logger.Infof("Domain %s start listen on %s", d.domain.Name, d.ln.Addr()) + logger.Infof("Domain Gateway %s start listen on %s", d.selectedGateway.Name, d.ln.Addr()) return nil } @@ -88,48 +87,46 @@ func (d *domainGateway) GetListenAddr() *net.TCPAddr { } func (d *domainGateway) getAvailableGateway() bool { - configTimeout := time.Duration(config.GetConf().SSHTimeout) - for i := range d.domain.Gateways { - gateway := d.domain.Gateways[i] - if gateway.Protocol == "ssh" { - auths := make([]gossh.AuthMethod, 0, 3) - if gateway.Password != "" { - auths = append(auths, gossh.Password(gateway.Password)) - auths = append(auths, gossh.KeyboardInteractive(func(user, instruction string, - questions []string, echos []bool) (answers []string, err error) { - return []string{gateway.Password}, nil - })) - } - if gateway.PrivateKey != "" { - if signer, err := gossh.ParsePrivateKey([]byte(gateway.PrivateKey)); err != nil { - logger.Errorf("Domain gateway Parse private key error: %s", err) - } else { - auths = append(auths, gossh.PublicKeys(signer)) - } - } - sshConfig := gossh.ClientConfig{ - User: gateway.Username, - Auth: auths, - HostKeyCallback: gossh.InsecureIgnoreHostKey(), - Timeout: configTimeout * time.Second, - } - addr := net.JoinHostPort(gateway.IP, strconv.Itoa(gateway.Port)) - sshClient, err := gossh.Dial("tcp", addr, &sshConfig) - logger.Debugf("Domain %s try dial gateway %s", d.domain.Name, gateway.Name) - if err != nil { - logger.Errorf("Dial gateway %s err: %s ", gateway.Name, err) - continue - } - logger.Infof("Domain %s use gateway %s", d.domain.Name, gateway.Name) - d.sshClient = sshClient - d.selectedGateway = &gateway - return true + if d.selectedGateway != nil { + sshClient, err := d.createGatewaySSHClient(d.selectedGateway) + if err != nil { + logger.Errorf("Dial select gateway %s err: %s ", d.selectedGateway.Name, err) + return false } + d.sshClient = sshClient + return true } - logger.Errorf("Domain %s has no available gateway", d.domain.Name) return false } +func (d *domainGateway) createGatewaySSHClient(gateway *model.Gateway) (*gossh.Client, error) { + configTimeout := time.Duration(config.GetConf().SSHTimeout) + auths := make([]gossh.AuthMethod, 0, 3) + loginAccount := gateway.Account + if loginAccount.IsSSHKey() { + if signer, err1 := gossh.ParsePrivateKey([]byte(loginAccount.Secret)); err1 == nil { + auths = append(auths, gossh.PublicKeys(signer)) + } else { + logger.Errorf("Domain gateway Parse private key error: %s", err1) + } + } else { + auths = append(auths, gossh.Password(loginAccount.Secret)) + auths = append(auths, gossh.KeyboardInteractive(func(user, instruction string, + questions []string, echos []bool) (answers []string, err error) { + return []string{loginAccount.Secret}, nil + })) + } + sshConfig := gossh.ClientConfig{ + User: loginAccount.Username, + Auth: auths, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + Timeout: configTimeout * time.Second, + } + port := gateway.Protocols.GetProtocolPort(model.ProtocolSSH) + addr := net.JoinHostPort(gateway.Address, strconv.Itoa(port)) + return gossh.Dial("tcp", addr, &sshConfig) +} + func (d *domainGateway) Stop() { d.closeOnce() } @@ -138,6 +135,6 @@ func (d *domainGateway) closeOnce() { d.once.Do(func() { _ = d.ln.Close() _ = d.sshClient.Close() - logger.Debugf("Domain %s close listen and gateway ssh client", d.domain.Name) + logger.Debugf("Domain Gateway %s close listen and gateway ssh client", d.selectedGateway.Name) }) } diff --git a/pkg/proxy/k8s.go b/pkg/proxy/k8s.go new file mode 100644 index 000000000..bc8a587d4 --- /dev/null +++ b/pkg/proxy/k8s.go @@ -0,0 +1,401 @@ +package proxy + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net" + "net/url" + "os" + "os/exec" + "strings" + + "github.com/jumpserver/koko/pkg/srvconn" + "k8s.io/client-go/rest" + + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver/koko/pkg/logger" +) + +const ( + ResourceNamespace = "namespace" + ResourcePod = "pod" + ResourceContainer = "container" +) + +type Container struct { + Name string `json:"name"` + Type string `json:"type"` +} + +type Pod struct { + Name string `json:"name"` + Type string `json:"type"` + Containers []Container `json:"containers"` +} + +type Namespace struct { + Name string `json:"name"` + Type string `json:"type"` + Pods []Pod `json:"pods"` +} + +type KubernetesClient struct { + configName string + token string + gateway *domainGateway +} + +func NewKubernetesClient(address, namespace, token string, gateway *model.Gateway) (*KubernetesClient, error) { + kc := &KubernetesClient{} + if err := kc.InitClient(address, namespace, token, gateway); err != nil { + return nil, err + } + return kc, nil +} + +func (kc *KubernetesClient) InitClient(address, namespace, token string, gateway *model.Gateway) error { + var proxyAddr *net.TCPAddr + if gateway != nil { + dGateway, err := newK8sGateWayServer(address, gateway) + if err != nil { + return fmt.Errorf("start domain gateway failed: %v", err) + } + if err = dGateway.Start(); err != nil { + return fmt.Errorf("start domain gateway failed: %v", err) + } + kc.gateway = dGateway + proxyAddr = dGateway.GetListenAddr() + } + + if proxyAddr != nil { + originUrl, err := url.Parse(address) + if err != nil { + return err + } + address = ReplaceURLHostAndPort(originUrl, "127.0.0.1", proxyAddr.Port) + } + + kubeConf := &rest.Config{ + Host: address, + BearerToken: token, + } + kubeConf.Insecure = true + + if !srvconn.IsValidK8sUserToken(kubeConf) { + return srvconn.ErrValidToken + } + + kubeConfigYAML := kc.GetKubeConfig(address, namespace, token) + + tmpFile, err := os.CreateTemp("", "kubeconfig-*.yaml") + if err != nil { + return fmt.Errorf("error creating temp file: %w", err) + } + defer tmpFile.Close() + + if _, err := tmpFile.Write([]byte(kubeConfigYAML)); err != nil { + return fmt.Errorf("error writing to temp file: %w", err) + } + kc.configName = tmpFile.Name() + kc.token = token + return nil +} + +func newK8sGateWayServer(address string, gateway *model.Gateway) (*domainGateway, error) { + dstHost, dstPort, err := ParseUrlHostAndPort(address) + if err != nil { + return nil, err + } + dGateway := &domainGateway{ + dstIP: dstHost, + dstPort: dstPort, + selectedGateway: gateway, + } + return dGateway, nil +} + +func (kc *KubernetesClient) GetKubeConfig(address, namespace, token string) string { + return fmt.Sprintf(` +apiVersion: v1 +kind: Config +clusters: +- cluster: + server: %s + insecure-skip-tls-verify: true + name: remote-cluster +contexts: +- context: + cluster: remote-cluster + user: remote-user + namespace: %s + name: remote-context +current-context: remote-context +users: +- name: remote-user + user: + token: %s +`, address, namespace, token) +} + +var ( + getNamespacesLineAll = "kubectl get namespaces -o custom-columns=NAME:.metadata.name --no-headers" + getPodsAllNamespacesLine = "kubectl get pods --all-namespaces -o custom-columns=NAMESPACE:.metadata.namespace,POD:.metadata.name,CONTAINER:.spec.containers[*].name --no-headers" + getPodsInNamespaceFmt = "kubectl get pods -n %s -o custom-columns=NAMESPACE:.metadata.namespace,POD:.metadata.name,CONTAINER:.spec.containers[*].name --no-headers" + getCurrentNSLine = "kubectl config view --minify -o jsonpath='{..namespace}'" +) + +func (kc *KubernetesClient) GetTreeData() (string, error) { + env := append(os.Environ(), "KUBECONFIG="+kc.configName) + + nsList, err := kc.resolveNamespaces(env) + if err != nil { + logger.Debugf("resolveNamespaces failed: %v", err) + return "{}", err + } + if len(nsList) == 0 { + return "{}", fmt.Errorf("no accessible namespaces detected for current credentials") + } + + podLines, err := kc.listPodsSmart(env, nsList) + if err != nil { + logger.Debugf("listPodsSmart failed: %v", err) + return "{}", err + } + + k8sTree := NewTree() + for _, line := range podLines { + record := strings.Fields(line) + if len(record) < 3 { + continue + } + nsName, podName, containerNames := record[0], record[1], record[2] + for _, containerName := range strings.Split(containerNames, ",") { + if strings.TrimSpace(containerName) == "" { + continue + } + k8sTree.InsertResource(nsName, podName, containerName) + } + } + + namespaces := make(map[string]*Namespace) + for _, ns := range nsList { + namespaces[ns] = &Namespace{Name: ns, Type: "namespace"} + } + for _, ns := range k8sTree.SearchNamespaces() { + namespaces[ns.Name] = &ns + } + + jsonData, err := json.Marshal(namespaces) + if err != nil { + logger.Errorf("Error marshalling JSON: %v", err) + return "{}", err + } + return string(jsonData), nil +} + +func (kc *KubernetesClient) Close() { + var err error + if removeErr := os.Remove(kc.configName); removeErr != nil { + err = errors.Join(err, fmt.Errorf("failed to remove kubeconfig file: %w", removeErr)) + } + if kc.gateway != nil { + kc.gateway.Stop() + } + if err != nil { + logger.Error(err) + } +} + +type K8sResourceTree struct { + Root *K8sNode +} + +type K8sNode struct { + Name string + Type string + SubTree map[string]*K8sNode +} + +func NewTree() *K8sResourceTree { + return &K8sResourceTree{ + Root: NewNode(), + } +} + +func NewNode() *K8sNode { + return &K8sNode{ + SubTree: make(map[string]*K8sNode), + } +} + +func (node *K8sNode) insert(resource, resourceType string) *K8sNode { + if node.SubTree == nil { + node.SubTree = make(map[string]*K8sNode) + } + if k8sNode, ok := node.SubTree[resource]; ok { + return k8sNode + } + newNode := NewNode() + newNode.Type = resourceType + newNode.Name = resource + node.SubTree[resource] = newNode + return newNode +} + +func (node *K8sNode) searchContainers() []Container { + containers := make([]Container, 0, len(node.SubTree)) + for _, container := range node.SubTree { + containers = append(containers, Container{ + Type: container.Type, + Name: container.Name, + }) + } + return containers +} + +func (node *K8sNode) searchPods() []Pod { + pods := make([]Pod, 0, len(node.SubTree)) + for _, pod := range node.SubTree { + pods = append(pods, Pod{ + Type: pod.Type, + Name: pod.Name, + Containers: pod.searchContainers(), + }) + } + return pods +} + +func (tree *K8sResourceTree) SearchNamespaces() []Namespace { + namespaces := make([]Namespace, 0, len(tree.Root.SubTree)) + for _, namespace := range tree.Root.SubTree { + namespaces = append(namespaces, Namespace{ + Type: namespace.Type, + Name: namespace.Name, + Pods: namespace.searchPods(), + }) + } + return namespaces +} + +func (tree *K8sResourceTree) InsertResource(ns, pod, container string) { + cur := tree.Root + cur.insert(ns, ResourceNamespace). + insert(pod, ResourcePod). + insert(container, ResourceContainer) +} + +func (kc *KubernetesClient) resolveNamespaces(env []string) ([]string, error) { + if curNS := strings.TrimSpace(shellOutOrEmpty(env, getCurrentNSLine)); curNS != "" { + return []string{curNS}, nil + } + + if out, err := runCmd(env, getNamespacesLineAll); err == nil { + return nonEmptyLines(out), nil + } + + if ns, err := extractNamespaceFromSAToken(kc.token); err == nil && ns != "" { + return []string{ns}, nil + } else { + logger.Debugf("extractNamespaceFromSAToken failed: %v", err) + } + + return nil, fmt.Errorf("cannot determine accessible namespaces: no list permission, no SA token namespace, no context namespace") +} + +func (kc *KubernetesClient) listPodsSmart(env []string, nsList []string) ([]string, error) { + if len(nsList) > 1 { + if out, err := runCmd(env, getPodsAllNamespacesLine); err == nil { + return nonEmptyLines(out), nil + } + } + var all []string + for _, ns := range nsList { + cmd := fmt.Sprintf(getPodsInNamespaceFmt, shellEscape(ns)) + out, err := runCmd(env, cmd) + if err != nil { + logger.Debugf("list pods in ns %q failed: %v", ns, err) + continue + } + all = append(all, nonEmptyLines(out)...) + } + return all, nil +} + +func extractNamespaceFromSAToken(token string) (string, error) { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return "", fmt.Errorf("token is not a JWT") + } + payloadB64 := parts[1] + + payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadB64) + if err != nil { + if payloadBytes2, err2 := base64.StdEncoding.DecodeString(payloadB64); err2 == nil { + payloadBytes = payloadBytes2 + } else { + return "", fmt.Errorf("failed to decode JWT payload: %v / %v", err, err2) + } + } + + var claims map[string]any + if err := json.Unmarshal(payloadBytes, &claims); err != nil { + return "", fmt.Errorf("failed to unmarshal JWT payload: %w", err) + } + + if v, ok := claims["kubernetes.io/serviceaccount/namespace"]; ok { + if ns, ok := v.(string); ok && strings.TrimSpace(ns) != "" { + return ns, nil + } + } + return "", fmt.Errorf("no serviceaccount namespace claim in token") +} + +func runCmd(env []string, line string) (string, error) { + cmd := exec.Command("bash", "-c", line) + cmd.Env = env + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + if err != nil { + return "", fmt.Errorf("cmd failed: %s, err: %w, stderr: %s", line, err, strings.TrimSpace(stderr.String())) + } + + return stdout.String(), nil +} + +func shellOutOrEmpty(env []string, line string) string { + out, err := runCmd(env, line) + if err != nil { + return "" + } + return out +} + +func nonEmptyLines(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + raw := strings.Split(strings.TrimSpace(s), "\n") + dst := make([]string, 0, len(raw)) + for _, v := range raw { + v = strings.TrimSpace(v) + if v != "" && !strings.HasPrefix(v, "error:") { + dst = append(dst, v) + } + } + return dst +} + +func shellEscape(s string) string { + if !strings.ContainsAny(s, " \t\n'\"\\$`") { + return s + } + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} diff --git a/pkg/proxy/k8s_test.go b/pkg/proxy/k8s_test.go new file mode 100644 index 000000000..6425ff736 --- /dev/null +++ b/pkg/proxy/k8s_test.go @@ -0,0 +1,347 @@ +package proxy + +import ( + "crypto/rand" + "fmt" + "math/big" + "strings" + "testing" + "time" +) + +const podLineMocked = ` +default frontend nginx +default backend nodejs,mongodb +monitoring prometheus-server prometheus,config-reloader +logging fluentd-aggregator fluentd,log-exporter +app-production order-service java-app,redis-sidecar +app-production payment-service go-app,postgresql +app-staging order-service java-app-staging,redis-staging +app-staging payment-service go-app-staging,postgres-staging +kube-system kube-proxy kube-proxy +kube-system coredns coredns +vault vault-server vault +ai-pipeline tensorflow-job trainer,metrics-collector,log-uploader +bigdata spark-driver spark-main,zeppelin,livy,history-server +ci-cd jenkins-controller jenkins,jnlp-agent,git-sync,ssh-tunnel +long-namespace-name-123 very-long-pod-name-567890 minimal-container +special-chars @weird_pod! $pecial-container,{test}-box +stress-test high-mem-pod memtester,swap-manager +stress-test high-cpu-pod cpuburner,throttle-monitor +namespace1 pod1 container1 +namespace1 pod2 container1,container2 +namespace2 pod3 containerA +namespace2 pod4 containerB,containerC +` + +func TestK8sResourceTree_SearchNamespaces(t *testing.T) { + k8sTree := NewTree() + lines := strings.Split(strings.TrimSpace(podLineMocked), "\n") + for _, line := range lines { + elements := strings.Fields(line) + ns, pod, containers := elements[0], elements[1], elements[2] + for _, container := range strings.Split(containers, ",") { + k8sTree.InsertResource(ns, pod, container) + } + } + namespaces := k8sTree.SearchNamespaces() + fmt.Println(namespaces) +} + +func TestK8sResourceTree_ResultTest(t *testing.T) { + mockedData := GenPodLines(30000) + res1 := v1(mockedData) + res2 := v2(mockedData) + if !nsIsEqual(res1, res2) { + panic("not equal") + } + fmt.Println("ok") +} + +func TestK8sTreeGen_SpeedTest(t *testing.T) { + mockedData := GenPodLines(30000) // 模拟企业级生产环境小集群架构下的k8s规模 + + var duration1, duration2 int64 + st1 := time.Now() + for i := 0; i <= 100; i++ { + v1(mockedData) + } + duration1 = time.Since(st1).Milliseconds() + + st2 := time.Now() + for i := 0; i <= 100; i++ { + v2(mockedData) + } + duration2 = time.Since(st2).Milliseconds() + + fmt.Printf("v1: %d\n", duration1/100) + fmt.Printf("v2: %d\n", duration2/100) + fmt.Printf("improvement: %2f\n", (float64(duration1-duration2))/float64(duration1)) +} + +func containersIsEqual(c1, c2 []Container) (ans bool) { + defer func() { + if !ans { + fmt.Println("container error") + } + }() + c1Map := make(map[string]Container) + for _, c := range c1 { + c1Map[c.Name] = c + } + for _, c := range c2 { + if cc, ok := c1Map[c.Name]; !ok { + return + } else if cc != c { + return + } + delete(c1Map, c.Name) + } + ans = len(c1Map) == 0 + return +} + +func podsIsEqual(p1, p2 []Pod) (ans bool) { + defer func() { + if !ans { + fmt.Println("pod error") + } + }() + p1Map := make(map[string]Pod) + for _, p := range p1 { + p1Map[p.Name] = p + } + for _, p := range p2 { + if pp, ok := p1Map[p.Name]; !ok { + return + } else if p.Name != pp.Name || !containersIsEqual(p.Containers, pp.Containers) { + return + } + + delete(p1Map, p.Name) + } + + ans = len(p1Map) == 0 + return +} + +func nsIsEqual(n1, n2 map[string]*Namespace) (ans bool) { + defer func() { + if !ans { + fmt.Println("ns error") + } + }() + n1Map := n1 + for _, n := range n2 { + if nn, ok := n1Map[n.Name]; !ok { + return + } else if n.Name != nn.Name || !podsIsEqual(n.Pods, nn.Pods) { + return + } + + delete(n1Map, n.Name) + } + ans = len(n1Map) == 0 + return +} + +func v2(podLines []string) map[string]*Namespace { + namespaces := make(map[string]*Namespace) + k8sTree := NewTree() + for _, line := range podLines { + elements := strings.Fields(line) + ns, pod, containers := elements[0], elements[1], elements[2] + for _, container := range strings.Split(containers, ",") { + k8sTree.InsertResource(ns, pod, container) + } + } + for _, namespace := range k8sTree.SearchNamespaces() { + namespaceCopy := namespace + namespaces[namespace.Name] = &namespaceCopy + } + return namespaces +} + +func v1(podLines []string) map[string]*Namespace { + namespaces := make(map[string]*Namespace) + for _, line := range podLines { + record := strings.Fields(line) + if len(record) < 3 { + continue + } + + nsName, podName, containerName := record[0], record[1], record[2] + + ns, exists := namespaces[nsName] + if !exists { + ns = &Namespace{Name: nsName, Type: "namespace"} + namespaces[nsName] = ns + } + + var pod *Pod + for i := range ns.Pods { + if ns.Pods[i].Name == podName { + pod = &ns.Pods[i] + break + } + } + + if pod == nil { + pod = &Pod{Name: podName, Type: "pod"} + ns.Pods = append(ns.Pods, *pod) + } + + for i := range ns.Pods { + if ns.Pods[i].Name == podName { + containers := make([]Container, 0) + for _, v := range strings.Split(containerName, ",") { + containers = append(containers, Container{Name: v, Type: "container"}) + } + ns.Pods[i].Containers = append(ns.Pods[i].Containers, containers...) + break + } + } + } + return namespaces +} + +// 模拟大规模的生产环境的k8s集群数据 +// +//nolint:gosec +func GenPodLines(scale int) []string { + lines := make([]string, scale) + + // 预生成命名空间列表(约100个不同的ns) + nsList := make([]string, 100) + for i := range nsList { + nsList[i] = genNsName() + } + + // 真实集群的典型分布模式 + for i := 0; i < scale; i++ { + var ns string + switch { + case i < scale/20: // 5% 系统组件 + ns = choice([]string{"kube-system", "kube-public", "istio-system"}) + case i < scale/100*15: // 15% 监控日志 + ns = choice([]string{"monitoring", "logging", "security"}) + case i < scale/2: // 50% 业务应用 + ns = nsList[CryptoRandInt(30)+20] // 使用前50个业务ns + default: // 30% 其他 + ns = nsList[CryptoRandInt(len(nsList))] + } + + lines[i] = fmt.Sprintf("%s\t%s\t%s", + ns, + genPodName(ns), + genContainers(ns), + ) + } + return lines +} + +// 生成命名空间名称 +// +//nolint:gosec +func genNsName() string { + prefix := choice([]string{ + "app", "team", "project", "service", "system", + "infra", "test", "staging", "prod", "backend", + }) + suffix := fmt.Sprintf("%03d", CryptoRandInt(200)) + return fmt.Sprintf("%s-%s-%s", prefix, choice([]string{"web", "api", "data", "mobile"}), suffix) +} + +// 生成Pod名称(基于命名空间特征) +// +//nolint:gosec +func genPodName(ns string) string { + parts := strings.Split(ns, "-") + appType := "" + if len(parts) > 1 { + appType = parts[1] + } else { + appType = parts[0] + } + + templates := map[string][]string{ + "web": {"frontend", "ui", "portal"}, + "api": {"gateway", "service", "controller"}, + "data": {"database", "redis", "kafka"}, + "mobile": {"android", "ios", "sync"}, + } + + return fmt.Sprintf("%s-%s-%s-%s", + appType, + choice(templates[appType]), + choice([]string{"prod", "dev", "canary"}), + randomString(4), + ) +} + +// 生成容器列表(带sidecar模式) +// +//nolint:gosec +func genContainers(ns string) string { + baseContainers := []string{ + "nginx", "nodejs", "java-app", "python", + "postgres", "redis", "kafka", "elasticsearch", + } + + sidecars := []string{ + "prometheus-exporter", "istio-proxy", + "log-collector", "vault-agent", + } + + var containers []string + + // 主容器 + main := baseContainers[CryptoRandInt(len(baseContainers))] + containers = append(containers, main) + + // 30%的Pod带sidecar + if CryptoRandInt(100) < 30 { + containers = append(containers, sidecars[CryptoRandInt(len(sidecars))]) + } + + // 系统命名空间特殊处理 + if strings.Contains(ns, "kube-system") { + return strings.Join([]string{"kube-proxy", "metrics-server"}, ",") + } + + return strings.Join(containers, ",") +} + +// 辅助函数:随机选择 +// +//nolint:gosec // 测试数据生成无需密码学安全随机 +func choice(options []string) string { + if len(options) == 0 { + return "none" + } + return options[CryptoRandInt(len(options))] +} + +// 生成随机字符串 +// +//nolint:gosec +func randomString(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, n) + for i := range b { + b[i] = letters[CryptoRandInt(len(letters))] + } + return string(b) +} + +func CryptoRandInt(max int) int { + bigRange := big.NewInt(int64(max)) + + randomNum, err := rand.Int(rand.Reader, bigRange) + if err != nil { + return 0 + } + + // 调整到目标范围并返回 + return int(randomNum.Int64()) +} diff --git a/pkg/proxy/login_confirm.go b/pkg/proxy/login_confirm.go deleted file mode 100644 index 1b2938f53..000000000 --- a/pkg/proxy/login_confirm.go +++ /dev/null @@ -1,101 +0,0 @@ -package proxy - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/jumpserver/koko/pkg/auth" - "github.com/jumpserver/koko/pkg/logger" - "github.com/jumpserver/koko/pkg/utils" -) - -// 校验用户登录资产是否需要复核 -func (s *Server) validateLoginConfirm(srv *auth.LoginConfirmService, userCon UserConnection) bool { - lang := s.connOpts.getLang() - ok, err := srv.CheckIsNeedLoginConfirm() - if err != nil { - logger.Errorf("Conn[%s] validate login confirm api err: %s", - userCon.ID(), err.Error()) - msg := lang.T("validate Login confirm err: Core Api failed") - utils.IgnoreErrWriteString(userCon, msg) - utils.IgnoreErrWriteString(userCon, utils.CharNewLine) - return false - } - if !ok { - logger.Debugf("Conn[%s] no need login confirm", userCon.ID()) - return true - } - - ctx, cancelFunc := context.WithCancel(userCon.Context()) - term := utils.NewTerminal(userCon, "") - defer userCon.Close() - go func() { - defer cancelFunc() - for { - line, err := term.ReadLine() - if err != nil { - logger.Errorf("Wait confirm user readLine exit: %s", err.Error()) - return - } - switch line { - case "quit", "q": - logger.Infof("Conn[%s] quit confirm", userCon.ID()) - return - } - } - }() - reviewers := srv.GetReviewers() - detailURL := srv.GetTicketUrl() - titleMsg := lang.T("Need ticket confirm to login, already send email to the reviewers") - reviewersMsg := fmt.Sprintf(lang.T("Ticket Reviewers: %s"), strings.Join(reviewers, ", ")) - detailURLMsg := fmt.Sprintf(lang.T("Could copy website URL to notify reviewers: %s"), detailURL) - waitMsg := lang.T("Please waiting for the reviewers to confirm, enter q to exit. ") - utils.IgnoreErrWriteString(userCon, titleMsg) - utils.IgnoreErrWriteString(userCon, utils.CharNewLine) - utils.IgnoreErrWriteString(userCon, reviewersMsg) - utils.IgnoreErrWriteString(userCon, utils.CharNewLine) - utils.IgnoreErrWriteString(userCon, detailURLMsg) - utils.IgnoreErrWriteString(userCon, utils.CharNewLine) - go func() { - delay := 0 - for { - select { - case <-ctx.Done(): - return - default: - delayS := fmt.Sprintf("%ds", delay) - data := strings.Repeat("\x08", len(delayS)+len(waitMsg)) + waitMsg + delayS - utils.IgnoreErrWriteString(userCon, data) - time.Sleep(time.Second) - delay += 1 - } - } - }() - - status := srv.WaitLoginConfirm(ctx) - cancelFunc() - processor := srv.GetProcessor() - var success bool - statusMsg := lang.T("Unknown status") - switch status { - case auth.StatusApprove: - // 审核通过 - formatMsg := lang.T("%s approved") - statusMsg = utils.WrapperString(fmt.Sprintf(formatMsg, processor), utils.Green) - success = true - case auth.StatusReject: - // 审核未通过 - formatMsg := lang.T("%s rejected") - statusMsg = utils.WrapperString(fmt.Sprintf(formatMsg, processor), utils.Red) - case auth.StatusCancel: - // 审核取消 - statusMsg = utils.WrapperString(lang.T("Cancel confirm"), utils.Red) - } - logger.Infof("Conn[%s] Login Confirm result: %s", userCon.ID(), statusMsg) - utils.IgnoreErrWriteString(userCon, utils.CharNewLine) - utils.IgnoreErrWriteString(userCon, statusMsg) - utils.IgnoreErrWriteString(userCon, utils.CharNewLine) - return success -} diff --git a/pkg/proxy/parser.go b/pkg/proxy/parser.go index 0ab5f4900..1a0db2547 100644 --- a/pkg/proxy/parser.go +++ b/pkg/proxy/parser.go @@ -3,25 +3,30 @@ package proxy import ( "bytes" "context" + "encoding/hex" "fmt" + "strings" "sync" "time" "github.com/LeeEirc/tclientlib" + "github.com/LeeEirc/terminalparser" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" + "github.com/jumpserver/koko/pkg/srvconn" + "github.com/jumpserver/koko/pkg/config" "github.com/jumpserver/koko/pkg/exchange" "github.com/jumpserver/koko/pkg/i18n" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" "github.com/jumpserver/koko/pkg/logger" - "github.com/jumpserver/koko/pkg/srvconn" "github.com/jumpserver/koko/pkg/utils" "github.com/jumpserver/koko/pkg/zmodem" ) var ( charEnter = []byte("\r") + charLF = []byte("\n") enterMarks = [][]byte{ []byte("\x1b[?1049h"), @@ -36,11 +41,14 @@ var ( []byte("\x1b[?1047l"), []byte("\x1b[?47l"), } -) - -const ( - CommandInputParserName = "Command Input parser" - CommandOutputParserName = "Command Output parser" + screenMarks = [][]byte{ + {0x1b, 0x5b, 0x4b, 0x0d, 0x0a}, // 4b 0d 0a + //{0x1b, 0x5b, 0x34, 0x6c}, // 1b 5b 34 6c + } + vimMarks = [][]byte{ + {0x1b, 0x5b, 0x32, 0x3b, 0x31}, // ESC ] 2; 设置标题 1b 5b 32 3b 31 + //{0x1b, 0x5b, 0x32, 0x32, 0x3b, 0x30, 0x3b, 0x30, 0x74}, // 1b 5b 32 32 3b 30 3b 30 74 设置标题的控制字符 + } ) type Parser struct { @@ -52,22 +60,21 @@ type Parser struct { srvOutputChan chan []byte cmdRecordChan chan *ExecutedCommand - inputInitial bool - inputPreState bool - inputState bool + TerminalParser *TerminalParser + + isScreenMode bool + isEditMode bool inVimState bool once sync.Once lock sync.RWMutex - command string - output string - cmdCreateDate time.Time - cmdInputParser *CmdParser - cmdOutputParser *CmdParser + command string + output string + cmdCreateDate time.Time - cmdFilterRules []model.SystemUserFilterRule - closed chan struct{} + cmdFilterACLs model.CommandACLs + closed chan struct{} confirmStatus commandConfirmStatus @@ -80,14 +87,71 @@ type Parser struct { i18nLang string platform *model.Platform + + currentCmdRiskLevel int64 + currentCmdFilterRule CommandRule + + userInputFilter func([]byte) []byte + + disableInputAsCmd bool +} + +func (p *Parser) setCurrentCmdStatusLevel(level int64) { + p.currentCmdRiskLevel = level +} + +func (p *Parser) getCurrentCmdStatusLevel() int64 { + return p.currentCmdRiskLevel +} + +func (p *Parser) setCurrentCmdFilterRule(rule CommandRule) { + p.currentCmdFilterRule = rule +} + +func (p *Parser) getCurrentCmdFilterRule() CommandRule { + return p.currentCmdFilterRule } -func (p *Parser) initial() { +func (p *Parser) resetCurrentCmdFilterRule() { + p.currentCmdFilterRule = CommandRule{} +} - p.cmdInputParser = NewCmdParser(p.id, CommandInputParserName) - p.cmdOutputParser = NewCmdParser(p.id, CommandOutputParserName) +func (p *Parser) CurrentScreenType() int { + if isWindows(p.platform) { + return WindowsScreen + } + switch p.protocolType { + case srvconn.ProtocolMongoDB: + return MongoScreen + case srvconn.ProtocolMySQL, + srvconn.ProtocolMariadb, + srvconn.ProtocolPostgresql, + srvconn.ProtocolClickHouse, + srvconn.ProtocolOracle, + srvconn.ProtocolSQLServer: + return UsqlScreen + default: + } + return LinuxScreen +} + +func (p *Parser) initial(w, h int) { + screenType := p.CurrentScreenType() + p.TerminalParser = &TerminalParser{IsEnter: p.isEnterKeyPress, + EmitCommands: p.EmitCommandEvent, + usqlScreenParser: terminalparser.NewUSqlParser(), + winScreenParser: terminalparser.NewWindowsParser(), + mongoScreenParser: terminalparser.NewMongoShParser(), + screenType: screenType, + preScreenType: screenType, + Screen: terminalparser.NewScreen(h, w)} p.closed = make(chan struct{}) p.cmdRecordChan = make(chan *ExecutedCommand, 1024) + p.disableInputAsCmd = config.GetConf().DisableInputAsCommand +} + +func (p *Parser) SetUserInputFilter(filter func([]byte) []byte) { + p.userInputFilter = filter } // ParseStream 解析数据流 @@ -105,6 +169,9 @@ func (p *Parser) ParseStream(userInChan chan *exchange.RoomMessage, srvInChan <- p.zmodemParser.Cleanup() logger.Infof("Session %s: Parser routine done", p.id) }() + cmdRecordTicker := time.NewTicker(time.Minute) + defer cmdRecordTicker.Stop() + lastActiveTime := time.Now() for { select { case <-p.closed: @@ -119,10 +186,9 @@ func (p *Parser) ParseStream(userInChan chan *exchange.RoomMessage, srvInChan <- b = msg.Body } p.UpdateActiveUser(msg) - if len(b) == 0 { - continue + if len(b) > 0 { + b = p.ParseUserInput(b) } - b = p.ParseUserInput(b) select { case <-p.closed: return @@ -139,13 +205,40 @@ func (p *Parser) ParseStream(userInChan chan *exchange.RoomMessage, srvInChan <- return case p.srvOutputChan <- b: } - + case now := <-cmdRecordTicker.C: + // 每隔一分钟超时,尝试结算一次命令 + if now.Sub(lastActiveTime) > time.Minute { + p.sendCommandRecord() + p.TerminalParser.TryMultipleCommands() + } + continue } + lastActiveTime = time.Now() } }() return p.userOutputChan, p.srvOutputChan } +func (p *Parser) isEnterKeyPress(b []byte) bool { + if bytes.LastIndex(b, charEnter) == 0 { + return true + } + if len(b) > 1 && bytes.HasSuffix(b, charLF) && isLinux(p.platform) { + return true + } + // 多行命令,会有 \r 字符,此处也需要拦截 + if bytes.ContainsRune(b, '\r') { + return true + } + if p.TerminalParser != nil && p.TerminalParser.screenType == UsqlScreen { + // terminal 右键粘贴时,没有 \r 只有 \n + if bytes.ContainsRune(b, '\n') && bytes.ContainsRune(b, ';') { + return true + } + } + return false +} + // parseInputState 切换用户输入状态, 并结算命令和结果 func (p *Parser) parseInputState(b []byte) []byte { lang := i18n.NewLang(p.i18nLang) @@ -204,7 +297,26 @@ func (p *Parser) parseInputState(b []byte) []byte { p.confirmStatus.Status) return nil } - waitMsg := lang.T("the reviewers will confirm. continue or not [Y/n]") + + WarnWaitMsg := lang.T("The command you executed is risky and an alert notification will be sent to the administrator. Do you want to continue?[Y/N]") + if p.confirmStatus.InQuery() && p.getCurrentCmdStatusLevel() == model.WarningLevel { + switch strings.ToLower(string(b)) { + case "y": + p.confirmStatus.SetStatus(StatusNone) + p.userOutputChan <- []byte("\r\n") + case "n": + p.confirmStatus.SetStatus(StatusNone) + p.srvOutputChan <- []byte("\r\n") + p.TerminalParser.resetCommand() + p.command = "" + return p.breakInputPacket() + default: + p.srvOutputChan <- []byte("\r\n" + WarnWaitMsg) + } + return nil + } + + confirmWaitMsg := lang.T("The command '%s' requires review. Continue or not [Y/n]?") if p.confirmStatus.InQuery() { switch strings.ToLower(string(b)) { case "y": @@ -222,13 +334,15 @@ func (p *Parser) parseInputState(b []byte) []byte { } processor := p.confirmStatus.GetProcessor() switch p.confirmStatus.GetAction() { - case model.ActionAllow: + case model.ActionAccept: + p.setCurrentCmdStatusLevel(model.ReviewAccept) formatMsg := lang.T("%s approved") statusMsg := utils.WrapperString(fmt.Sprintf(formatMsg, processor), utils.Green) p.srvOutputChan <- []byte("\r\n") p.srvOutputChan <- []byte(statusMsg) p.userOutputChan <- []byte(p.confirmStatus.data) - case model.ActionDeny: + case model.ActionReject: + p.setCurrentCmdStatusLevel(model.ReviewReject) formatMsg := lang.T("%s rejected") statusMsg := utils.WrapperString(fmt.Sprintf(formatMsg, processor), utils.Red) p.srvOutputChan <- []byte("\r\n") @@ -236,6 +350,7 @@ func (p *Parser) parseInputState(b []byte) []byte { p.forbiddenCommand(p.confirmStatus.Cmd) default: // 默认是取消 不执行 + p.setCurrentCmdStatusLevel(model.ReviewCancel) p.srvOutputChan <- []byte("\r\n") p.userOutputChan <- p.breakInputPacket() } @@ -243,106 +358,95 @@ func (p *Parser) parseInputState(b []byte) []byte { p.confirmStatus.SetStatus(StatusNone) }() case "n": + p.setCurrentCmdStatusLevel(model.ReviewCancel) p.confirmStatus.SetStatus(StatusNone) p.srvOutputChan <- []byte("\r\n") return p.breakInputPacket() default: - p.srvOutputChan <- []byte("\r\n" + waitMsg) + confirmMsg := fmt.Sprintf(confirmWaitMsg, stripNewLine(p.confirmStatus.Cmd)) + p.srvOutputChan <- []byte("\r\n" + confirmMsg) } return nil } - - if bytes.LastIndex(b, charEnter) == 0 { - // 连续输入enter key, 结算上一条可能存在的命令结果 + if currentCmd, ok1 := p.TerminalParser.WriteInput(b); ok1 { p.sendCommandRecord() - p.inputState = false - // 用户输入了Enter,开始结算命令 - p.parseCmdInput() - if rule, cmd, ok := p.IsMatchCommandRule(p.command); ok { - switch rule.Action { - case model.ActionDeny: + p.command = currentCmd + p.cmdCreateDate = time.Now() + if rule, cmd, ok := p.IsMatchCommandRule(currentCmd); ok { + switch rule.Acl.Action { + case model.ActionReject: + p.setCurrentCmdStatusLevel(model.RejectLevel) + p.setCurrentCmdFilterRule(rule) p.forbiddenCommand(cmd) return nil - case model.ActionConfirm: - switch p.protocolType { - case srvconn.ProtocolMySQL, srvconn.ProtocolMariadb, - srvconn.ProtocolSQLServer, srvconn.ProtocolRedis: - // 数据库相关 暂时不支持 复核 直接拒绝 - fbdMsg2 := utils.WrapperWarn(lang.T("Command review is not currently supported")) - p.srvOutputChan <- []byte("\r\n" + fbdMsg2) - p.forbiddenCommand(cmd) - return nil - default: - p.confirmStatus.SetStatus(StatusQuery) - p.confirmStatus.SetRule(rule) - p.confirmStatus.SetCmd(p.command) - p.confirmStatus.SetData(string(b)) - p.confirmStatus.ResetCtx() - p.srvOutputChan <- []byte("\r\n" + waitMsg) - } + case model.ActionReview: + p.setCurrentCmdFilterRule(rule) + p.confirmStatus.SetStatus(StatusQuery) + p.confirmStatus.SetRule(rule) + p.confirmStatus.SetCmd(p.command) + p.confirmStatus.SetData(string(b)) + p.confirmStatus.ResetCtx() + confirmMsg := fmt.Sprintf(confirmWaitMsg, stripNewLine(p.confirmStatus.Cmd)) + p.srvOutputChan <- []byte("\r\n" + confirmMsg) + return nil + case model.ActionWarning: + p.setCurrentCmdFilterRule(rule) + p.setCurrentCmdStatusLevel(model.WarningLevel) + logger.Debugf("Session %s: command %s match warning rule", p.id, p.command) + case model.ActionNotifyAndWarn: + p.confirmStatus.SetStatus(StatusQuery) + p.setCurrentCmdFilterRule(rule) + p.setCurrentCmdStatusLevel(model.WarningLevel) + logger.Debugf("Session %s: command %s match notify and warn rule", p.id, p.command) + p.srvOutputChan <- []byte("\r\n" + WarnWaitMsg) return nil default: } } - } else { - p.inputState = true - // 用户又开始输入,并上次不处于输入状态,开始结算上次命令的结果 - if !p.inputPreState { - p.sendCommandRecord() - if ps1 := p.cmdOutputParser.GetPs1(); ps1 != "" { - p.cmdInputParser.SetPs1(ps1) - } + if strings.Contains(p.command, "\r") { + // 先记录一次 多行命令的输入,output 暂且为空 + p.sendCommandToChan() + p.command = "" } } return b } +func (p *Parser) supportMultiCmd() bool { + switch p.protocolType { + case model.ProtocolSSH, + model.ProtocolTelnet, + model.ProtocolK8S: + return true + default: + return false + } +} + func (p *Parser) IsNeedParse() bool { p.lock.Lock() defer p.lock.Unlock() if p.inVimState { return false } - p.inputPreState = p.inputState return true } func (p *Parser) forbiddenCommand(cmd string) { lang := i18n.NewLang(p.i18nLang) - fbdMsg := utils.WrapperWarn(fmt.Sprintf(lang.T("Command `%s` is forbidden"), cmd)) - p.srvOutputChan <- []byte("\r\n" + fbdMsg) - p.cmdRecordChan <- &ExecutedCommand{ - Command: p.command, - Output: fbdMsg, - CreatedDate: p.cmdCreateDate, - RiskLevel: model.HighRiskFlag, - User: p.currentActiveUser} - p.command = "" - p.output = "" + fbdMsg := fmt.Sprintf(lang.T("Command `%s` is forbidden"), cmd) + p.srvOutputChan <- []byte("\r\n" + utils.WrapperWarn(fbdMsg)) + p.output = fbdMsg + p.sendCommandToChan() + p.TerminalParser.resetCommand() p.userOutputChan <- p.breakInputPacket() } -// parseCmdInput 解析命令的输入 -func (p *Parser) parseCmdInput() { - commands := p.cmdInputParser.Parse() - if len(commands) <= 0 { - p.command = "" - } else { - p.command = commands[len(commands)-1] - } - p.cmdCreateDate = time.Now() -} - -// parseCmdOutput 解析命令输出 -func (p *Parser) parseCmdOutput() { - p.output = strings.Join(p.cmdOutputParser.Parse(), "\r\n") -} - // ParseUserInput 解析用户的输入 func (p *Parser) ParseUserInput(b []byte) []byte { - p.once.Do(func() { - p.inputInitial = true - }) + if p.userInputFilter != nil { + b = p.userInputFilter(b) + } nb := p.parseInputState(b) return nb } @@ -355,13 +459,34 @@ func (p *Parser) parseZmodemState(b []byte) { // parseVimState 解析vim的状态,处于vim状态中,里面输入的命令不再记录 func (p *Parser) parseVimState(b []byte) { - if !p.inVimState && IsEditEnterMode(b) { - p.inVimState = true - logger.Debug("In vim state: true") + if !p.isEditMode && IsEditEnterMode(b) { + p.isEditMode = true + logger.Debugf("Session %s enter edit mode", p.id) + } + if p.isEditMode { + //if !p.inVimState && !p.isScreenMode { + // fmt.Println("-----------hexdump---------") + // fmt.Println(hex.Dump(b)) + //} + if !p.isScreenMode && isNewScreen(b) { + p.isScreenMode = true + p.inVimState = false + logger.Debugf("Session %s In screen state: true", p.id) + } + if !p.isScreenMode && !p.inVimState && matchMark(b, vimMarks) { + p.inVimState = true + logger.Debugf("Session %s In vim state: true", p.id) + if terminalDebug { + fmt.Println("-----------vim hexdump---------") + fmt.Println(hex.Dump(b)) + } + } } - if p.inVimState && IsEditExitMode(b) { + if p.isEditMode && IsEditExitMode(b) { + p.isEditMode = false p.inVimState = false - logger.Debug("In vim state: false") + p.isScreenMode = false + logger.Debugf("Session %s exit ( edit | vim | screen) mode", p.id) } } @@ -383,10 +508,13 @@ func (p *Parser) splitCmdStream(b []byte) []byte { p.userOutputChan <- charEnter return nil } + if !p.zmodemParser.IsStartSession() && p.zmodemParser.AbnormalFinish { + p.srvOutputChan <- []byte{0x4f, 0x4f} + } return b } else { p.parseVimState(b) - if p.inVimState || !p.inputInitial { + if p.inVimState { return b } p.parseZmodemState(b) @@ -395,10 +523,7 @@ func (p *Parser) splitCmdStream(b []byte) []byte { logger.Infof("Zmodem start session %s", p.zmodemParser.Status()) return b } - if p.inputState { - _, _ = p.cmdInputParser.WriteData(b) - } - _, _ = p.cmdOutputParser.WriteData(b) + p.TerminalParser.Feed(b) return b } @@ -410,26 +535,34 @@ func (p *Parser) ParseServerOutput(b []byte) []byte { } // IsMatchCommandRule 判断命令是不是在过滤规则中 -func (p *Parser) IsMatchCommandRule(command string) (model.SystemUserFilterRule, string, bool) { - for _, rule := range p.cmdFilterRules { - allowed, cmd := rule.Match(command) +func (p *Parser) IsMatchCommandRule(command string) (CommandRule, + string, bool) { + for i := range p.cmdFilterACLs { + rule := p.cmdFilterACLs[i] + item, allowed, cmd := rule.Match(command) switch allowed { - case model.ActionAllow: - return rule, cmd, true - case model.ActionConfirm, model.ActionDeny: - return rule, cmd, true + case model.ActionAccept, model.ActionWarning, model.ActionNotifyAndWarn: + return CommandRule{Acl: &rule, Item: &item}, cmd, true + case model.ActionReview, model.ActionReject: + return CommandRule{Acl: &rule, Item: &item}, cmd, true default: } } - return model.SystemUserFilterRule{}, "", false + return CommandRule{}, "", false +} + +type CommandRule struct { + Acl *model.CommandACL + Item *model.CommandFilterItem } func (p *Parser) waitCommandConfirm() { cmd := p.confirmStatus.Cmd - resp, err := p.jmsService.SubmitCommandConfirm(p.id, p.confirmStatus.Rule.ID, p.confirmStatus.Cmd) + rule := p.confirmStatus.Rule + resp, err := p.jmsService.SubmitCommandReview(p.id, rule.Acl.ID, p.confirmStatus.Cmd) if err != nil { logger.Errorf("Session %s: submit command confirm api err: %s", p.id, err) - p.confirmStatus.SetAction(model.ActionDeny) + p.confirmStatus.SetAction(model.ActionReject) return } lang := i18n.NewLang(p.i18nLang) @@ -437,7 +570,9 @@ func (p *Parser) waitCommandConfirm() { cancelReq := resp.CloseReq detailURL := resp.TicketDetailUrl reviewers := resp.Reviewers - msg := lang.T("Please waiting for the reviewers to confirm command `%s`, cancel by CTRL+C.") + msg := lang.T("Please waiting for the reviewers to confirm command `%s`, cancel by CTRL+C or CTRL+D.") + cmd = strings.ReplaceAll(cmd, "\r", "") + cmd = strings.ReplaceAll(cmd, "\n", "") waitMsg := fmt.Sprintf(msg, cmd) checkTimer := time.NewTicker(10 * time.Second) defer checkTimer.Stop() @@ -448,6 +583,7 @@ func (p *Parser) waitCommandConfirm() { titleMsg := lang.T("Need ticket confirm to execute command, already send email to the reviewers") reviewersMsg := fmt.Sprintf(lang.T("Ticket Reviewers: %s"), strings.Join(reviewers, ", ")) detailURLMsg := fmt.Sprintf(lang.T("Could copy website URL to notify reviewers: %s"), detailURL) + spinner := []string{". ", ".. ", "... "} var tipString strings.Builder tipString.WriteString(utils.CharNewLine) tipString.WriteString(titleMsg) @@ -456,6 +592,8 @@ func (p *Parser) waitCommandConfirm() { tipString.WriteString(utils.CharNewLine) tipString.WriteString(detailURLMsg) tipString.WriteString(utils.CharNewLine) + tipString.WriteString(waitMsg) + tipString.WriteString(utils.CharNewLine) p.srvOutputChan <- []byte(utils.WrapperString(tipString.String(), utils.Green)) for { select { @@ -465,7 +603,8 @@ func (p *Parser) waitCommandConfirm() { return default: delayS := fmt.Sprintf("%ds", delay) - data := strings.Repeat("\x08", len(delayS)+len(waitMsg)) + waitMsg + delayS + currentSpinner := spinner[delay%len(spinner)] + data := strings.Repeat("\x08", len(delayS)+len(currentSpinner)) + currentSpinner + delayS p.srvOutputChan <- []byte(data) time.Sleep(time.Second) delay += 1 @@ -498,12 +637,12 @@ func (p *Parser) waitCommandConfirm() { case model.TicketOpen: continue case model.TicketApproved: - p.confirmStatus.SetAction(model.ActionAllow) + p.confirmStatus.SetAction(model.ActionAccept) p.confirmStatus.SetProcessor(statusResp.Processor) return case model.TicketRejected, model.TicketClosed: p.confirmStatus.SetProcessor(statusResp.Processor) - p.confirmStatus.SetAction(model.ActionDeny) + p.confirmStatus.SetAction(model.ActionReject) return default: logger.Errorf("Receive unknown command confirm status %s", statusResp.Status) @@ -524,24 +663,53 @@ func (p *Parser) Close() { close(p.closed) } - _ = p.cmdOutputParser.Close() - _ = p.cmdInputParser.Close() logger.Infof("Session %s: Parser close", p.id) } func (p *Parser) sendCommandRecord() { if p.command != "" { - p.parseCmdOutput() - p.cmdRecordChan <- &ExecutedCommand{ - Command: p.command, - Output: p.output, - CreatedDate: p.cmdCreateDate, - RiskLevel: model.LessRiskFlag, - User: p.currentActiveUser, - } - p.command = "" - p.output = "" + p.output = p.TerminalParser.TryOutput() + p.sendCommandToChan() + return } + +} + +func (p *Parser) EmitCommandEvent(cmd string, outputBuf string) { + if cmd == "" { + logger.Debugf("Session %s: Command cannot be empty: %s", p.id, outputBuf) + return + } + p.command = cmd + p.output = outputBuf + p.sendCommandToChan() +} + +func (p *Parser) sendCommandToChan() { + if p.command == "" { + return + } + cmd := p.command + output := p.output + cmdFilterId := "" + cmdGroupId := "" + if rule := p.getCurrentCmdFilterRule(); rule.Acl != nil { + cmdFilterId = rule.Acl.ID + cmdGroupId = rule.Item.ID + } + p.cmdRecordChan <- &ExecutedCommand{ + Command: cmd, + Output: output, + CreatedDate: p.cmdCreateDate, + RiskLevel: p.getCurrentCmdStatusLevel(), + CmdFilterACLId: cmdFilterId, + CmdGroupId: cmdGroupId, + User: p.currentActiveUser, + } + p.setCurrentCmdStatusLevel(model.NormalLevel) + p.resetCurrentCmdFilterRule() + p.command = "" + p.output = "" } func (p *Parser) NeedRecord() bool { @@ -561,8 +729,11 @@ type ExecutedCommand struct { Command string Output string CreatedDate time.Time - RiskLevel string + RiskLevel int64 User CurrentActiveUser + + CmdFilterACLId string + CmdGroupId string } type CurrentActiveUser struct { @@ -571,6 +742,10 @@ type CurrentActiveUser struct { RemoteAddr string } +func isNewScreen(p []byte) bool { + return matchMark(p, screenMarks) +} + func IsEditEnterMode(p []byte) bool { return matchMark(p, enterMarks) } @@ -597,8 +772,13 @@ func matchMark(p []byte, marks [][]byte) bool { */ const ( - h3c = "h3c" - huawei = "huawei" + h3c = "h3c" + huawei = "huawei" + cisco = "cisco" + linux = "linux" + windows = "windows" + + mfaAuth = "mfa" ) func isH3C(p *model.Platform) bool { @@ -609,6 +789,18 @@ func isHuaWei(p *model.Platform) bool { return isPlatform(p, huawei) } +func isCisco(p *model.Platform) bool { + return isPlatform(p, cisco) +} + +func isLinux(p *model.Platform) bool { + return isPlatform(p, linux) +} + +func isWindows(p *model.Platform) bool { + return isPlatform(p, windows) +} + func isPlatform(p *model.Platform, platform string) bool { name := strings.ToLower(p.Name) os := strings.ToLower(p.BaseOs) @@ -622,7 +814,13 @@ func (p *Parser) breakInputPacket() []byte { if isHuaWei(p.platform) { return []byte{CharCTRLE, utils.CharCleanLine, '\r'} } - return []byte{tclientlib.IAC, tclientlib.BRK, '\r'} + if isCisco(p.platform) || isLinux(p.platform) { + return []byte{CharCTRLE, utils.CharCleanLine, '\r'} + } + if isH3C(p.platform) { + return []byte{CharCTRLE, CharCTRLX, '\r'} + } + return []byte{tclientlib.IAC, tclientlib.BRK, CharCTRLC, '\r'} case model.ProtocolSSH: if isH3C(p.platform) { return []byte{CharCTRLE, CharCTRLX, '\r'} diff --git a/pkg/proxy/parsercmd.go b/pkg/proxy/parsercmd.go index 060f10913..0cf1c6cc1 100644 --- a/pkg/proxy/parsercmd.go +++ b/pkg/proxy/parsercmd.go @@ -2,90 +2,519 @@ package proxy import ( "bytes" + "encoding/hex" + "fmt" + "os" + "regexp" + "runtime/debug" "strings" "sync" + "unicode" "github.com/LeeEirc/terminalparser" - "github.com/jumpserver/koko/pkg/logger" ) -func NewCmdParser(sid, name string) *CmdParser { - parser := CmdParser{id: sid, name: name} - return &parser -} +var terminalDebug = false -type CmdParser struct { - id string - name string +func init() { + if os.Getenv("TERMINALPARSER") != "" { + terminalDebug = true + } +} - buf bytes.Buffer - lock sync.Mutex +const ( + LinuxScreen = iota + 1 + UsqlScreen + MongoScreen + TmuxScreen + WindowsScreen +) - ps1 string +func DefaultEnterKeyPressHandler(p []byte) bool { + return bytes.ContainsRune(p, '\r') } -func (cp *CmdParser) WriteData(p []byte) (int, error) { - cp.lock.Lock() - defer cp.lock.Unlock() - if cp.buf.Len() >= 1024 { - return 0, nil - } - return cp.buf.Write(p) +const maxBufSize = 1024 * 100 + +const ( + InputPreState = iota + 1 + InputState + InVimState + OutputState +) + +type ScreenParser interface { + Feed([]byte) + GetCursorRow() string } -func (cp *CmdParser) Close() error { - logger.Infof("session ID: %s, ParseEngine name: %s Close", cp.id, cp.name) - return nil +type TerminalParser struct { + InputBuf bytes.Buffer + Ps1sStr string + Screen *terminalparser.Screen + state int + once sync.Once + mux sync.Mutex + + IsEnter func(p []byte) bool + cmd string + + commands []string + + EmitCommands func(cmd, out string) + + tmuxParser *terminalparser.TmuxParser + isSubMode bool + + srvOutputBuf bytes.Buffer + + screenType int + preScreenType int + //screenParser ScreenParser + + winScreenParser *terminalparser.WindowsParser + mongoScreenParser *terminalparser.MongoShParser + usqlScreenParser *terminalparser.USqlParser } -func (cp *CmdParser) removePs1(s string) string { - // 通过去除Ps1 获取完整的命令 - return strings.TrimPrefix(s, cp.ps1) +func (s *TerminalParser) SetState(state int) { + s.state = state } -// Parse 解析命令或输出 -func (cp *CmdParser) Parse() []string { - cp.lock.Lock() - defer cp.lock.Unlock() - lines := make([]string, 0, 100) - for _, line := range cp.parse(cp.buf.Bytes()) { - line = cp.removePs1(line) - if line != "" { - lines = append(lines, line) - } +func (s *TerminalParser) CheckSubScreen(b []byte) { + if !s.isSubMode && IsEditEnterMode(b) { + s.isSubMode = true + s.tmuxParser = terminalparser.NewTmuxParser() + s.screenType = TmuxScreen } - cp.buf.Reset() - return lines + if s.isSubMode && IsEditExitMode(b) { + s.isSubMode = false + s.tmuxParser = nil + s.srvOutputBuf.Reset() + s.screenType = s.preScreenType + } +} + +func (s *TerminalParser) resetCommand() { + s.cmd = "" + s.commands = nil } -func (cp *CmdParser) GetPs1() string { - cp.lock.Lock() - defer cp.lock.Unlock() - lines := cp.parse(cp.buf.Bytes()) - if len(lines) == 0 { - return "" +func (s *TerminalParser) GetCursorRow() string { + switch s.screenType { + case LinuxScreen: + row := s.Screen.GetCursorRow() + return row.String() + case UsqlScreen: + row := s.usqlScreenParser.TmuxScreen.GetCursorRow() + return row.String() + case MongoScreen: + row := s.mongoScreenParser.TmuxScreen.GetCursorRow() + return row.String() + case TmuxScreen: + row := s.tmuxParser.TmuxScreen.GetCursorRow() + return row.String() + default: + row := s.Screen.GetCursorRow() + return row.String() } - cp.ps1 = lines[len(lines)-1] - // output的最后行大概率可能是 ps1 - return cp.ps1 } -func (cp *CmdParser) SetPs1(s string) { - cp.lock.Lock() - defer cp.lock.Unlock() - cp.ps1 = s +func (s *TerminalParser) feed(p []byte) { + defer func() { + if r := recover(); r != nil { + if terminalDebug { + fmt.Printf("Recovered from panic: %s %s\n", r, string(debug.Stack())) + } + } + }() + + switch s.screenType { + case UsqlScreen: + s.usqlScreenParser.Feed(p) + case MongoScreen: + s.mongoScreenParser.Feed(p) + case TmuxScreen: + s.tmuxParser.Feed(p) + //case LinuxScreen: + // s.Screen.Feed(p) + // s.ResizeRows() + default: + // 默认就是 LinuxScreen + s.Screen.Feed(p) + s.ResizeRows() + } + if terminalDebug { + fmt.Println("---------Feed-------------") + fmt.Println(hex.Dump(p)) + fmt.Println("current row: ", s.GetCursorRow()) + fmt.Println() + } } -func (cp *CmdParser) parse(p []byte) []string { +func (s *TerminalParser) Feed(p []byte) { defer func() { if r := recover(); r != nil { - logger.Errorf("[%s] %s panic: %s\n", cp.id, cp.name, r) + if terminalDebug { + fmt.Printf("Recovered from panic: %s %s\n", r, string(debug.Stack())) + } } }() - s := terminalparser.Screen{ - Rows: make([]*terminalparser.Row, 0, 1024), - Cursor: &terminalparser.Cursor{}, + s.mux.Lock() + defer s.mux.Unlock() + // 检测是否是 tmux 和 screen 的情况 + s.CheckSubScreen(p) + + s.feed(p) + + if s.state == OutputState { + // output 且解析出 cmd 才写入 output 减少内存 + if s.srvOutputBuf.Len() < maxBufSize { + s.srvOutputBuf.Write(p) + } else { + // 长时间输出达到最大值,直接命令结算一次 + outputBuf := s.TrySrvOutput() + if s.EmitCommands != nil { + s.EmitCommands(s.cmd, outputBuf) + s.cmd = "" + return + } + } + ps1 := s.Ps1sStr + half := len(ps1) / 2 + halfPs1 := ps1[:half] + rowStr := s.GetCursorRow() + // 单行的命令解析 + if strings.HasPrefix(rowStr, halfPs1) && s.cmd != "" { + outputBuf := s.TrySrvOutput() + if s.EmitCommands != nil { + s.EmitCommands(s.cmd, outputBuf) + } + if terminalDebug { + // 从这里找上一个匹配的 ps1 row,然后这之间的 rows 就是output + fmt.Println("============= match ps1 command================") + fmt.Println("ps1: ", s.Ps1sStr) + fmt.Println("command input: ", s.cmd) + fmt.Println("command output: ", outputBuf) + fmt.Println("===============================================") + // 这个时候应该是 输入状态了,命令结束了 + } + s.cmd = "" + return + } + + // 多行命令 解析需要等完整输出,等下次输入的结果中,解析数据。参见WriteInput 里对 len(s.commands) >= 1 的处理 + } +} + +func (s *TerminalParser) OnSize() { + +} + +func (s *TerminalParser) TrySrvOutput() string { + output := s.srvOutputBuf.Bytes() + if s.tmuxParser != nil { + output = tmuxBar2Regx.ReplaceAll(output, []byte{}) + } + outputs := terminalparser.ParseOutput(output) + var str strings.Builder + ps1 := strings.TrimSpace(s.Ps1sStr) + for _, o := range outputs { + o = strings.TrimSpace(o) + o = strings.ReplaceAll(o, ps1, "") + if len(o) > 0 && str.Len() < maxBufSize { + str.WriteString(o) + str.WriteString("\n") + } + } + s.srvOutputBuf.Reset() + if s.srvOutputBuf.Cap() > maxBufSize { + s.srvOutputBuf = bytes.Buffer{} + } + return str.String() +} + +func (s *TerminalParser) TryOutput() string { + s.cmd = "" + return s.TrySrvOutput() +} + +func (s *TerminalParser) ResizeRows() { + rowsLen := len(s.Screen.Rows) + if rowsLen >= 2000 { + newRows := make([]*terminalparser.Row, 1000, 2000) + oldRows := s.Screen.Rows + oldY := s.Screen.Cursor.Y + keep := 1000 + start := rowsLen - keep + if start < 0 { + start = 0 + } + latestRows := oldRows[start:] + copy(newRows, latestRows) + s.Screen.Rows = newRows + if oldY >= len(latestRows) { + s.Screen.Cursor.Y = len(latestRows) + } + // for gc + for i := 0; i < start; i++ { + oldRows[i] = nil + } + // for gc + oldRows = nil + latestRows = nil + logger.Debugf("Resize Y: %d, row Len: %d", s.Screen.Cursor.Y, len(s.Screen.Rows)) + } +} + +func IsPrintable(s string) bool { + for _, r := range s { + switch r { + case '\t', '\n', '\r': + continue + default: + } + if !unicode.IsPrint(r) { + return false + } + } + return true +} + +func (s *TerminalParser) WriteInput(chars []byte) (string, bool) { + if len(chars) == 0 { + return "", false + } + s.mux.Lock() + defer s.mux.Unlock() + + s.once.Do(func() { + s.state = InputState + s.Ps1sStr = s.GetPs1() + }) + + // 复制粘贴多行命令执行 + s.TryMultipleCommands() + + isEnterFunc := DefaultEnterKeyPressHandler + if s.IsEnter != nil { + isEnterFunc = s.IsEnter + } + + /* + 如果是多行命令,先完全解析下 input 内容做拦截,具体的执行命令及结果,则从命令解析器里面查找内容 + */ + s.InputBuf.Write(chars) + if isEnterFunc(chars) { + inputStr := strings.TrimSpace(s.InputBuf.String()) + s.state = OutputState + //if s.isSubMode { + // cmd = s.TryTmuxInput() + //} else { + // // 针对多行命令,从最新一行,往前查找到最近一次的 ps1 之间的都是命令 + // cmd = s.TryInput() + //} + cmd := s.TryLastRowInput() + if cmd == "" && len(chars) > 1 { + //从返回值解析,cmd 为 空的情况下,当前输入的则为 + cmd = strings.TrimSpace(string(chars)) + if strings.Contains(cmd, "\r") { + // 多行命令 + s.commands = strings.Split(cmd, "\r") + } + } else { + s.cmd = cmd + suffixCmd := cmd[len(cmd)/2:] + if IsPrintable(inputStr) { + if strings.Contains(inputStr, suffixCmd) { + cmd = inputStr + } else if strings.Contains(inputStr, "\r") { + s.commands = strings.Split(inputStr, "\r") + cmd = inputStr + } + } + if s.cmd == "" && cmd != "" && len(s.commands) == 0 { + if IsPasswordPrompt(s.Ps1sStr) { + if terminalDebug { + fmt.Println("============ password Input ignore =============") + fmt.Println("ps1: ", s.Ps1sStr) + fmt.Println("inputStr:", inputStr) + } + cmd = "" + s.cmd = cmd + } + } + } + if terminalDebug { + // 从这里找上一个匹配的 ps1 row,然后这之间的 rows 就是output + fmt.Println("============= enter command================") + fmt.Println("ps1: ", s.Ps1sStr) + fmt.Println("command input1: ", cmd) + fmt.Println("command input2: ", s.cmd) + fmt.Println("commands : ", s.commands) + fmt.Println("===============================================") + // 这个时候应该是 输出状态了,命令结束了 + } + return cmd, true + } + if s.state == OutputState { + s.state = InputState + s.Ps1sStr = s.GetPs1() + } + return "", false +} + +func (s *TerminalParser) TryTmuxInput() string { + lastLine := s.tmuxParser.TmuxScreen.GetCursorRow() + cmd := strings.TrimPrefix(lastLine.String(), s.Ps1sStr) + s.InputBuf.Reset() + return strings.TrimSpace(cmd) +} + +func (s *TerminalParser) TryInput() string { + lastLine := s.Screen.GetCursorRow() + cmd := strings.TrimPrefix(lastLine.String(), s.Ps1sStr) + s.InputBuf.Reset() + return strings.TrimSpace(cmd) +} + +func (s *TerminalParser) TryLastRowInput() string { + rowStr := s.GetCursorRow() + cmd := strings.TrimPrefix(rowStr, s.Ps1sStr) + s.InputBuf.Reset() + return strings.TrimSpace(cmd) +} + +func (s *TerminalParser) GetPs1() string { + rowStr := s.GetCursorRow() + return strings.TrimSuffix(rowStr, s.InputBuf.String()) +} + +func (s *TerminalParser) FindCommands(cmds []string, startCmd string) { + // 从最后一行开始往前查询命令 + outputs := make([]string, 0, 10) + rows := s.Screen.Rows + j := len(rows) - 1 + + // 去除 startCMd的干扰 + for j > 0 { + row := rows[j] + j-- + if strings.Contains(row.String(), startCmd) { + break + } + } + ps1 := s.Ps1sStr + half := len(ps1) / 2 + halfPs1 := ps1[:half] + if terminalDebug { + fmt.Println("ps1: ", ps1, " halfPs1: ", halfPs1) + } + for i := len(cmds) - 1; i >= 0; i-- { + currentCommand := cmds[i] + if currentCommand == "" { + continue + } + for j > 0 { + row := rows[j] + rowStr := row.String() + j-- + if strings.Contains(rowStr, currentCommand) && strings.Contains(rowStr, halfPs1) { + // 匹配到 当前的命令,获取下所有的output + output := reverseString(outputs) + if s.EmitCommands != nil { + s.EmitCommands(currentCommand, output) + if terminalDebug { + fmt.Println("-----------EmitCommands----------- ") + fmt.Println("command input: ", currentCommand) + fmt.Println("command output: ", output) + } + } + outputs = make([]string, 0, 10) + break + } + outputStr := strings.TrimPrefix(rowStr, s.Ps1sStr) + if outputStr != "" { + outputs = append(outputs, outputStr) + } + } + } +} + +func (s *TerminalParser) TryMultipleCommands() { + if s.screenType != LinuxScreen { + // 仅 linux screen方式支持 + return + } + if len(s.commands) >= 1 { + commands := s.commands + + // 需要从返回的数据里,获取到当前的命令结果 + lastCommand := commands[len(commands)-1] + startCommand := lastCommand + if startCommand == "" { + startCommand = s.Ps1sStr + } else { + //排除最后一个未执行的 + commands = commands[:len(commands)-1] + } + if terminalDebug { + for i := len(commands) - 1; i >= 0; i-- { + cmd := commands[i] + fmt.Printf("may be command: `%s`\n", cmd) + } + } + s.FindCommands(commands, startCommand) + s.commands = nil + } +} + +func reverseString(rows []string) string { + var str strings.Builder + + for i := len(rows) - 1; i >= 0; i-- { + str.WriteString(rows[i]) + str.Write([]byte{'\r', '\n'}) } - return s.Parse(p) + return str.String() } + +// filtering for password input scenarios +var passwordPromptRegexps = []*regexp.Regexp{ + regexp.MustCompile(`(?i)password:?$`), // 常见的 Password: + regexp.MustCompile(`(?i)\[sudo]\s*password\s*for\s+.*:`), // [sudo] password for user: + regexp.MustCompile(`(?i)enter\s+passphrase\s+for\s+.*:`), // SSH/GPG 私钥 passphrase + regexp.MustCompile(`(?i)passphrase\s+for\s+key\s+.*:`), // git/ssh key 提示 + regexp.MustCompile(`(?i)请输入密码[::]?$`), + regexp.MustCompile(`(?i)mot de passe[::]?$`), + regexp.MustCompile(`(?i)contraseña[::]?$`), + regexp.MustCompile(`(?i)senha[::]?$`), +} + +func IsPasswordPrompt(ps1 string) bool { + ps1 = strings.TrimSpace(ps1) + for _, re := range passwordPromptRegexps { + if re.MatchString(ps1) { + return true + } + } + return false +} + +// 合并的正则表达式,匹配以下四种模式: +// 1. 隐藏光标: ESC[?25l +// 2. ANSI颜色转义序列: ESC[数字m +// 3. ANSI位置转义序列: ESC[数字;数字H +// 4. 数字开头的状态栏格式: [数字] 空格 内容 空格 内容... +// 0D 0A \r \n +var ( + tmuxBarRegx = regexp.MustCompile(`\x1b\[\?(\d+)l\x1b\[(\d+)m\x1b\[(\d+)m\x1b\[(\d+);(\d+)H\[(\d+)]\s+\d+:.+\s+.+\s+.+\s+.+\x1b\(B.*\x1b\[\?(\d+)l\x1b\[\?(\d+)h`) + // \[(\d+)]\s+\d+:.+\s+.+\s+.+\s+.+ + + // 可能包含 \r\n + //tmuxBar1Regx = regexp.MustCompile(`\r\n\[(\d+)]\s+\d+:.+\s+.+\s+.+\s+.+\x1b\(B`) + + // 不包含 \r\n + tmuxBar2Regx = regexp.MustCompile(`\[(\d+)]\s+\d+:.+\s+.+\s+.+\s+.+\x1b\(B`) +) diff --git a/pkg/proxy/parsercmd_test.go b/pkg/proxy/parsercmd_test.go index fd25f5b09..83bed7ffe 100644 --- a/pkg/proxy/parsercmd_test.go +++ b/pkg/proxy/parsercmd_test.go @@ -27,3 +27,37 @@ func TestCmdParser_Parse(t *testing.T) { t.Log("line: ", strings.Join(data, "")) } + +func TestIsTmuxStatusBar(t *testing.T) { + bar := []byte{ + 0x1b, 0x5b, 0x3f, 0x32, 0x35, 0x6c, 0x1b, 0x5b, 0x33, 0x30, 0x6d, 0x1b, 0x5b, 0x34, 0x32, 0x6d, + 0x1b, 0x5b, 0x33, 0x39, 0x3b, 0x31, 0x48, 0x5b, 0x36, 0x30, 0x5d, 0x20, 0x30, 0x3a, 0x62, 0x61, + 0x73, 0x68, 0x2a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x22, 0x6a, 0x75, 0x6d, 0x70, 0x73, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x22, 0x20, 0x30, 0x37, 0x3a, 0x33, 0x39, 0x20, 0x30, 0x38, 0x2d, 0x41, 0x75, + 0x67, 0x2d, 0x32, 0x35, 0x1b, 0x28, 0x42, 0x1b, 0x5b, 0x6d, 0x1b, 0x5b, 0x33, 0x3b, 0x32, 0x30, + 0x48, 0x1b, 0x5b, 0x3f, 0x31, 0x32, 0x6c, 0x1b, 0x5b, 0x3f, 0x32, 0x35, 0x68, + } + hasBar := tmuxBarRegx.Match(bar) + hasBar2 := tmuxBar2Regx.Match(bar) + t.Logf("hasbar: %v", hasBar) + t.Logf("hasbar2: %v", hasBar2) + +} + +func TestIsPasswordPrompt(t *testing.T) { + prompts := []string{ + "password:", + "[sudo] password for eric:", + } + for _, prompt := range prompts { + if !IsPasswordPrompt(prompt) { + t.Error(prompt) + } + + } +} diff --git a/pkg/proxy/recorder.go b/pkg/proxy/recorder.go index a7a5d0387..5e3d5c022 100644 --- a/pkg/proxy/recorder.go +++ b/pkg/proxy/recorder.go @@ -1,6 +1,7 @@ package proxy import ( + "io" "os" "path/filepath" "strings" @@ -9,11 +10,11 @@ import ( storage "github.com/jumpserver/koko/pkg/proxy/recorderstorage" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" "github.com/jumpserver/koko/pkg/asciinema" "github.com/jumpserver/koko/pkg/common" "github.com/jumpserver/koko/pkg/config" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" "github.com/jumpserver/koko/pkg/logger" ) @@ -58,8 +59,9 @@ func (c *CommandRecorder) record() { if !ok { return } - if p.RiskLevel == model.DangerLevel { + if p.RiskLevel >= model.WarningLevel && p.RiskLevel < model.ReviewAccept { notificationList = append(notificationList, p) + logger.Debugf("Session %s: command notify %d", c.sessionID, p.RiskLevel) } cmdList = append(cmdList, p) if len(cmdList) < 5 { @@ -72,12 +74,17 @@ func (c *CommandRecorder) record() { } if len(notificationList) > 0 { if err := c.jmsService.NotifyCommand(notificationList); err == nil { + logger.Debugf("Session %s: %d command notify success", c.sessionID, len(notificationList)) notificationList = notificationList[:0] } else { logger.Errorf("Session %s: command notify err: %s", c.sessionID, err) } } err := c.storage.BulkSave(cmdList) + if err != nil && c.storage.TypeName() != "server" { + logger.Warnf("Session %s: Switch default command storage save.", c.sessionID) + err = c.jmsService.PushSessionCommand(cmdList) + } if err == nil { cmdList = cmdList[:0] maxRetry = 0 @@ -138,6 +145,10 @@ func NewReplayRecord(sid string, jmsService *service.JMService, fd, err := os.Create(recorder.absFilePath) if err != nil { logger.Errorf("Create replay file %s error: %s\n", recorder.absFilePath, err) + reason := model.SessionReplayErrCreatedFailed + if _, err1 := jmsService.SessionReplayFailed(sid, reason); err1 != nil { + logger.Errorf("Session[%s] update replay status %s failed: %s", sid, reason, err1) + } recorder.err = err return recorder, err } @@ -190,6 +201,7 @@ func (r *ReplyRecorder) Record(p []byte) { func (r *ReplyRecorder) End() { if r.isNullStorage() { + r.recordLifecycleLog(model.ReplayUploadFailure, string(model.ReasonErrNullStorage)) return } _ = r.file.Close() @@ -219,22 +231,38 @@ func (r *ReplyRecorder) uploadReplay() { func (r *ReplyRecorder) UploadGzipFile(maxRetry int) { if r.isNullStorage() { _ = os.Remove(r.absGzipFilePath) + r.recordLifecycleLog(model.ReplayUploadFailure, string(model.ReasonErrNullStorage)) + return + } + absFileInfo, err := os.Stat(r.absGzipFilePath) + if err != nil { + logger.Errorf("Session %s: Replay file %s stat error: %s", r.SessionID, r.absGzipFilePath, err) return } + replaySize := absFileInfo.Size() + r.recordLifecycleLog(model.ReplayUploadStart, "") + for i := 0; i <= maxRetry; i++ { logger.Infof("Upload replay file: %s, type: %s", r.absGzipFilePath, r.storage.TypeName()) err := r.storage.Upload(r.absGzipFilePath, r.Target) if err == nil { _ = os.Remove(r.absGzipFilePath) - if err = r.jmsService.FinishReply(r.SessionID); err != nil { + if _, err = r.jmsService.FinishReplyWithSize(r.SessionID, replaySize); err != nil { logger.Errorf("Session[%s] finish replay err: %s", r.SessionID, err) } + r.recordLifecycleLog(model.ReplayUploadSuccess, "") break } + failureMsg := strings.ReplaceAll(err.Error(), ",", " ") + r.recordLifecycleLog(model.ReplayUploadFailure, failureMsg) logger.Errorf("Upload replay file err: %s", err) // 如果还是失败,上传 server 再传一次 if i == maxRetry { if r.storage.TypeName() == "server" { + reason := model.SessionReplayErrUploadFailed + if _, err1 := r.jmsService.SessionReplayFailed(r.SessionID, reason); err1 != nil { + logger.Errorf("Session[%s] update replay status %s failed: %s", r.SessionID, reason, err1) + } break } logger.Errorf("Session[%s] using server storage retry upload", r.SessionID) @@ -245,8 +273,264 @@ func (r *ReplyRecorder) UploadGzipFile(maxRetry int) { } } +func (r *ReplyRecorder) recordLifecycleLog(event model.LifecycleEvent, reason string) { + eventLog := model.SessionLifecycleLog{Reason: reason} + if err := r.jmsService.RecordSessionLifecycleLog(r.SessionID, event, eventLog); err != nil { + logger.Errorf("Update session %s activity log %s failed: %s", r.SessionID, event, err) + } +} + type ReplyInfo struct { Width int Height int TimeStamp time.Time } + +func NewFTPFileRecord(jmsService *service.JMService, storage FTPFileStorage, maxStore int64) *FTPFileRecorder { + recorder := &FTPFileRecorder{ + jmsService: jmsService, + storage: storage, + TargetPrefix: FtpTargetPrefix, + MaxFileSize: maxStore, + ftpLogMap: make(map[string]*FTPFileInfo), + } + return recorder +} + +const FtpTargetPrefix = "FTP_FILES" + +type FTPFileRecorder struct { + jmsService *service.JMService + storage FTPFileStorage + + TargetPrefix string + MaxFileSize int64 + + ftpLogMap map[string]*FTPFileInfo + + lock sync.RWMutex +} + +func (r *FTPFileRecorder) removeFTPFile(id string) { + r.lock.Lock() + defer r.lock.Unlock() + delete(r.ftpLogMap, id) +} + +func (r *FTPFileRecorder) getFTPFile(id string) *FTPFileInfo { + r.lock.RLock() + defer r.lock.RUnlock() + return r.ftpLogMap[id] +} + +func (r *FTPFileRecorder) setFTPFile(id string, info *FTPFileInfo) { + r.lock.Lock() + defer r.lock.Unlock() + r.ftpLogMap[id] = info +} + +func (r *FTPFileRecorder) CreateFTPFileInfo(logData *model.FTPLog) (info *FTPFileInfo, err error) { + info = &FTPFileInfo{ + ftpLog: logData, + + maxWrittenSize: r.MaxFileSize, + } + today := info.ftpLog.DateStart.UTC().Format(dateTimeFormat) + ftpFileRootDir := config.GetConf().FTPFileFolderPath + ftpFileDirPath := filepath.Join(ftpFileRootDir, today) + err = common.EnsureDirExist(ftpFileDirPath) + if err != nil { + logger.Errorf("Create dir %s error: %s\n", ftpFileDirPath, err) + return nil, err + } + absFilePath := filepath.Join(ftpFileDirPath, logData.ID) + storageTargetName := strings.Join([]string{FtpTargetPrefix, today, logData.ID}, "/") + info.absFilePath = absFilePath + info.Target = storageTargetName + fd, err := os.OpenFile(info.absFilePath, os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + logger.Errorf("Create FTP file %s error: %s\n", absFilePath, err) + return nil, err + } + logger.Debugf("Create or open FTP file %s", absFilePath) + info.fd = fd + r.setFTPFile(logData.ID, info) + return info, nil +} + +func (r *FTPFileRecorder) FinishFTPFile(id string) { + info := r.getFTPFile(id) + if info == nil { + return + } + _ = info.Close() + go r.UploadFile(3, id) +} + +func (r *FTPFileRecorder) Record(ftpLog *model.FTPLog, reader io.Reader) (err error) { + if r.isNullStorage() { + return + } + info := r.getFTPFile(ftpLog.ID) + if info == nil { + info, err = r.CreateFTPFileInfo(ftpLog) + } + if err != nil { + return err + } + if err1 := info.WriteFromReader(reader); err1 != nil { + logger.Errorf("FTP file %s write err: %s", ftpLog.ID, err1) + } + _ = info.Close() + go r.UploadFile(3, ftpLog.ID) + return +} + +func (r *FTPFileRecorder) ChunkedRecord(ftpLog *model.FTPLog, readerAt io.ReaderAt, offset, totalSize int64) (err error) { + if r.isNullStorage() { + return + } + info := r.getFTPFile(ftpLog.ID) + if info == nil { + info, err = r.CreateFTPFileInfo(ftpLog) + } + if err != nil { + return err + } + + if info.isExceedWrittenSize() || totalSize >= info.maxWrittenSize { + logger.Errorf("FTP file %s is exceeds the max limit and discard it", ftpLog.ID) + return nil + } + + if err1 := common.ChunkedFileTransfer(info.fd, readerAt, offset, totalSize); err1 != nil { + logger.Errorf("FTP file %s write err: %s", ftpLog.ID, err1) + } + return +} + +func (r *FTPFileRecorder) isNullStorage() bool { + return r.storage.TypeName() == "null" || r.MaxFileSize == 0 +} + +func (r *FTPFileRecorder) exceedFileMaxSize(info *FTPFileInfo) bool { + stat, err := os.Stat(info.absFilePath) + if err == nil { + if stat.Size() >= r.MaxFileSize { + return true + } + } + return false +} + +func (r *FTPFileRecorder) UploadFile(maxRetry int, ftpLogId string) { + if r.isNullStorage() { + return + } + info := r.getFTPFile(ftpLogId) + if info == nil { + logger.Errorf("FTP file %s not found", ftpLogId) + return + } + if !common.FileExists(info.absFilePath) { + logger.Infof("FTP file not found: %s", info.absFilePath) + return + } + if r.exceedFileMaxSize(info) { + logger.Info("FTP file is exceeds the upper limit for saving files, removed: ", + info.absFilePath) + _ = os.Remove(info.absFilePath) + r.removeFTPFile(info.ftpLog.ID) + return + } + logger.Infof("FTPLog %s: FTP File recorder is uploading", info.ftpLog.ID) + + for i := 0; i <= maxRetry; i++ { + logger.Infof("Upload FTP file: %s, type: %s", info.absFilePath, r.storage.TypeName()) + err := r.storage.Upload(info.absFilePath, info.Target) + if err == nil { + _ = os.Remove(info.absFilePath) + if err := r.jmsService.FinishFTPFile(info.ftpLog.ID); err != nil { + logger.Errorf("FTP file %s upload failed: %s", info.ftpLog.ID, err) + } + r.removeFTPFile(info.ftpLog.ID) + break + } + logger.Errorf("Upload FTP file err: %s", err) + // 如果还是失败,上传 server 再传一次 + if i == maxRetry { + if r.storage.TypeName() == "server" { + break + } + logger.Errorf("Session[%s] using server storage retry upload", info.ftpLog.ID) + r.storage = storage.FTPServerStorage{StorageType: "server", JmsService: r.jmsService} + r.UploadFile(3, info.ftpLog.ID) + break + } + } +} + +type FTPFileInfo struct { + ftpLog *model.FTPLog + fd *os.File + + absFilePath string + Target string + + maxWrittenSize int64 + writtenBytes int64 +} + +func (f *FTPFileInfo) WriteFromReader(r io.Reader) error { + buf := make([]byte, 32*1024) + var err error + for { + nr, er := r.Read(buf) + if nr > 0 { + nw, ew := f.fd.Write(buf[0:nr]) + if nw > 0 { + f.writtenBytes += int64(nw) + if f.isExceedWrittenSize() { + logger.Errorf("FTP file %s is exceeds the max limit and discard it", + f.ftpLog.ID) + return nil + } + } + if ew != nil { + err = ew + break + } + if nr != nw { + err = io.ErrShortWrite + break + } + } + if er != nil { + if er != io.EOF { + err = er + } + break + } + } + return err +} + +func (f *FTPFileInfo) isExceedWrittenSize() bool { + return f.writtenBytes >= f.maxWrittenSize +} + +func (f *FTPFileInfo) Close() error { + if f.fd != nil { + err := f.fd.Close() + f.fd = nil + return err + } + return nil +} + +func GetFTPFileRecorder(jmsService *service.JMService) *FTPFileRecorder { + terminalConfig, _ := jmsService.GetTerminalConfig() + maxSize := int64(terminalConfig.MaxStoreFTPFileSize) * 1024 * 1024 + recorder := NewFTPFileRecord(jmsService, NewFTPFileStorage(jmsService, &terminalConfig), maxSize) + return recorder +} diff --git a/pkg/proxy/recorderstorage/es.go b/pkg/proxy/recorderstorage/es.go index 0f9e996ae..6af57a9b7 100644 --- a/pkg/proxy/recorderstorage/es.go +++ b/pkg/proxy/recorderstorage/es.go @@ -6,12 +6,15 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "github.com/elastic/go-elasticsearch/v6" + "github.com/elastic/go-elasticsearch/v6/esapi" + elasticsearch8 "github.com/elastic/go-elasticsearch/v8" + "github.com/jumpserver-dev/sdk-go/model" "github.com/jumpserver/koko/pkg/logger" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" ) type ESCommandStorage struct { @@ -19,102 +22,175 @@ type ESCommandStorage struct { Index string DocType string + IsDataStream bool InsecureSkipVerify bool } -func (es ESCommandStorage) BulkSave(commands []*model.Command) (err error) { - var buf bytes.Buffer - transport := http.DefaultTransport.(*http.Transport).Clone() - tlsClientConfig := &tls.Config{InsecureSkipVerify: es.InsecureSkipVerify} - transport.TLSClientConfig = tlsClientConfig - esClient, err := elasticsearch.NewClient(elasticsearch.Config{ - Addresses: es.Hosts, - Transport: transport, - }) - if err != nil { - logger.Errorf("ES new client err: %s", err) - return err +func (es ESCommandStorage) BulkSave(commands []*model.Command) error { + if es.IsEs8() { + return es.BulkSaveEs8(commands) } + return es.BulkSaveEs(commands) +} + +func (es ESCommandStorage) TypeName() string { + return "es" +} + +type bulkActionResponse struct { + ID string `json:"_id"` + Result string `json:"result"` + Status int `json:"status"` + Error struct { + Type string `json:"type"` + Reason string `json:"reason"` + Cause struct { + Type string `json:"type"` + Reason string `json:"reason"` + } `json:"caused_by"` + } `json:"error"` +} + +// https://www.elastic.co/guide/en/elasticsearch/reference/master/docs-bulk.html#bulk-api-response-body +type bulkResponse struct { + Errors bool `json:"errors"` + Items []map[string]*bulkActionResponse `json:"items"` +} + +func (es ESCommandStorage) bulkActionBuffer(action string, commands []*model.Command) *bytes.Buffer { + var buf bytes.Buffer for _, item := range commands { - meta := []byte(fmt.Sprintf(`{ "index" : { } }%s`, "\n")) - data, err := json.Marshal(item) - if err != nil { - logger.Errorf("ES marshal data to json err: %s", err) - return err - } + meta := []byte(fmt.Sprintf(`{ "%s" : { } }%s`, action, "\n")) + data, _ := json.Marshal(item) data = append(data, "\n"...) buf.Write(meta) buf.Write(data) } + return &buf +} - response, err := esClient.Bulk(bytes.NewReader(buf.Bytes()), - esClient.Bulk.WithIndex(es.Index), esClient.Bulk.WithDocumentType(es.DocType)) +func (es ESCommandStorage) BulkSaveEs(commands []*model.Command) error { + action := "index" + if es.IsDataStream { + action = "create" + } + buf := es.bulkActionBuffer(action, commands) + esClient, err := es.createEsClient() + if err != nil { + logger.Errorf("ES new client err: %s", err) + return err + } + opts := make([]func(*esapi.BulkRequest), 0, 2) + opts = append(opts, esClient.Bulk.WithIndex(es.Index)) + opts = append(opts, esClient.Bulk.WithDocumentType(es.DocType)) + response, err := esClient.Bulk(buf, opts...) if err != nil { logger.Errorf("ES client bulk save err: %s", err) return err } defer response.Body.Close() + return es.handleResp(action, response.IsError(), response.Body) +} + +func (es ESCommandStorage) BulkSaveEs8(commands []*model.Command) (err error) { + action := "index" + if es.IsDataStream { + action = "create" + } + buf := es.bulkActionBuffer(action, commands) + esClient, err := es.createEs8Client() + if err != nil { + logger.Errorf("ES8 new client err: %s", err) + return err + } + response, err := esClient.Bulk(buf, esClient.Bulk.WithIndex(es.Index)) + if err != nil { + logger.Errorf("ES8 client bulk save err: %s", err) + return err + } + defer response.Body.Close() + return es.handleResp(action, response.IsError(), response.Body) +} + +func (es ESCommandStorage) handleResp(action string, isErr bool, reader io.Reader) error { var ( blk *bulkResponse raw map[string]interface{} numErrors int64 numIndexed int64 ) - if response.IsError() { - if err = json.NewDecoder(response.Body).Decode(&raw); err != nil { + if isErr { + if err := json.NewDecoder(reader).Decode(&raw); err != nil { logger.Errorf("ES failure to parse response body: %s", err) } else { - logger.Errorf("ES failure to bulk save: [%d] %s: %s", - response.StatusCode, raw["error"].(map[string]interface{})["type"], + logger.Errorf("ES failure to bulk save: %s: %s", + raw["error"].(map[string]interface{})["type"], raw["error"].(map[string]interface{})["reason"], ) } return errors.New("es failure to bulk save") } - if err = json.NewDecoder(response.Body).Decode(&blk); err != nil { + if err := json.NewDecoder(reader).Decode(&blk); err != nil { logger.Errorf("ES failure to parse response body: %s", err) } else { for _, d := range blk.Items { - if d.Index.Status > 201 { + if d[action].Status > 201 { numErrors++ logger.Errorf("ES failure to save: [%d]: %s: %s: %s: %s", - d.Index.Status, - d.Index.Error.Type, - d.Index.Error.Reason, - d.Index.Error.Cause.Type, - d.Index.Error.Cause.Reason, + d[action].Status, + d[action].Error.Type, + d[action].Error.Reason, + d[action].Error.Cause.Type, + d[action].Error.Cause.Reason, ) } else { numIndexed++ } } } - logger.Infof("ES client try bulk save %d commands: success %d failure %d", - len(commands), numIndexed, numErrors) - return + logger.Infof("ES client bulk save commands success %d failure %d", numIndexed, numErrors) + return nil } -func (es ESCommandStorage) TypeName() string { - return "es" +func (es ESCommandStorage) IsEs8() bool { + esClient, err := es.createEsClient() + if err != nil { + return false + } + resp, err1 := esClient.Info() + if err1 != nil { + return false + } + defer resp.Body.Close() + var infoResp InfoResponse + if err2 := json.NewDecoder(resp.Body).Decode(&infoResp); err2 != nil { + return false + } + return infoResp.Version.Number[0] == '8' } -// https://www.elastic.co/guide/en/elasticsearch/reference/master/docs-bulk.html#bulk-api-response-body -type bulkResponse struct { - Errors bool `json:"errors"` - Items []struct { - Index struct { - ID string `json:"_id"` - Result string `json:"result"` - Status int `json:"status"` - Error struct { - Type string `json:"type"` - Reason string `json:"reason"` - Cause struct { - Type string `json:"type"` - Reason string `json:"reason"` - } `json:"caused_by"` - } `json:"error"` - } `json:"index"` - } `json:"items"` +func (es ESCommandStorage) createEsClient() (*elasticsearch.Client, error) { + transport := http.DefaultTransport.(*http.Transport).Clone() + tlsClientConfig := &tls.Config{InsecureSkipVerify: es.InsecureSkipVerify} + transport.TLSClientConfig = tlsClientConfig + cfg := elasticsearch.Config{Addresses: es.Hosts, Transport: transport} + return elasticsearch.NewClient(cfg) +} + +func (es ESCommandStorage) createEs8Client() (*elasticsearch8.Client, error) { + transport := http.DefaultTransport.(*http.Transport).Clone() + tlsClientConfig := &tls.Config{InsecureSkipVerify: es.InsecureSkipVerify} + transport.TLSClientConfig = tlsClientConfig + cfg := elasticsearch8.Config{Addresses: es.Hosts, Transport: transport} + return elasticsearch8.NewClient(cfg) +} + +type InfoResponse struct { + Version Version `json:"version"` +} + +type Version struct { + BuildDate string `json:"build_date"` + Number string `json:"number"` } diff --git a/pkg/proxy/recorderstorage/es_test.go b/pkg/proxy/recorderstorage/es_test.go new file mode 100644 index 000000000..d84ed237f --- /dev/null +++ b/pkg/proxy/recorderstorage/es_test.go @@ -0,0 +1,32 @@ +package recorderstorage + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestEsIndexResponse(t *testing.T) { + respBodys := [][2]string{ + {"index", `{"took":24,"errors":false,"items":[{"index":{"_index":"jumpserver-test-1","_type":"_doc","_id":"mo9R9IkBIDTIizd_N0BL","_version":1,"result":"created","_shards":{"total":1,"successful":1,"failed":0},"_seq_no":3,"_primary_term":1,"status":201}},{"index":{"_index":"jumpserver-test-1","_type":"_doc","_id":"m49R9IkBIDTIizd_N0BL","_version":1,"result":"created","_shards":{"total":1,"successful":1,"failed":0},"_seq_no":4,"_primary_term":1,"status":201}}]}`}, + {"create", `{"took":36,"errors":false,"items":[{"create":{"_index":"jumpserver-test-1","_type":"_doc","_id":"mI9Q9IkBIDTIizd_5UBF","_version":1,"result":"created","_shards":{"total":1,"successful":1,"failed":0},"_seq_no":1,"_primary_term":1,"status":201}},{"create":{"_index":"jumpserver-test-1","_type":"_doc","_id":"mY9Q9IkBIDTIizd_5UBF","_version":1,"result":"created","_shards":{"total":1,"successful":1,"failed":0},"_seq_no":2,"_primary_term":1,"status":201}}]}`}, + } + + for idx := range respBodys { + action, data := respBodys[idx][0], respBodys[idx][1] + var ( + blk *bulkResponse + body bytes.Buffer = *bytes.NewBufferString(data) + ) + + if err := json.NewDecoder(&body).Decode(&blk); err != nil { + t.Fatalf("ES failure to parse response body: %s", err) + } else { + for _, d := range blk.Items { + if _, ok := d[action]; !ok { + t.Fatalf("can not get action response from es bulk response body %d", idx) + } + } + } + } +} diff --git a/pkg/proxy/recorderstorage/influxdb.go b/pkg/proxy/recorderstorage/influxdb.go new file mode 100644 index 000000000..4a9f18c46 --- /dev/null +++ b/pkg/proxy/recorderstorage/influxdb.go @@ -0,0 +1,57 @@ +package recorderstorage + +import ( + "context" + "encoding/json" + "strconv" + + influxdb2 "github.com/influxdata/influxdb-client-go/v2" + + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver/koko/pkg/logger" +) + +type InfluxdbStorage struct { + ServerURL string + AuthToken string + Bucket string + Measurement string +} + +func NewInfluxdbClient(serverURL, authToken string) influxdb2.Client { + return influxdb2.NewClient(serverURL, authToken) +} + +func (influx InfluxdbStorage) BulkSave(commands []*model.Command) (err error) { + client := NewInfluxdbClient(influx.ServerURL, influx.AuthToken) + defer client.Close() + for _, item := range commands { + tags := map[string]string{ + "session": item.SessionID, + "orgId": item.OrgID, + "input": item.Input, + "output": item.Output, + "account": item.Account, + "user": item.User, + "asset": item.Server, + "riskLevel": strconv.Itoa(int(item.RiskLevel)), + "timeStamp": strconv.FormatInt(item.Timestamp, 10), + "dateCreated": item.DateCreated.String(), + } + itemBytes, _ := json.Marshal(item) + field := map[string]interface{}{ + "value": string(itemBytes), + } + writeApi := client.WriteAPIBlocking("", influx.Bucket) + p := influxdb2.NewPoint(influx.Measurement, tags, field, item.DateCreated) + if err1 := writeApi.WritePoint(context.Background(), p); err1 != nil { + logger.Errorf("Influxdb write point err: %s", err1) + return err1 + } + } + return +} + +func (influx InfluxdbStorage) TypeName() string { + return "influxdb" +} diff --git a/pkg/proxy/recorderstorage/null.go b/pkg/proxy/recorderstorage/null.go index ce4a090eb..b442a2913 100644 --- a/pkg/proxy/recorderstorage/null.go +++ b/pkg/proxy/recorderstorage/null.go @@ -1,8 +1,8 @@ package recorderstorage import ( + "github.com/jumpserver-dev/sdk-go/model" "github.com/jumpserver/koko/pkg/logger" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" ) func NewNullStorage() (storage NullStorage) { diff --git a/pkg/proxy/recorderstorage/oss.go b/pkg/proxy/recorderstorage/oss.go index 661cb929e..964b976ed 100644 --- a/pkg/proxy/recorderstorage/oss.go +++ b/pkg/proxy/recorderstorage/oss.go @@ -1,6 +1,7 @@ package recorderstorage import ( + "errors" "github.com/aliyun/aliyun-oss-go-sdk/oss" "github.com/jumpserver/koko/pkg/logger" @@ -14,6 +15,11 @@ type OSSReplayStorage struct { } func (o OSSReplayStorage) Upload(gZipFilePath, target string) (err error) { + // 创建OSSClient实例。如果 endpoint 是空,会造成 panic,所以需要检查一下 + if o.Endpoint == "" { + logger.Error("OSS endpoint is empty") + return ErrEmptyEndpoint + } client, err := oss.New(o.Endpoint, o.AccessKey, o.SecretKey) if err != nil { return @@ -21,7 +27,7 @@ func (o OSSReplayStorage) Upload(gZipFilePath, target string) (err error) { bucket, err := client.Bucket(o.Bucket) if err != nil { logger.Error(err.Error()) - return + return err } return bucket.PutObjectFromFile(target, gZipFilePath) } @@ -29,3 +35,5 @@ func (o OSSReplayStorage) Upload(gZipFilePath, target string) (err error) { func (o OSSReplayStorage) TypeName() string { return "oss" } + +var ErrEmptyEndpoint = errors.New("oss endpoint is empty") diff --git a/pkg/proxy/recorderstorage/s3.go b/pkg/proxy/recorderstorage/s3.go index 55015188a..0c58b13e9 100644 --- a/pkg/proxy/recorderstorage/s3.go +++ b/pkg/proxy/recorderstorage/s3.go @@ -28,12 +28,13 @@ func (s S3ReplayStorage) Upload(gZipFilePath, target string) (err error) { } defer file.Close() s3Config := &aws.Config{ - Credentials: credentials.NewStaticCredentials(s.AccessKey, s.SecretKey, ""), Endpoint: aws.String(s.Endpoint), Region: aws.String(s.Region), S3ForcePathStyle: aws.Bool(true), } - + if s.AccessKey != "" && s.SecretKey != "" { + s3Config.Credentials = credentials.NewStaticCredentials(s.AccessKey, s.SecretKey, "") + } sess, err := session.NewSession(s3Config) if err != nil { logger.Errorf("S3 new session failed: %s", err) @@ -58,4 +59,4 @@ func (s S3ReplayStorage) Upload(gZipFilePath, target string) (err error) { func (s S3ReplayStorage) TypeName() string { return "s3" -} \ No newline at end of file +} diff --git a/pkg/proxy/recorderstorage/server.go b/pkg/proxy/recorderstorage/server.go index 14f7e3f26..863c63b2b 100644 --- a/pkg/proxy/recorderstorage/server.go +++ b/pkg/proxy/recorderstorage/server.go @@ -4,8 +4,8 @@ import ( "path/filepath" "strings" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" ) type ServerStorage struct { @@ -13,6 +13,20 @@ type ServerStorage struct { JmsService *service.JMService } +type FTPServerStorage struct { + StorageType string + JmsService *service.JMService +} + +func (s FTPServerStorage) Upload(filePath, target string) (err error) { + id := strings.Split(filepath.Base(filePath), ".")[0] + return s.JmsService.UploadFTPFile(id, filePath) +} + +func (s FTPServerStorage) TypeName() string { + return s.StorageType +} + func (s ServerStorage) BulkSave(commands []*model.Command) (err error) { return s.JmsService.PushSessionCommand(commands) } diff --git a/pkg/proxy/server.go b/pkg/proxy/server.go index a18cda1ea..b669f7d40 100644 --- a/pkg/proxy/server.go +++ b/pkg/proxy/server.go @@ -13,162 +13,22 @@ import ( "sync/atomic" "time" - "github.com/jumpserver/koko/pkg/auth" - "github.com/jumpserver/koko/pkg/zmodem" gossh "golang.org/x/crypto/ssh" + "golang.org/x/term" + + "github.com/jumpserver-dev/sdk-go/common" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" - "github.com/jumpserver/koko/pkg/common" "github.com/jumpserver/koko/pkg/config" - "github.com/jumpserver/koko/pkg/i18n" - modelCommon "github.com/jumpserver/koko/pkg/jms-sdk-go/common" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" + "github.com/jumpserver/koko/pkg/exchange" "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/session" "github.com/jumpserver/koko/pkg/srvconn" "github.com/jumpserver/koko/pkg/utils" + "github.com/jumpserver/koko/pkg/zmodem" ) -type ConnectionOption func(options *ConnectionOptions) - -type ConnectionOptions struct { - ProtocolType string - i18nLang string - - user *model.User - systemUser *model.SystemUser - - asset *model.Asset - - app *model.Application - - k8sContainer *ContainerInfo - - params *ConnectionParams -} - -type ConnectionParams struct { - DisableMySQLAutoHash bool -} - -type ContainerInfo struct { - Namespace string - PodName string - Container string -} - -func (c *ContainerInfo) String() string { - return fmt.Sprintf("%s_%s_%s", c.Namespace, c.PodName, c.Container) -} - -func (c *ContainerInfo) K8sName(name string) string { - k8sName := fmt.Sprintf("%s(%s)", name, c.String()) - if len([]rune(k8sName)) <= 128 { - return k8sName - } - containerName := []rune(c.String()) - nameRune := []rune(name) - remainLen := 128 - len(nameRune) - 2 - 3 - indexLen := remainLen / 2 - startIndex := len(containerName) - indexLen - startPart := string(containerName[:indexLen]) - endPart := string(containerName[startIndex:]) - return fmt.Sprintf("%s(%s...%s)", name, startPart, endPart) -} - -func ConnectUser(user *model.User) ConnectionOption { - return func(opts *ConnectionOptions) { - opts.user = user - } -} - -func ConnectProtocolType(protocol string) ConnectionOption { - return func(opts *ConnectionOptions) { - opts.ProtocolType = protocol - } -} - -func ConnectSystemUser(systemUser *model.SystemUser) ConnectionOption { - return func(opts *ConnectionOptions) { - opts.systemUser = systemUser - } -} - -func ConnectAsset(asset *model.Asset) ConnectionOption { - return func(opts *ConnectionOptions) { - opts.asset = asset - } -} - -func ConnectApp(app *model.Application) ConnectionOption { - return func(opts *ConnectionOptions) { - opts.app = app - } -} - -func ConnectContainer(info *ContainerInfo) ConnectionOption { - return func(opts *ConnectionOptions) { - opts.k8sContainer = info - } -} -func ConnectParams(params *ConnectionParams) ConnectionOption { - return func(opts *ConnectionOptions) { - opts.params = params - } -} - -func ConnectI18nLang(lang string) ConnectionOption { - return func(opts *ConnectionOptions) { - opts.i18nLang = lang - } -} - -func (opts *ConnectionOptions) TerminalTitle() string { - title := "" - switch opts.ProtocolType { - case srvconn.ProtocolTELNET, - srvconn.ProtocolSSH: - title = fmt.Sprintf("%s://%s@%s", - opts.ProtocolType, - opts.systemUser.Username, - opts.asset.IP) - case srvconn.ProtocolMySQL, srvconn.ProtocolMariadb, srvconn.ProtocolSQLServer, - srvconn.ProtocolRedis, srvconn.ProtocolMongoDB: - title = fmt.Sprintf("%s://%s@%s", - opts.ProtocolType, - opts.systemUser.Username, - opts.app.Attrs.Host) - case srvconn.ProtocolK8s: - title = fmt.Sprintf("%s+%s", - opts.ProtocolType, - opts.app.Attrs.Cluster) - } - return title -} - -func (opts *ConnectionOptions) ConnectMsg() string { - lang := opts.getLang() - msg := "" - switch opts.ProtocolType { - case srvconn.ProtocolTELNET, - srvconn.ProtocolSSH: - msg = fmt.Sprintf(lang.T("Connecting to %s@%s"), opts.systemUser.Name, opts.asset.IP) - case srvconn.ProtocolMySQL, srvconn.ProtocolMariadb, srvconn.ProtocolSQLServer, - srvconn.ProtocolRedis, srvconn.ProtocolMongoDB: - msg = fmt.Sprintf(lang.T("Connecting to Database %s"), opts.app) - case srvconn.ProtocolK8s: - msg = fmt.Sprintf(lang.T("Connecting to Kubernetes %s"), opts.app.Attrs.Cluster) - if opts.k8sContainer != nil { - msg = fmt.Sprintf(lang.T("Connecting to Kubernetes %s container %s"), - opts.app.Name, opts.k8sContainer.Container) - } - } - return msg -} - -func (opts *ConnectionOptions) getLang() i18n.LanguageCode { - return i18n.NewLang(opts.i18nLang) -} - var ( ErrMissClient = errors.New("the protocol client has not installed") ErrUnMatchProtocol = errors.New("the protocols are not matched") @@ -177,18 +37,7 @@ var ( ErrNoAuthInfo = errors.New("no auth info") ) -/* - 简单校验: - 协议是否支持 - 资产协议是否匹配 - - API 相关 - 1. 获取 系统用户 的 Auth info--> 获取认证信息 - 2. 获取 授权权限---> 校验权限 - 3. 获取需要的domain---> 网关信息 - 4. 获取需要的过滤规则---> 获取命令过滤 - 5. 获取当前的终端配置,(录像和命令存储配置) -*/ +const localIP = "127.0.0.1" func NewServer(conn UserConnection, jmsService *service.JMService, opts ...ConnectionOption) (*Server, error) { connOpts := &ConnectionOptions{} @@ -196,166 +45,60 @@ func NewServer(conn UserConnection, jmsService *service.JMService, opts ...Conne setter(connOpts) } lang := connOpts.getLang() - - if err := srvconn.IsSupportedProtocol(connOpts.ProtocolType); err != nil { + protocol := connOpts.authInfo.Protocol + asset := connOpts.authInfo.Asset + account := connOpts.authInfo.Account + user := connOpts.authInfo.User + if err := srvconn.IsSupportedProtocol(protocol); err != nil { logger.Errorf("Conn[%s] checking protocol %s failed: %s", conn.ID(), - connOpts.ProtocolType, err) + protocol, err) var errMsg string switch { - case errors.Is(err, srvconn.ErrMySQLClient), errors.Is(err, srvconn.ErrSQLServerClient), - errors.Is(err, srvconn.ErrRedisClient), errors.Is(err, srvconn.ErrMongoDBClient), - errors.Is(err, srvconn.ErrKubectlClient): + case errors.As(err, &srvconn.ErrNoClient{}): errMsg = lang.T("%s protocol client not installed.") - errMsg = fmt.Sprintf(errMsg, connOpts.ProtocolType) + errMsg = fmt.Sprintf(errMsg, protocol) err = fmt.Errorf("%w: %s", ErrMissClient, err) default: - errMsg = lang.T("Terminal does not support protocol %s, please use web terminal to access") - errMsg = fmt.Sprintf(errMsg, connOpts.ProtocolType) + errMsg = lang.T("HandleTask does not support protocol %s, please use web terminal to access") + errMsg = fmt.Sprintf(errMsg, protocol) err = fmt.Errorf("%w: %s", ErrUnMatchProtocol, err) } utils.IgnoreErrWriteString(conn, utils.WrapperWarn(errMsg)) return nil, err } - - terminalConf, err := jmsService.GetTerminalConfig() - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrAPIFailed, err) - } - userId := connOpts.user.ID - sysId := connOpts.systemUser.ID - var ( - assetId string - appId string - ) - if connOpts.asset != nil { - assetId = connOpts.asset.ID - } - if connOpts.app != nil { - appId = connOpts.app.ID + if !asset.IsSupportProtocol(protocol) { + msg := lang.T("Account <%s> and asset <%s> protocol are inconsistent.") + msg = fmt.Sprintf(msg, account.Username, asset.Address) + utils.IgnoreErrWriteString(conn, utils.WrapperWarn(msg)) + return nil, fmt.Errorf("%w: %s", ErrUnMatchProtocol, msg) } - - filterRules, err := jmsService.GetCommandFilterRules(userId, sysId, assetId, appId) + terminalConf, err := jmsService.GetTerminalConfig() if err != nil { return nil, fmt.Errorf("%w: %s", ErrAPIFailed, err) } - // 过滤规则排序 - sort.Sort(model.FilterRules(filterRules)) - var ( - apiSession *model.Session - - sysUserAuthInfo *model.SystemUserAuthInfo - suSysUserAuthInfo *model.SystemUserAuthInfo - domainGateways *model.Domain - platform *model.Platform - perms *model.Permission - - checkConnectPermFunc func() (model.ExpireInfo, error) - ) - - switch connOpts.ProtocolType { - case srvconn.ProtocolMySQL, srvconn.ProtocolMariadb, srvconn.ProtocolSQLServer, - srvconn.ProtocolRedis, srvconn.ProtocolMongoDB, srvconn.ProtocolK8s: - authInfo, err := jmsService.GetUserApplicationAuthInfo(connOpts.systemUser.ID, connOpts.app.ID, - connOpts.user.ID, connOpts.user.Username) - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrAPIFailed, err) - } - sysUserAuthInfo = &authInfo - if connOpts.app.Domain != "" { - domain, err := jmsService.GetDomainGateways(connOpts.app.Domain) - if err != nil { - return nil, err - } - domainGateways = &domain - } - checkConnectPermFunc = func() (model.ExpireInfo, error) { - return jmsService.ValidateApplicationPermission(connOpts.user.ID, - connOpts.app.ID, connOpts.systemUser.ID) - } - assetName := connOpts.app.Name - if connOpts.k8sContainer != nil { - assetName = connOpts.k8sContainer.K8sName(assetName) - } - apiSession = &model.Session{ - ID: common.UUID(), - User: connOpts.user.String(), - SystemUser: sysUserAuthInfo.String(), - LoginFrom: conn.LoginFrom(), - RemoteAddr: conn.RemoteAddr(), - Protocol: connOpts.systemUser.Protocol, - UserID: connOpts.user.ID, - SystemUserID: connOpts.systemUser.ID, - Asset: assetName, - AssetID: connOpts.app.ID, - OrgID: connOpts.app.OrgID, - } - default: - if !connOpts.asset.IsSupportProtocol(connOpts.systemUser.Protocol) { - msg := lang.T("System user <%s> and asset <%s> protocol are inconsistent.") - msg = fmt.Sprintf(msg, connOpts.systemUser.Username, connOpts.asset.Hostname) - utils.IgnoreErrWriteString(conn, utils.WrapperWarn(msg)) - return nil, fmt.Errorf("%w: %s", ErrUnMatchProtocol, msg) - } - - authInfo, err := jmsService.GetSystemUserAuthById(connOpts.systemUser.ID, connOpts.asset.ID, - connOpts.user.ID, connOpts.user.Username) - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrAPIFailed, err) - } - sysUserAuthInfo = &authInfo - if connOpts.systemUser.SuEnabled { - suSystemUserId := connOpts.systemUser.SuFrom - assetId := connOpts.asset.ID - suAuthInfo, err := jmsService.GetSystemUserAuthById(suSystemUserId, assetId, - connOpts.user.ID, connOpts.user.Username) - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrAPIFailed, err) - } - suSysUserAuthInfo = &suAuthInfo - } - if connOpts.asset.Domain != "" { - domain, err := jmsService.GetDomainGateways(connOpts.asset.Domain) - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrAPIFailed, err) - } - domainGateways = &domain - } - checkConnectPermFunc = func() (model.ExpireInfo, error) { - return jmsService.ValidateAssetConnectPermission(connOpts.user.ID, - connOpts.asset.ID, connOpts.systemUser.ID) - } - assetPlatform, err2 := jmsService.GetAssetPlatform(connOpts.asset.ID) - if err2 != nil { - return nil, fmt.Errorf("%w: %s", ErrAPIFailed, err2) - } - // 获取权限校验 - permission, err3 := jmsService.GetPermission(connOpts.user.ID, connOpts.asset.ID, connOpts.systemUser.ID) - if err3 != nil { - return nil, fmt.Errorf("%w: %s", ErrAPIFailed, err3) - } - perms = &permission - platform = &assetPlatform - apiSession = &model.Session{ - ID: common.UUID(), - User: connOpts.user.String(), - SystemUser: sysUserAuthInfo.String(), - LoginFrom: conn.LoginFrom(), - RemoteAddr: conn.RemoteAddr(), - Protocol: connOpts.systemUser.Protocol, - UserID: connOpts.user.ID, - SystemUserID: connOpts.systemUser.ID, - Asset: connOpts.asset.String(), - AssetID: connOpts.asset.ID, - OrgID: connOpts.asset.OrgID, - } - } - - expireInfo, err := checkConnectPermFunc() - if err != nil { - logger.Error(err) - } - - if !expireInfo.HasPermission { + assetName := asset.String() + if connOpts.k8sContainer != nil { + assetName = connOpts.k8sContainer.K8sName(asset.Name) + } + + apiSession := &model.Session{ + ID: common.UUID(), + User: user.String(), + Account: account.String(), + LoginFrom: model.LabelField(conn.LoginFrom()), + RemoteAddr: conn.RemoteAddr(), + Protocol: protocol, + UserID: user.ID, + Asset: assetName, + AssetID: asset.ID, + AccountID: account.ID, + OrgID: connOpts.authInfo.OrgId, + Type: model.NORMALType, + TokenId: connOpts.authInfo.Id, + LangCode: connOpts.i18nLang, + } + + if !connOpts.authInfo.Actions.EnableConnect() { msg := lang.T("You don't have permission login %s") msg = utils.WrapperWarn(fmt.Sprintf(msg, connOpts.TerminalTitle())) utils.IgnoreErrWriteString(conn, msg) @@ -363,34 +106,27 @@ func NewServer(conn UserConnection, jmsService *service.JMService, opts ...Conne } return &Server{ - ID: apiSession.ID, - UserConn: conn, - jmsService: jmsService, - - connOpts: connOpts, - systemUserAuthInfo: sysUserAuthInfo, - - suFromSystemUserAuthInfo: suSysUserAuthInfo, - - filterRules: filterRules, - terminalConf: &terminalConf, - domainGateways: domainGateways, - expireInfo: &expireInfo, - platform: platform, - permActions: perms, - sessionInfo: apiSession, + ID: apiSession.ID, + UserConn: conn, + jmsService: jmsService, + connOpts: connOpts, + account: &account, + suFromAccount: account.SuFrom, + terminalConf: &terminalConf, + gateway: connOpts.authInfo.Gateway, + sessionInfo: apiSession, CreateSessionCallback: func() error { - apiSession.DateStart = modelCommon.NewNowUTCTime() - return jmsService.CreateSession(*apiSession) - }, - ConnectedSuccessCallback: func() error { - return jmsService.SessionSuccess(apiSession.ID) + apiSession.DateStart = common.NewNowUTCTime() + _, err2 := jmsService.CreateSession(*apiSession) + return err2 }, ConnectedFailedCallback: func(err error) error { - return jmsService.SessionFailed(apiSession.ID, err) + _, err1 := jmsService.SessionFailed(apiSession.ID, err) + return err1 }, DisConnectedCallback: func() error { - return jmsService.SessionDisconnect(apiSession.ID) + _, err2 := jmsService.SessionDisconnect(apiSession.ID) + return err2 }, }, nil } @@ -402,31 +138,36 @@ type Server struct { connOpts *ConnectionOptions - systemUserAuthInfo *model.SystemUserAuthInfo + account *model.Account - suFromSystemUserAuthInfo *model.SystemUserAuthInfo + suFromAccount *model.BaseAccount - filterRules []model.SystemUserFilterRule - terminalConf *model.TerminalConfig - domainGateways *model.Domain - expireInfo *model.ExpireInfo - platform *model.Platform - permActions *model.Permission + terminalConf *model.TerminalConfig + gateway *model.Gateway sessionInfo *model.Session cacheSSHConnection *srvconn.SSHConnection - CreateSessionCallback func() error - ConnectedSuccessCallback func() error - ConnectedFailedCallback func(err error) error - DisConnectedCallback func() error + CreateSessionCallback func() error + ConnectedFailedCallback func(err error) error + DisConnectedCallback func() error keyboardMode int32 - OnSessionInfo func(info *model.Session) + OnSessionInfo func(info *SessionInfo) - loginTicketId string + BroadcastEvent func(event *exchange.RoomMessage) +} + +type SessionInfo struct { + Session *model.Session `json:"session"` + Perms *model.Permission `json:"permission"` + + BackspaceAsCtrlH *bool `json:"backspaceAsCtrlH,omitempty"` + CtrlCAsCtrlZ bool `json:"ctrlCAsCtrlZ"` + + ThemeName string `json:"themeName"` } func (s *Server) IsKeyboardMode() bool { @@ -442,11 +183,14 @@ func (s *Server) resetKeyboardMode() { } func (s *Server) CheckPermissionExpired(now time.Time) bool { - return s.expireInfo.ExpireAt < now.Unix() + return s.connOpts.authInfo.ExpireAt.IsExpired(now) } func (s *Server) ZmodemFileTransferEvent(zinfo *zmodem.ZFileInfo, status bool) { - switch s.connOpts.ProtocolType { + protocol := s.connOpts.authInfo.Protocol + asset := s.connOpts.authInfo.Asset + user := s.connOpts.authInfo.User + switch protocol { case srvconn.ProtocolTELNET, srvconn.ProtocolSSH: operate := model.OperateDownload switch zinfo.Type() { @@ -456,15 +200,17 @@ func (s *Server) ZmodemFileTransferEvent(zinfo *zmodem.ZFileInfo, status bool) { operate = model.OperateDownload } item := model.FTPLog{ - OrgID: s.connOpts.asset.OrgID, - User: s.connOpts.user.String(), - Hostname: s.connOpts.asset.String(), - SystemUser: s.systemUserAuthInfo.String(), + ID: common.UUID(), + OrgID: asset.OrgID, + User: user.String(), + Asset: asset.String(), + Account: s.account.String(), RemoteAddr: s.UserConn.RemoteAddr(), Operate: operate, Path: zinfo.Filename(), - DateStart: modelCommon.NewUTCTime(zinfo.Time()), + DateStart: common.NewUTCTime(zinfo.Time()), IsSuccess: status, + Session: s.sessionInfo.ID, } if err := s.jmsService.CreateFileOperationLog(item); err != nil { logger.Errorf("Create zmodem ftp log err: %s", err) @@ -477,28 +223,33 @@ func (s *Server) GetFilterParser() *Parser { enableUpload bool enableDownload bool ) - if s.permActions != nil { - if s.permActions.EnableDownload() { - enableDownload = true - } - if s.permActions.EnableUpload() { - enableUpload = true - } + actions := s.connOpts.authInfo.Actions + if actions.EnableDownload() { + enableDownload = true + } + if actions.EnableUpload() { + enableUpload = true } zParser := zmodem.New() zParser.FileEventCallback = s.ZmodemFileTransferEvent + protocol := s.connOpts.authInfo.Protocol + filterRules := s.connOpts.authInfo.CommandFilterACLs + platform := s.connOpts.authInfo.Platform + // 过滤规则排序 + sort.Sort(model.CommandACLs(filterRules)) + pty := s.UserConn.Pty() parser := Parser{ id: s.ID, - protocolType: s.connOpts.ProtocolType, + protocolType: protocol, jmsService: s.jmsService, - cmdFilterRules: s.filterRules, + cmdFilterACLs: filterRules, enableDownload: enableDownload, enableUpload: enableUpload, zmodemParser: zParser, i18nLang: s.connOpts.i18nLang, - platform: s.platform, + platform: &platform, } - parser.initial() + parser.initial(pty.Window.Width, pty.Window.Height) return &parser } @@ -530,75 +281,46 @@ func (s *Server) GetCommandRecorder() *CommandRecorder { return &cmdR } -func (s *Server) GenerateCommandItem(user, input, output string, - riskLevel int64, createdDate time.Time) *model.Command { - var ( - server string - orgID string - ) - switch s.connOpts.ProtocolType { - case srvconn.ProtocolTELNET, srvconn.ProtocolSSH: - server = s.connOpts.asset.String() - orgID = s.connOpts.asset.OrgID - - case srvconn.ProtocolMySQL, srvconn.ProtocolMariadb, srvconn.ProtocolSQLServer, - srvconn.ProtocolRedis, srvconn.ProtocolMongoDB, srvconn.ProtocolK8s: - server = s.connOpts.app.Name +func (s *Server) GenerateCommandItem(user, input, output string, item *ExecutedCommand) *model.Command { + asset := s.connOpts.authInfo.Asset + protocol := s.connOpts.authInfo.Protocol + server := asset.String() + switch protocol { + case srvconn.ProtocolK8s: + server = asset.Name if s.connOpts.k8sContainer != nil { server = s.connOpts.k8sContainer.K8sName(server) } - orgID = s.connOpts.app.OrgID } + createdDate := item.CreatedDate return &model.Command{ SessionID: s.ID, - OrgID: orgID, + OrgID: asset.OrgID, Server: server, User: user, - SystemUser: s.systemUserAuthInfo.String(), + Account: s.account.String(), Input: input, Output: output, Timestamp: createdDate.Unix(), - RiskLevel: riskLevel, + RiskLevel: item.RiskLevel, DateCreated: createdDate.UTC(), - } -} -func (s *Server) getUsernameIfNeed() (err error) { - if s.systemUserAuthInfo.Username == "" { - logger.Infof("Conn[%s] need manuel input system user username", s.UserConn.ID()) - var username string - term := utils.NewTerminal(s.UserConn, "username: ") - for { - username, err = term.ReadLine() - if err != nil { - return err - } - username = strings.TrimSpace(username) - if username != "" { - break - } - } - s.systemUserAuthInfo.Username = username - logger.Infof("Conn[%s] get username from user input: %s", s.UserConn.ID(), username) + CmdFilterAclId: item.CmdFilterACLId, + CmdGroupId: item.CmdGroupId, } - return } func (s *Server) getAuthPasswordIfNeed() (err error) { var line string - if s.systemUserAuthInfo.Password == "" { - term := utils.NewTerminal(s.UserConn, "password: ") - if s.systemUserAuthInfo.Username != "" { - line, err = term.ReadPassword(fmt.Sprintf("%s's password: ", s.systemUserAuthInfo.Username)) - } else { - line, err = term.ReadPassword("password: ") - } + if s.account.Secret == "" { + vt := term.NewTerminal(s.UserConn, "password: ") + line, err = vt.ReadPassword(fmt.Sprintf("%s's password: ", s.account.String())) if err != nil { logger.Errorf("Conn[%s] get password from user err: %s", s.UserConn.ID(), err.Error()) return err } - s.systemUserAuthInfo.Password = line + s.account.Secret = line logger.Infof("Conn[%s] get password from user input", s.UserConn.ID()) } return nil @@ -606,47 +328,38 @@ func (s *Server) getAuthPasswordIfNeed() (err error) { func (s *Server) checkRequiredAuth() error { lang := s.connOpts.getLang() - switch s.connOpts.ProtocolType { + protocol := s.connOpts.authInfo.Protocol + asset := s.connOpts.authInfo.Asset + loginAccount := s.account + switch protocol { case srvconn.ProtocolK8s: - if s.systemUserAuthInfo.Token == "" { + if s.account.Secret == "" { msg := utils.WrapperWarn(lang.T("You get auth token failed")) utils.IgnoreErrWriteString(s.UserConn, msg) return errors.New("no auth token") } - case srvconn.ProtocolMySQL, srvconn.ProtocolMariadb, srvconn.ProtocolTELNET, - srvconn.ProtocolSQLServer, srvconn.ProtocolMongoDB: - if err := s.getUsernameIfNeed(); err != nil { - msg := utils.WrapperWarn(lang.T("Get auth username failed")) - utils.IgnoreErrWriteString(s.UserConn, msg) - return fmt.Errorf("get auth username failed: %s", err) - } - if err := s.getAuthPasswordIfNeed(); err != nil { - msg := utils.WrapperWarn(lang.T("Get auth password failed")) - utils.IgnoreErrWriteString(s.UserConn, msg) - return fmt.Errorf("get auth password failed: %s", err) - } - case srvconn.ProtocolRedis: + case srvconn.ProtocolTELNET, srvconn.ProtocolClickHouse, + srvconn.ProtocolMongoDB, + + srvconn.ProtocolMySQL, srvconn.ProtocolMariadb, + srvconn.ProtocolSQLServer, srvconn.ProtocolPostgresql, + srvconn.ProtocolRedis, srvconn.ProtocolOracle: if err := s.getAuthPasswordIfNeed(); err != nil { msg := utils.WrapperWarn(lang.T("Get auth password failed")) utils.IgnoreErrWriteString(s.UserConn, msg) return fmt.Errorf("get auth password failed: %s", err) } case srvconn.ProtocolSSH: - if err := s.getUsernameIfNeed(); err != nil { - msg := utils.WrapperWarn(lang.T("Get auth username failed")) - utils.IgnoreErrWriteString(s.UserConn, msg) - return err - } if s.checkReuseSSHClient() { if cacheConn, ok := s.getCacheSSHConn(); ok { s.cacheSSHConnection = cacheConn return nil } logger.Debugf("Conn[%s] did not found cache ssh client(%s@%s)", - s.UserConn.ID(), s.connOpts.systemUser.Name, s.connOpts.asset.Hostname) + s.UserConn.ID(), loginAccount.Name, asset.Name) } - if s.systemUserAuthInfo.PrivateKey == "" { + if s.account.Secret == "" { if err := s.getAuthPasswordIfNeed(); err != nil { msg := utils.WrapperWarn(lang.T("Get auth password failed")) utils.IgnoreErrWriteString(s.UserConn, msg) @@ -665,9 +378,11 @@ const ( func (s *Server) checkReuseSSHClient() bool { if config.GetConf().ReuseConnection { - platformMatched := s.connOpts.asset.Platform == linuxPlatform - protocolMatched := s.connOpts.systemUser.Protocol == model.ProtocolSSH - notSuSystemUser := !s.connOpts.systemUser.SuEnabled + platform := s.connOpts.authInfo.Platform + protocol := s.connOpts.authInfo.Protocol + platformMatched := strings.EqualFold(platform.Type.Value, linuxPlatform) + protocolMatched := protocol == model.ProtocolSSH + notSuSystemUser := s.suFromAccount == nil return platformMatched && protocolMatched && notSuSystemUser } return false @@ -675,9 +390,12 @@ func (s *Server) checkReuseSSHClient() bool { func (s *Server) getCacheSSHConn() (srvConn *srvconn.SSHConnection, ok bool) { lang := s.connOpts.getLang() - keyId := srvconn.MakeReuseSSHClientKey(s.connOpts.user.ID, s.connOpts.asset.ID, - s.connOpts.systemUser.ID, s.connOpts.asset.IP, s.systemUserAuthInfo.Username) - sshClient, ok := srvconn.GetClientFromCache(keyId) + asset := s.connOpts.authInfo.Asset + user := s.connOpts.authInfo.User + loginAccount := s.account + key := srvconn.MakeReuseSSHClientKey(user.ID, asset.ID, + loginAccount.ID, asset.Address, loginAccount.HashId()) + sshClient, ok := srvconn.GetClientFromCache(key) if !ok { return nil, ok } @@ -687,7 +405,8 @@ func (s *Server) getCacheSSHConn() (srvConn *srvconn.SSHConnection, ok bool) { return nil, false } pty := s.UserConn.Pty() - cacheConn, err := srvconn.NewSSHConnection(sess, srvconn.SSHCharset(s.platform.Charset), + charset := s.getCharset() + cacheConn, err := srvconn.NewSSHConnection(sess, srvconn.SSHCharset(charset), srvconn.SSHPtyWin(srvconn.Windows{ Width: pty.Window.Width, Height: pty.Window.Height, @@ -696,68 +415,77 @@ func (s *Server) getCacheSSHConn() (srvConn *srvconn.SSHConnection, ok bool) { logger.Errorf("Cache ssh session failed: %s", err) _ = sess.Close() sshClient.ReleaseSession(sess) + srvconn.ReleaseClientCacheKey(key, sshClient) return nil, false } reuseMsg := fmt.Sprintf(lang.T("Reuse SSH connections (%s@%s) [Number of connections: %d]"), - s.connOpts.systemUser.Name, s.connOpts.asset.IP, sshClient.RefCount()) + loginAccount.Name, asset.Address, sshClient.RefCount()) utils.IgnoreErrWriteString(s.UserConn, reuseMsg+"\r\n") go func() { _ = sess.Wait() sshClient.ReleaseSession(sess) logger.Infof("Reuse SSH client(%s) shell connection release", sshClient) + srvconn.ReleaseClientCacheKey(key, sshClient) }() return cacheConn, true } -func (s *Server) createAvailableGateWay(domain *model.Domain) (*domainGateway, error) { - var dGateway *domainGateway - switch s.connOpts.ProtocolType { - case srvconn.ProtocolK8s: - dstHost, dstPort, err := ParseUrlHostAndPort(s.connOpts.app.Attrs.Cluster) +func (s *Server) createAvailableGateWay() (*domainGateway, error) { + asset := s.connOpts.authInfo.Asset + protocol := s.connOpts.authInfo.Protocol + dstIP := asset.Address + dstPort := asset.ProtocolPort(protocol) + if protocol == srvconn.ProtocolK8s { + dstHost, dstPort1, err := ParseUrlHostAndPort(asset.Address) if err != nil { return nil, err } - dGateway = &domainGateway{ - domain: domain, - dstIP: dstHost, - dstPort: dstPort, - } - case srvconn.ProtocolMySQL, srvconn.ProtocolMariadb, srvconn.ProtocolSQLServer, - srvconn.ProtocolRedis, srvconn.ProtocolMongoDB: - dGateway = &domainGateway{ - domain: domain, - dstIP: s.connOpts.app.Attrs.Host, - dstPort: s.connOpts.app.Attrs.Port, - } - default: - return nil, fmt.Errorf("%w: %s", ErrUnMatchProtocol, - s.connOpts.ProtocolType) + dstIP = dstHost + dstPort = dstPort1 + } + dGateway := &domainGateway{ + dstIP: dstIP, + dstPort: dstPort, + selectedGateway: s.gateway, } return dGateway, nil } // getSSHConn 获取ssh连接 func (s *Server) getK8sConConn(localTunnelAddr *net.TCPAddr) (srvConn srvconn.ServerConnection, err error) { - clusterServer := s.connOpts.app.Attrs.Cluster + namespaceValue := "" + if k8sSettings, ok := s.connOpts.authInfo.Platform.GetProtocolSetting(srvconn.ProtocolK8s); ok { + if v, ok := k8sSettings.Setting["namespace"]; ok { + if s, ok := v.(string); ok { + namespaceValue = s + } + } + } + asset := s.connOpts.authInfo.Asset + clusterServer := asset.Address if localTunnelAddr != nil { - originUrl, err := url.Parse(clusterServer) - if err != nil { - return nil, err + originUrl, err1 := url.Parse(clusterServer) + if err1 != nil { + return nil, err1 } - clusterServer = ReplaceURLHostAndPort(originUrl, "127.0.0.1", localTunnelAddr.Port) + clusterServer = ReplaceURLHostAndPort(originUrl, localIP, localTunnelAddr.Port) } if s.connOpts.k8sContainer != nil { return s.getContainerConn(clusterServer) } srvConn, err = srvconn.NewK8sConnection( - srvconn.K8sToken(s.systemUserAuthInfo.Token), + srvconn.K8sToken(s.account.Secret), srvconn.K8sClusterServer(clusterServer), - srvconn.K8sUsername(s.systemUserAuthInfo.Username), + srvconn.K8sUsername(s.account.Username), srvconn.K8sSkipTls(true), srvconn.K8sPtyWin(srvconn.Windows{ Width: s.UserConn.Pty().Window.Width, Height: s.UserConn.Pty().Window.Height, }), + srvconn.K8sExtraEnvs(map[string]string{ + "K8sName": asset.Name, + "Namespace": namespaceValue, + }), ) return } @@ -765,10 +493,11 @@ func (s *Server) getK8sConConn(localTunnelAddr *net.TCPAddr) (srvConn srvconn.Se func (s *Server) getContainerConn(clusterServer string) ( srvConn *srvconn.ContainerConnection, err error) { info := s.connOpts.k8sContainer - token := s.systemUserAuthInfo.Token + token := s.account.Secret + pty := s.UserConn.Pty() win := srvconn.Windows{ - Width: s.UserConn.Pty().Window.Width, - Height: s.UserConn.Pty().Window.Height, + Width: pty.Window.Width, + Height: pty.Window.Height, } opts := make([]srvconn.ContainerOption, 0, 5) opts = append(opts, srvconn.ContainerHost(clusterServer)) @@ -782,43 +511,42 @@ func (s *Server) getContainerConn(clusterServer string) ( return } -func (s *Server) getMySQLConn(localTunnelAddr *net.TCPAddr) (srvConn *srvconn.MySQLConn, err error) { - host := s.connOpts.app.Attrs.Host - port := s.connOpts.app.Attrs.Port +func (s *Server) getRedisConn(localTunnelAddr *net.TCPAddr) (srvConn *srvconn.RedisConn, err error) { + asset := s.connOpts.authInfo.Asset + protocol := s.connOpts.authInfo.Protocol + platform := s.connOpts.authInfo.Platform + host := asset.Address + port := asset.ProtocolPort(protocol) if localTunnelAddr != nil { - host = "127.0.0.1" + host = localIP port = localTunnelAddr.Port } - mysqlOpts := make([]srvconn.SqlOption, 0, 7) - mysqlOpts = append(mysqlOpts, srvconn.SqlHost(host)) - mysqlOpts = append(mysqlOpts, srvconn.SqlPort(port)) - mysqlOpts = append(mysqlOpts, srvconn.SqlUsername(s.systemUserAuthInfo.Username)) - mysqlOpts = append(mysqlOpts, srvconn.SqlPassword(s.systemUserAuthInfo.Password)) - mysqlOpts = append(mysqlOpts, srvconn.SqlDBName(s.connOpts.app.Attrs.Database)) - mysqlOpts = append(mysqlOpts, srvconn.SqlPtyWin(srvconn.Windows{ - Width: s.UserConn.Pty().Window.Width, - Height: s.UserConn.Pty().Window.Height, - })) - if s.connOpts.params != nil && s.connOpts.params.DisableMySQLAutoHash { - mysqlOpts = append(mysqlOpts, srvconn.MySQLDisableAutoReHash()) - } - srvConn, err = srvconn.NewMySQLConnection(mysqlOpts...) - return -} + username := s.account.Username + isAuthUsername := false + isClusterMode := false + if platformProtocol, ok := platform.GetProtocolSetting("redis"); ok { + protocolSetting := platformProtocol.GetSetting() + isAuthUsername = protocolSetting.AuthUsername -func (s *Server) getRedisConn(localTunnelAddr *net.TCPAddr) (srvConn *srvconn.RedisConn, err error) { - host := s.connOpts.app.Attrs.Host - port := s.connOpts.app.Attrs.Port - if localTunnelAddr != nil { - host = "127.0.0.1" - port = localTunnelAddr.Port + // 解析集群模式配置 TODO: 将优化 sdk-go 的 ProtocolSetting 加上 enable_cluster_mode + if useCluster, exists := platformProtocol.Setting["enable_cluster_mode"]; exists { + isClusterMode = parseBoolValue(useCluster) + } + } + if s.account.IsNull() || !isAuthUsername { + username = "" } srvConn, err = srvconn.NewRedisConnection( srvconn.SqlHost(host), srvconn.SqlPort(port), - srvconn.SqlUsername(s.systemUserAuthInfo.Username), - srvconn.SqlPassword(s.systemUserAuthInfo.Password), - srvconn.SqlDBName(s.connOpts.app.Attrs.Database), + srvconn.SqlUsername(username), + srvconn.SqlPassword(s.account.Secret), + srvconn.SqlClusterMode(isClusterMode), + srvconn.SqlDBName(asset.SpecInfo.DBName), + srvconn.SqlUseSSL(asset.SpecInfo.UseSSL), + srvconn.SqlCaCert(asset.SecretInfo.CaCert), + srvconn.SqlClientCert(asset.SecretInfo.ClientCert), + srvconn.SqlCertKey(asset.SecretInfo.ClientKey), srvconn.SqlPtyWin(srvconn.Windows{ Width: s.UserConn.Pty().Window.Width, Height: s.UserConn.Pty().Window.Height, @@ -828,39 +556,36 @@ func (s *Server) getRedisConn(localTunnelAddr *net.TCPAddr) (srvConn *srvconn.Re } func (s *Server) getMongoDBConn(localTunnelAddr *net.TCPAddr) (srvConn *srvconn.MongoDBConn, err error) { - host := s.connOpts.app.Attrs.Host - port := s.connOpts.app.Attrs.Port + asset := s.connOpts.authInfo.Asset + protocol := s.connOpts.authInfo.Protocol + host := asset.Address + port := asset.ProtocolPort(protocol) if localTunnelAddr != nil { - host = "127.0.0.1" + host = localIP port = localTunnelAddr.Port } - srvConn, err = srvconn.NewMongoDBConnection( - srvconn.SqlHost(host), - srvconn.SqlPort(port), - srvconn.SqlUsername(s.systemUserAuthInfo.Username), - srvconn.SqlPassword(s.systemUserAuthInfo.Password), - srvconn.SqlDBName(s.connOpts.app.Attrs.Database), - srvconn.SqlPtyWin(srvconn.Windows{ - Width: s.UserConn.Pty().Window.Width, - Height: s.UserConn.Pty().Window.Height, - }), - ) - return -} + platform := s.connOpts.authInfo.Platform -func (s *Server) getSQLServerConn(localTunnelAddr *net.TCPAddr) (srvConn *srvconn.SQLServerConn, err error) { - host := s.connOpts.app.Attrs.Host - port := s.connOpts.app.Attrs.Port - if localTunnelAddr != nil { - host = "127.0.0.1" - port = localTunnelAddr.Port + authSource := "" + connectionOpts := "" + if platformProtocol, ok := platform.GetProtocolSetting("mongodb"); ok { + protocolSetting := platformProtocol.GetSetting() + authSource = protocolSetting.AuthSource + connectionOpts = protocolSetting.ConnectionOpts } - srvConn, err = srvconn.NewSQLServerConnection( + + srvConn, err = srvconn.NewMongoDBConnection( srvconn.SqlHost(host), srvconn.SqlPort(port), - srvconn.SqlUsername(s.systemUserAuthInfo.Username), - srvconn.SqlPassword(s.systemUserAuthInfo.Password), - srvconn.SqlDBName(s.connOpts.app.Attrs.Database), + srvconn.SqlUsername(s.account.Username), + srvconn.SqlPassword(s.account.Secret), + srvconn.SqlDBName(asset.SpecInfo.DBName), + srvconn.SqlUseSSL(asset.SpecInfo.UseSSL), + srvconn.SqlCaCert(asset.SecretInfo.CaCert), + srvconn.SqlCertKey(asset.SecretInfo.ClientKey), + srvconn.SqlAllowInvalidCert(asset.SpecInfo.AllowInvalidCert), + srvconn.SqlAuthSource(authSource), + srvconn.SqlConnectionOptions(connectionOpts), srvconn.SqlPtyWin(srvconn.Windows{ Width: s.UserConn.Pty().Window.Width, Height: s.UserConn.Pty().Window.Height, @@ -870,51 +595,50 @@ func (s *Server) getSQLServerConn(localTunnelAddr *net.TCPAddr) (srvConn *srvcon } func (s *Server) getSSHConn() (srvConn *srvconn.SSHConnection, err error) { - loginSystemUser := s.systemUserAuthInfo - if s.suFromSystemUserAuthInfo != nil { - loginSystemUser = s.suFromSystemUserAuthInfo - } - key := srvconn.MakeReuseSSHClientKey(s.connOpts.user.ID, s.connOpts.asset.ID, loginSystemUser.ID, - s.connOpts.asset.IP, loginSystemUser.Username) + loginAccount := s.account.GetBaseAccount() + if s.suFromAccount != nil { + loginAccount = s.suFromAccount + } + platform := s.connOpts.authInfo.Platform + asset := s.connOpts.authInfo.Asset + protocol := s.connOpts.authInfo.Protocol + user := s.connOpts.authInfo.User + key := srvconn.MakeReuseSSHClientKey(user.ID, asset.ID, + loginAccount.ID, asset.Address, loginAccount.HashId()) timeout := config.GlobalConfig.SSHTimeout sshAuthOpts := make([]srvconn.SSHClientOption, 0, 6) - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientUsername(loginSystemUser.Username)) - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientHost(s.connOpts.asset.IP)) - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPort(s.connOpts.asset.ProtocolPort(loginSystemUser.Protocol))) - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPassword(loginSystemUser.Password)) + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientUsername(loginAccount.Username)) + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientHost(asset.Address)) + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPort(asset.ProtocolPort(protocol))) sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientTimeout(timeout)) - if loginSystemUser.PrivateKey != "" { - // 先使用 password 解析 PrivateKey - if signer, err1 := gossh.ParsePrivateKeyWithPassphrase([]byte(loginSystemUser.PrivateKey), - []byte(loginSystemUser.Password)); err1 == nil { + if loginAccount.IsSSHKey() { + if signer, err1 := gossh.ParsePrivateKey([]byte(loginAccount.Secret)); err1 == nil { sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPrivateAuth(signer)) - } else { - // 如果之前使用password解析失败,则去掉 password, 尝试直接解析 PrivateKey 防止错误的passphrase - if signer, err1 = gossh.ParsePrivateKey([]byte(loginSystemUser.PrivateKey)); err1 == nil { - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPrivateAuth(signer)) - } + } + } else { + if !isPlatform(&platform, mfaAuth) { + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPassword(loginAccount.Secret)) } } - var passwordTryCount int - password := loginSystemUser.Password + + password := loginAccount.Secret kb := srvconn.SSHClientKeyboardAuth(func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { s.setKeyBoardMode() - termReader := utils.NewTerminal(s.UserConn, "") + vt := term.NewTerminal(s.UserConn, "") utils.IgnoreErrWriteString(s.UserConn, "\r\n") ans := make([]string, len(questions)) for i := range questions { q := questions[i] - termReader.SetPrompt(questions[i]) - logger.Debugf("Conn[%s] keyboard auth question [ %s ]", s.UserConn.ID(), q) + vt.SetPrompt(questions[i]) + logger.Debugf("Conn[%s] keyboard auth question %d [ %s ]", s.UserConn.ID(), i, q) if strings.Contains(strings.ToLower(q), "password") { - passwordTryCount++ - if passwordTryCount <= 1 && password != "" { + if password != "" { ans[i] = password continue } } - line, err2 := termReader.ReadLine() + line, err2 := vt.ReadLine() if err2 != nil { logger.Errorf("Conn[%s] keyboard auth read err: %s", s.UserConn.ID(), err2) } @@ -940,107 +664,174 @@ func (s *Server) getSSHConn() (srvConn *srvconn.SSHConnection, err error) { logger.Errorf("SSH client(%s) start session err %s", sshClient, err) return nil, err } + pty := s.UserConn.Pty() + charset := s.getCharset() sshConnectOpts := make([]srvconn.SSHOption, 0, 6) - sshConnectOpts = append(sshConnectOpts, srvconn.SSHCharset(s.platform.Charset)) + sshConnectOpts = append(sshConnectOpts, srvconn.SSHCharset(charset)) sshConnectOpts = append(sshConnectOpts, srvconn.SSHTerm(pty.Term)) sshConnectOpts = append(sshConnectOpts, srvconn.SSHPtyWin(srvconn.Windows{ Width: pty.Window.Width, Height: pty.Window.Height, })) - if s.suFromSystemUserAuthInfo != nil { + if s.suFromAccount != nil { /* - suSystemUserAuthInfo 是 switch user - systemUserAuthInfo 是最终 su 的登录用户 + suFromAccount 是 switch user + account 是最终 su 的登录用户 */ - suUsername := s.systemUserAuthInfo.Username - suPassword := s.systemUserAuthInfo.Password - suCommand := fmt.Sprintf(srvconn.LinuxSuCommand, suUsername) - sshConnectOpts = append(sshConnectOpts, srvconn.SSHLoginToSudo(true)) - sshConnectOpts = append(sshConnectOpts, srvconn.SSHSudoCommand(suCommand)) - sshConnectOpts = append(sshConnectOpts, srvconn.SSHSudoUsername(suUsername)) - sshConnectOpts = append(sshConnectOpts, srvconn.SSHSudoPassword(suPassword)) + suUsername := s.account.Username + suPassword := s.account.Secret + sudoType := srvconn.SuMethodSu + if platform.SuMethod != nil { + sudoType = srvconn.NewSuMethodType(platform.SuMethod.Value) + } + cfg := srvconn.SuConfig{ + MethodType: sudoType, + SudoUsername: suUsername, + SudoPassword: suPassword, + } + sshConnectOpts = append(sshConnectOpts, srvconn.SSHSudoConfig(&cfg)) } sshConn, err := srvconn.NewSSHConnection(sess, sshConnectOpts...) if err != nil { _ = sess.Close() sshClient.ReleaseSession(sess) + srvconn.ReleaseClientCacheKey(key, sshClient) return nil, err } - if s.suFromSystemUserAuthInfo != nil { + if s.suFromAccount != nil { lang := s.connOpts.getLang() - msg := fmt.Sprintf(lang.T("Switched to %s"), s.systemUserAuthInfo) + msg := fmt.Sprintf(lang.T("Switched to %s"), s.account) utils.IgnoreErrWriteString(s.UserConn, "\r\n") utils.IgnoreErrWriteString(s.UserConn, msg) _, _ = sshConn.Write([]byte("\r")) logger.Infof("Conn[%s]: su login from %s to %s", s.UserConn.ID(), - loginSystemUser, s.systemUserAuthInfo) + loginAccount, s.account) } - go func() { _ = sess.Wait() sshClient.ReleaseSession(sess) logger.Infof("SSH client(%s) shell connection release", sshClient) + srvconn.ReleaseClientCacheKey(key, sshClient) }() return sshConn, nil - } func (s *Server) getTelnetConn() (srvConn *srvconn.TelnetConnection, err error) { + loginAccount := s.account.GetBaseAccount() + if s.suFromAccount != nil { + loginAccount = s.suFromAccount + } telnetOpts := make([]srvconn.TelnetOption, 0, 8) timeout := config.GlobalConfig.SSHTimeout pty := s.UserConn.Pty() - cusString := s.terminalConf.TelnetRegex - if cusString != "" { - successPattern, err2 := regexp.Compile(cusString) - if err2 != nil { - logger.Errorf("Conn[%s] telnet custom regex %s compile err: %s", - s.UserConn.ID(), cusString, err) - return nil, err2 + protocol := s.connOpts.authInfo.Protocol + asset := s.connOpts.authInfo.Asset + platform := s.connOpts.authInfo.Platform + + usernamePrompt := "" + passwordPrompt := "" + successPrompt := "" + if platformProtocol, ok := platform.GetProtocolSetting(protocol); ok { + protocolSetting := platformProtocol.GetSetting() + usernamePrompt = strings.TrimSpace(protocolSetting.TelnetUsernamePrompt) + passwordPrompt = strings.TrimSpace(protocolSetting.TelnetPasswordPrompt) + successPrompt = strings.TrimSpace(protocolSetting.TelnetSuccessPrompt) + } + + if usernamePrompt != "" { + usernamePattern, err1 := regexp.Compile(usernamePrompt) + if err1 != nil { + logger.Errorf("Conn[%s] telnet username regex %s compile err: %s", + s.UserConn.ID(), usernamePrompt, err) + return nil, err + } + telnetOpts = append(telnetOpts, srvconn.TelnetCustomUsernamePattern(usernamePattern)) + } + if passwordPrompt != "" { + passwordPattern, err1 := regexp.Compile(passwordPrompt) + if err1 != nil { + logger.Errorf("Conn[%s] telnet password regex %s compile err: %s", + s.UserConn.ID(), passwordPrompt, err) + return nil, err + } + telnetOpts = append(telnetOpts, srvconn.TelnetCustomPasswordPattern(passwordPattern)) + } + if successPrompt != "" { + successPattern, err1 := regexp.Compile(successPrompt) + if err1 != nil { + logger.Errorf("Conn[%s] telnet success regex %s compile err: %s", + s.UserConn.ID(), successPrompt, err) + return nil, err } telnetOpts = append(telnetOpts, srvconn.TelnetCustomSuccessPattern(successPattern)) } - telnetOpts = append(telnetOpts, srvconn.TelnetHost(s.connOpts.asset.IP)) - telnetOpts = append(telnetOpts, srvconn.TelnetPort(s.connOpts.asset.ProtocolPort(s.systemUserAuthInfo.Protocol))) - telnetOpts = append(telnetOpts, srvconn.TelnetUsername(s.systemUserAuthInfo.Username)) - telnetOpts = append(telnetOpts, srvconn.TelnetUPassword(s.systemUserAuthInfo.Password)) + telnetOpts = append(telnetOpts, srvconn.TelnetHost(asset.Address)) + telnetOpts = append(telnetOpts, srvconn.TelnetPort(asset.ProtocolPort(protocol))) + telnetOpts = append(telnetOpts, srvconn.TelnetUsername(loginAccount.Username)) + telnetOpts = append(telnetOpts, srvconn.TelnetUPassword(loginAccount.Secret)) telnetOpts = append(telnetOpts, srvconn.TelnetUTimeout(timeout)) telnetOpts = append(telnetOpts, srvconn.TelnetPtyWin(srvconn.Windows{ Width: pty.Window.Width, Height: pty.Window.Height, })) - telnetOpts = append(telnetOpts, srvconn.TelnetCharset(s.platform.Charset)) + charset := s.getCharset() + telnetOpts = append(telnetOpts, srvconn.TelnetCharset(charset)) // 获取网关配置 proxyArgs := s.getGatewayProxyOptions() if proxyArgs != nil { telnetOpts = append(telnetOpts, srvconn.TelnetProxyOptions(proxyArgs)) } - return srvconn.NewTelnetConnection(telnetOpts...) + if s.suFromAccount != nil { + suUsername := s.account.Username + suPassword := s.account.Secret + sudoType := srvconn.SuMethodSu + if platform.SuMethod != nil { + sudoType = srvconn.NewSuMethodType(platform.SuMethod.Value) + } + cfg := srvconn.SuConfig{ + MethodType: sudoType, + SudoUsername: suUsername, + SudoPassword: suPassword, + } + telnetOpts = append(telnetOpts, srvconn.TelnetSuConfig(&cfg)) + } + tcon, err := srvconn.NewTelnetConnection(telnetOpts...) + if err != nil { + return tcon, err + } + if s.suFromAccount != nil { + lang := s.connOpts.getLang() + msg := fmt.Sprintf(lang.T("Switched to %s"), s.account) + utils.IgnoreErrWriteString(s.UserConn, "\r\n") + utils.IgnoreErrWriteString(s.UserConn, msg) + _, _ = tcon.Write([]byte("\r")) + logger.Infof("Conn[%s]: su login from %s to %s", s.UserConn.ID(), + loginAccount, s.account) + } + return tcon, nil } func (s *Server) getGatewayProxyOptions() []srvconn.SSHClientOptions { - /* - 兼容 云平台同步资产,配置网域,但网关配置为空的情况。 - */ - if s.domainGateways != nil && len(s.domainGateways.Gateways) != 0 { + // 仅有一个网关的情况 + if s.gateway != nil { timeout := config.GlobalConfig.SSHTimeout - proxyArgs := make([]srvconn.SSHClientOptions, 0, len(s.domainGateways.Gateways)) - for i := range s.domainGateways.Gateways { - gateway := s.domainGateways.Gateways[i] - proxyArg := srvconn.SSHClientOptions{ - Host: gateway.IP, - Port: strconv.Itoa(gateway.Port), - Username: gateway.Username, - Password: gateway.Password, - Passphrase: gateway.Password, // 兼容 带密码的private_key, - PrivateKey: gateway.PrivateKey, - Timeout: timeout, - } - proxyArgs = append(proxyArgs, proxyArg) + port := s.gateway.Protocols.GetProtocolPort(model.ProtocolSSH) + loginAccount := s.gateway.Account + proxyArg := srvconn.SSHClientOptions{ + Host: s.gateway.Address, + Port: strconv.Itoa(port), + Username: s.gateway.Account.Username, + Timeout: timeout, + } + if loginAccount.IsSSHKey() { + proxyArg.PrivateKey = s.gateway.Account.Secret + } else { + proxyArg.Password = s.gateway.Account.Secret } - return proxyArgs + return []srvconn.SSHClientOptions{proxyArg} } return nil } @@ -1055,21 +846,25 @@ func (s *Server) getServerConn(proxyAddr *net.TCPAddr) (srvconn.ServerConnection close(done) }() go s.sendConnectingMsg(done) - switch s.connOpts.ProtocolType { + protocol := s.connOpts.authInfo.Protocol + switch protocol { case srvconn.ProtocolSSH: return s.getSSHConn() case srvconn.ProtocolTELNET: return s.getTelnetConn() case srvconn.ProtocolK8s: return s.getK8sConConn(proxyAddr) - case srvconn.ProtocolMySQL, srvconn.ProtocolMariadb: - return s.getMySQLConn(proxyAddr) - case srvconn.ProtocolSQLServer: - return s.getSQLServerConn(proxyAddr) case srvconn.ProtocolRedis: return s.getRedisConn(proxyAddr) case srvconn.ProtocolMongoDB: return s.getMongoDBConn(proxyAddr) + case srvconn.ProtocolMySQL, + srvconn.ProtocolMariadb, + srvconn.ProtocolPostgresql, + srvconn.ProtocolSQLServer, + srvconn.ProtocolClickHouse, + srvconn.ProtocolOracle: + return s.getUSQLConn(proxyAddr) default: return nil, ErrUnMatchProtocol } @@ -1077,10 +872,11 @@ func (s *Server) getServerConn(proxyAddr *net.TCPAddr) (srvconn.ServerConnection func (s *Server) sendConnectingMsg(done chan struct{}) { delay := 0.0 + maxDelay := 5 * 60.0 // 最多执行五分钟 msg := fmt.Sprintf("%s %.1f", s.connOpts.ConnectMsg(), delay) utils.IgnoreErrWriteString(s.UserConn, msg) var activeFlag bool - for { + for delay < maxDelay { select { case <-done: return @@ -1105,28 +901,26 @@ func (s *Server) sendConnectingMsg(done chan struct{}) { } } -func (s *Server) checkLoginConfirm() bool { - opts := make([]auth.ConfirmOption, 0, 4) - opts = append(opts, auth.ConfirmWithUser(s.connOpts.user)) - opts = append(opts, auth.ConfirmWithSystemUser(s.systemUserAuthInfo)) - var ( - targetType string - targetId string - ) - switch s.connOpts.ProtocolType { - case srvconn.ProtocolMySQL, srvconn.ProtocolMariadb, srvconn.ProtocolSQLServer, - srvconn.ProtocolRedis, srvconn.ProtocolMongoDB, srvconn.ProtocolK8s: - targetType = model.AppType - targetId = s.connOpts.app.ID - default: - targetId = s.connOpts.asset.ID - } - opts = append(opts, auth.ConfirmWithTargetType(targetType)) - opts = append(opts, auth.ConfirmWithTargetID(targetId)) - confirmSrv := auth.NewLoginConfirm(s.jmsService, opts...) - ok := s.validateLoginConfirm(&confirmSrv, s.UserConn) - s.loginTicketId = confirmSrv.GetTicketId() - return ok +func (s *Server) getCharset() string { + platform := s.connOpts.authInfo.Platform + tokenConnOpts := s.connOpts.authInfo.ConnectOptions + charset := platform.Charset.Value + if tokenConnOpts.Charset != nil { + useCharset := strings.ToLower(*tokenConnOpts.Charset) + logger.Debugf("Conn[%s] set charset %s", s.UserConn.ID(), useCharset) + switch useCharset { + case "utf-8", "utf8": + charset = common.UTF8 + case "gbk": + charset = common.GBK + case "gb2312": + charset = common.GB2312 + case "ios-8859-1", "ascii": + charset = common.ISOLatin1 + default: + } + } + return charset } func (s *Server) Proxy() { @@ -1139,49 +933,82 @@ func (s *Server) Proxy() { _ = s.cacheSSHConnection.Close() } }() - if !s.checkLoginConfirm() { - logger.Errorf("Conn[%s]: check login confirm failed", s.UserConn.ID()) - return - } lang := s.connOpts.getLang() ctx, cancel := context.WithCancel(context.Background()) + maxIdleTime := s.terminalConf.MaxIdleTime + maxSessionTime := time.Now().Add(time.Duration(s.terminalConf.MaxSessionTime) * time.Hour) sw := SwitchSession{ ID: s.ID, - MaxIdleTime: s.terminalConf.MaxIdleTime, + MaxIdleTime: maxIdleTime, keepAliveTime: 60, ctx: ctx, cancel: cancel, p: s, + notifyMsgChan: make(chan *exchange.RoomMessage, 1), + + MaxSessionTime: maxSessionTime, } if err := s.CreateSessionCallback(); err != nil { msg := lang.T("Connect with api server failed") msg = utils.WrapperWarn(msg) utils.IgnoreErrWriteString(s.UserConn, msg) - logger.Errorf("Conn[%s] submit session %s to core server err: %s", - s.UserConn.ID(), s.ID, msg) + logger.Errorf("Conn[%s] submit session %s to core server err: %s %s", + s.UserConn.ID(), s.ID, msg, err) return } - if s.loginTicketId != "" { + if s.connOpts.authInfo.Ticket != nil { + reviewTicketId := s.connOpts.authInfo.Ticket.ID msg := fmt.Sprintf("Conn[%s] create session %s ticket %s relation", - s.UserConn.ID(), s.ID, s.loginTicketId) - logger.Debug(msg) - if err := s.jmsService.CreateSessionTicketRelation(s.sessionInfo.ID, s.loginTicketId); err != nil { + s.UserConn.ID(), s.ID, reviewTicketId) + logger.Info(msg) + if err := s.jmsService.CreateSessionTicketRelation(s.sessionInfo.ID, reviewTicketId); err != nil { logger.Errorf("%s err: %s", msg, err) } } - AddCommonSwitch(&sw) - defer RemoveCommonSwitch(&sw) + if s.connOpts.authInfo.FaceMonitorToken != "" { + faceMonitorToken := s.connOpts.authInfo.FaceMonitorToken + faceReq := service.JoinFaceMonitorRequest{ + FaceMonitorToken: faceMonitorToken, + SessionId: s.sessionInfo.ID, + } + logger.Infof("Conn[%s] join face monitor %s", s.UserConn.ID(), faceMonitorToken) + if err := s.jmsService.JoinFaceMonitor(faceReq); err != nil { + logger.Errorf("Conn[%s] join face monitor err: %s", s.UserConn.ID(), err) + } + } + + traceSession := session.NewSession(sw.p.sessionInfo, func(task *model.TerminalTask) error { + switch task.Name { + case model.TaskKillSession: + sw.Terminate(task.Kwargs.TerminatedBy) + case model.TaskLockSession: + sw.PauseOperation(task.Kwargs.CreatedByUser) + case model.TaskUnlockSession: + sw.ResumeOperation(task.Kwargs.CreatedByUser) + case model.TaskPermExpired: + sw.PermBecomeExpired(task.Name, task.Args) + case model.TaskPermValid: + sw.PermBecomeValid(task.Name, task.Args) + default: + return fmt.Errorf("ssh session unknown task %s", task.Name) + } + return nil + }) + session.AddSession(traceSession) + defer session.RemoveSession(traceSession) defer func() { if err := s.DisConnectedCallback(); err != nil { logger.Errorf("Conn[%s] update session %s err: %+v", s.UserConn.ID(), s.ID, err) } }() var proxyAddr *net.TCPAddr - if s.domainGateways != nil && len(s.domainGateways.Gateways) != 0 { - switch s.connOpts.ProtocolType { - case srvconn.ProtocolMySQL, srvconn.ProtocolMariadb, srvconn.ProtocolSQLServer, - srvconn.ProtocolRedis, srvconn.ProtocolMongoDB, srvconn.ProtocolK8s: - dGateway, err := s.createAvailableGateWay(s.domainGateways) + if s.gateway != nil { + protocol := s.connOpts.authInfo.Protocol + switch protocol { + case srvconn.ProtocolSSH, srvconn.ProtocolTELNET: + // ssh 和 telnet 协议不需要本地启动代理 + default: + dGateway, err := s.createAvailableGateWay() if err != nil { msg := lang.T("Start domain gateway failed %s") msg = fmt.Sprintf(msg, err) @@ -1199,7 +1026,6 @@ func (s *Server) Proxy() { } defer dGateway.Stop() proxyAddr = dGateway.GetListenAddr() - default: } } srvCon, err := s.getServerConn(proxyAddr) @@ -1209,16 +1035,39 @@ func (s *Server) Proxy() { if err2 := s.ConnectedFailedCallback(err); err2 != nil { logger.Errorf("Conn[%s] update session err: %s", s.UserConn.ID(), err2) } + errLog := model.SessionLifecycleLog{Reason: err.Error()} + if err1 := s.jmsService.RecordSessionLifecycleLog(s.sessionInfo.ID, model.AssetConnectFinished, + errLog); err1 != nil { + logger.Errorf("Conn[%s] record session activity log err: %s", s.UserConn.ID(), err1) + } return } defer srvCon.Close() + if err1 := s.jmsService.RecordSessionLifecycleLog(s.sessionInfo.ID, model.AssetConnectSuccess, + model.EmptyLifecycleLog); err1 != nil { + logger.Errorf("Conn[%s] record session activity log err: %s", s.UserConn.ID(), err1) + } logger.Infof("Conn[%s] create session %s success", s.UserConn.ID(), s.ID) - if err2 := s.ConnectedSuccessCallback(); err2 != nil { - logger.Errorf("Conn[%s] update session %s err: %s", s.UserConn.ID(), s.ID, err2) - } if s.OnSessionInfo != nil { - go s.OnSessionInfo(s.sessionInfo) + actions := s.connOpts.authInfo.Actions + tokenConnOpts := s.connOpts.authInfo.ConnectOptions + ctrlCAsCtrlZ := false + isK8s := s.connOpts.authInfo.Protocol == srvconn.ProtocolK8s + isNotPod := s.connOpts.k8sContainer == nil + if isK8s && isNotPod { + ctrlCAsCtrlZ = true + } + perm := actions.Permission() + info := SessionInfo{ + Session: s.sessionInfo, + Perms: &perm, + + BackspaceAsCtrlH: tokenConnOpts.BackspaceAsCtrlH, + CtrlCAsCtrlZ: ctrlCAsCtrlZ, + ThemeName: tokenConnOpts.TerminalThemeName, + } + go s.OnSessionInfo(&info) } utils.IgnoreErrWriteWindowTitle(s.UserConn, s.connOpts.TerminalTitle()) if err = sw.Bridge(s.UserConn, srvCon); err != nil { @@ -1232,26 +1081,21 @@ func (s *Server) sendConnectErrorMsg(err error) { utils.IgnoreErrWriteString(s.UserConn, msg) utils.IgnoreErrWriteString(s.UserConn, utils.CharNewLine) logger.Error(msg) - switch s.connOpts.ProtocolType { - case srvconn.ProtocolK8s: - token := s.systemUserAuthInfo.Token - if token != "" { - tokenLen := len(token) - showLen := tokenLen / 2 - hiddenLen := tokenLen - showLen - msg2 := fmt.Sprintf("Try token: %s", token[:showLen]+strings.Repeat("*", hiddenLen)) - logger.Error(msg2) - } - default: - password := s.systemUserAuthInfo.Password - if password != "" { - passwordLen := len(s.systemUserAuthInfo.Password) - showLen := passwordLen / 2 - hiddenLen := passwordLen - showLen - msg2 := fmt.Sprintf("Try password: %s", password[:showLen]+strings.Repeat("*", hiddenLen)) - logger.Error(msg2) + protocol := s.connOpts.authInfo.Protocol + password := s.account.Secret + if password != "" { + passwordLen := len(s.account.Secret) + showLen := passwordLen / 2 + hiddenLen := passwordLen - showLen + var msg2 string + if protocol == srvconn.ProtocolK8s { + msg2 = fmt.Sprintf("Try token: %s", password[:showLen]+strings.Repeat("*", hiddenLen)) + } else { + msg2 = fmt.Sprintf("Try password: %s", password[:showLen]+strings.Repeat("*", hiddenLen)) } + logger.Error(msg2) } + } func ParseUrlHostAndPort(clusterAddr string) (host string, port int, err error) { diff --git a/pkg/proxy/server_database.go b/pkg/proxy/server_database.go new file mode 100644 index 000000000..6a2361eea --- /dev/null +++ b/pkg/proxy/server_database.go @@ -0,0 +1,66 @@ +package proxy + +import ( + "errors" + "net" + + "github.com/jumpserver/koko/pkg/srvconn" +) + +var usqlProtocolAlias = map[string]string{ + srvconn.ProtocolMySQL: "mysql", + srvconn.ProtocolMariadb: "maria", + srvconn.ProtocolPostgresql: "postgres", + srvconn.ProtocolClickHouse: "clickhouse", + srvconn.ProtocolSQLServer: "sqlserver", + srvconn.ProtocolOracle: "oracle", +} + +var errUnknownProtocol = errors.New("unknown protocol") + +func (s *Server) getUSQLConn(localTunnelAddr *net.TCPAddr) (srvConn *srvconn.USQLConn, err error) { + + platform := s.connOpts.authInfo.Platform + asset := s.connOpts.authInfo.Asset + protocol := s.connOpts.authInfo.Protocol + host := asset.Address + port := asset.ProtocolPort(protocol) + if localTunnelAddr != nil { + host = "127.0.0.1" + port = localTunnelAddr.Port + } + + schema, ok := usqlProtocolAlias[protocol] + if !ok { + return nil, errUnknownProtocol + } + disableSQLServerEncrypt := false + if platformProtocol, ok1 := platform.GetProtocolSetting(protocol); ok1 { + protocolSetting := platformProtocol.GetSetting() + disableSQLServerEncrypt = !protocolSetting.Encrypt + } + + opts := make([]srvconn.SqlOption, 0, 9) + opts = append(opts, srvconn.SqlAssetName(asset.Name)) + opts = append(opts, srvconn.SqlSchema(schema)) + opts = append(opts, srvconn.SqlHost(host)) + opts = append(opts, srvconn.SqlPort(port)) + opts = append(opts, srvconn.SqlUsername(s.account.Username)) + opts = append(opts, srvconn.SqlPassword(s.account.Secret)) + opts = append(opts, srvconn.SqlDBName(asset.SpecInfo.DBName)) + opts = append(opts, srvconn.SqlUseSSL(asset.SpecInfo.UseSSL)) + opts = append(opts, srvconn.SqlPGSSLMode(asset.SpecInfo.PgSSLMode)) + opts = append(opts, srvconn.SqlCaCert(asset.SecretInfo.CaCert)) + opts = append(opts, srvconn.SqlClientCert(asset.SecretInfo.ClientCert)) + opts = append(opts, srvconn.SqlCertKey(asset.SecretInfo.ClientKey)) + opts = append(opts, srvconn.SqlAllowInvalidCert(asset.SpecInfo.AllowInvalidCert)) + opts = append(opts, srvconn.SqlDisableSqlServerEncrypt(disableSQLServerEncrypt)) + opts = append(opts, srvconn.SqlPtyWin(srvconn.Windows{ + Width: s.UserConn.Pty().Window.Width, + Height: s.UserConn.Pty().Window.Height, + })) + opts = append(opts, srvconn.SqlMaskingRules(s.connOpts.authInfo.DataMaskingRules)) + srvConn, err = srvconn.NewUSQLConnection(opts...) + + return +} diff --git a/pkg/proxy/server_options.go b/pkg/proxy/server_options.go new file mode 100644 index 000000000..a95def420 --- /dev/null +++ b/pkg/proxy/server_options.go @@ -0,0 +1,130 @@ +package proxy + +import ( + "fmt" + + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver/koko/pkg/i18n" + "github.com/jumpserver/koko/pkg/srvconn" +) + +type ConnectionOption func(options *ConnectionOptions) + +func ConnectContainer(info *ContainerInfo) ConnectionOption { + return func(opts *ConnectionOptions) { + opts.k8sContainer = info + } +} + +func ConnectParams(params *ConnectionParams) ConnectionOption { + return func(opts *ConnectionOptions) { + opts.params = params + } +} + +func ConnectI18nLang(lang string) ConnectionOption { + return func(opts *ConnectionOptions) { + opts.i18nLang = lang + } +} + +func ConnectTokenAuthInfo(authInfo *model.ConnectToken) ConnectionOption { + return func(opts *ConnectionOptions) { + opts.authInfo = authInfo + } +} + +type ConnectionOptions struct { + authInfo *model.ConnectToken + + i18nLang string + + k8sContainer *ContainerInfo + + params *ConnectionParams +} + +type ConnectionParams struct { + DisableMySQLAutoHash bool +} + +type ContainerInfo struct { + Namespace string + PodName string + Container string +} + +func (c *ContainerInfo) String() string { + return fmt.Sprintf("%s_%s_%s", c.Namespace, c.PodName, c.Container) +} + +func (c *ContainerInfo) K8sName(name string) string { + k8sName := fmt.Sprintf("%s(%s)", name, c.String()) + if len([]rune(k8sName)) <= 128 { + return k8sName + } + containerName := []rune(c.String()) + nameRune := []rune(name) + remainLen := 128 - len(nameRune) - 2 - 3 + indexLen := remainLen / 2 + startIndex := len(containerName) - indexLen + startPart := string(containerName[:indexLen]) + endPart := string(containerName[startIndex:]) + return fmt.Sprintf("%s(%s...%s)", name, startPart, endPart) +} + +func (opts *ConnectionOptions) TerminalTitle() string { + protocol := opts.authInfo.Protocol + asset := opts.authInfo.Asset + account := opts.authInfo.Account + + title := "" + switch protocol { + case srvconn.ProtocolK8s: + title = fmt.Sprintf("%s+%s", + protocol, + asset.Address) + default: + title = fmt.Sprintf("%s://%s@%s", + protocol, + account.Username, + asset.Address) + } + return title +} + +func (opts *ConnectionOptions) ConnectMsg() string { + protocol := opts.authInfo.Protocol + asset := opts.authInfo.Asset + account := opts.authInfo.Account + lang := opts.getLang() + msg := "" + switch protocol { + case srvconn.ProtocolTELNET, + srvconn.ProtocolSSH: + accountName := account.String() + switch account.Name { + case model.InputUser: + accountName = fmt.Sprintf("%s(%s)", lang.T("Manual"), account.Username) + case model.DynamicUser: + accountName = fmt.Sprintf("%s(%s)", lang.T("Dynamic"), account.Username) + default: + } + msg = fmt.Sprintf(lang.T("Connecting to %s@%s"), accountName, asset.Address) + case srvconn.ProtocolClickHouse, + srvconn.ProtocolRedis, srvconn.ProtocolMongoDB, srvconn.ProtocolMariadb, + srvconn.ProtocolMySQL, srvconn.ProtocolSQLServer, srvconn.ProtocolPostgresql: + msg = fmt.Sprintf(lang.T("Connecting to Database %s"), asset.String()) + case srvconn.ProtocolK8s: + msg = fmt.Sprintf(lang.T("Connecting to Kubernetes %s"), asset.Address) + if opts.k8sContainer != nil { + msg = fmt.Sprintf(lang.T("Connecting to Kubernetes %s container %s"), + asset.Name, opts.k8sContainer.Container) + } + } + return msg +} + +func (opts *ConnectionOptions) getLang() i18n.LanguageCode { + return i18n.NewLang(opts.i18nLang) +} diff --git a/pkg/proxy/sessmanager.go b/pkg/proxy/sessmanager.go deleted file mode 100644 index 9e94a47a1..000000000 --- a/pkg/proxy/sessmanager.go +++ /dev/null @@ -1,63 +0,0 @@ -package proxy - -import ( - "sync" -) - -var sessManager = newSessionManager() - -func GetSessionById(id string)(s *SwitchSession, ok bool){ - s, ok = sessManager.Get(id) - return -} - -func GetAliveSessions() []string { - return sessManager.Range() -} - -func AddCommonSwitch(s *SwitchSession) { - sessManager.Add(s.ID, s) -} - -func RemoveCommonSwitch(s *SwitchSession) { - sessManager.Delete(s.ID) -} - -func newSessionManager() *sessionManager { - return &sessionManager{ - data: make(map[string]*SwitchSession), - } -} - -type sessionManager struct { - data map[string]*SwitchSession - sync.Mutex -} - -func (s *sessionManager) Add(id string, sess *SwitchSession) { - s.Lock() - defer s.Unlock() - s.data[id] = sess -} -func (s *sessionManager) Get(id string) (sess *SwitchSession, ok bool) { - s.Lock() - defer s.Unlock() - sess, ok = s.data[id] - return -} - -func (s *sessionManager) Delete(id string) { - s.Lock() - defer s.Unlock() - delete(s.data, id) -} - -func (s *sessionManager) Range() []string { - sids := make([]string, 0, len(s.data)) - s.Lock() - defer s.Unlock() - for sid := range s.data { - sids = append(sids, sid) - } - return sids -} diff --git a/pkg/proxy/switch.go b/pkg/proxy/switch.go index 6101e9e2b..8788b290f 100644 --- a/pkg/proxy/switch.go +++ b/pkg/proxy/switch.go @@ -5,13 +5,13 @@ import ( "context" "encoding/json" "fmt" - "strings" "sync/atomic" "time" + "unicode/utf8" + "github.com/jumpserver-dev/sdk-go/common" + "github.com/jumpserver-dev/sdk-go/model" "github.com/jumpserver/koko/pkg/exchange" - "github.com/jumpserver/koko/pkg/jms-sdk-go/common" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" "github.com/jumpserver/koko/pkg/logger" "github.com/jumpserver/koko/pkg/srvconn" "github.com/jumpserver/koko/pkg/utils" @@ -29,7 +29,17 @@ type SwitchSession struct { p *Server - terminateAdmin atomic.Value // 终断会话的管理员名称 + currentOperator atomic.Value // 终断会话的管理员名称 + + pausedStatus atomic.Bool // 暂停状态 + + notifyMsgChan chan *exchange.RoomMessage + + MaxSessionTime time.Time + + invalidPerm atomic.Bool + invalidPermData []byte + invalidPermTime time.Time } func (s *SwitchSession) Terminate(username string) { @@ -37,22 +47,83 @@ func (s *SwitchSession) Terminate(username string) { case <-s.ctx.Done(): return default: - s.setTerminateAdmin(username) + s.setOperator(username) } s.cancel() - logger.Infof("Session[%s] receive terminate task from admin %s", s.ID, username) + logger.Infof("Session[%s] receive terminate task from %s", s.ID, username) +} + +func (s *SwitchSession) PauseOperation(username string) { + s.pausedStatus.Store(true) + s.setOperator(username) + logger.Infof("Session[%s] receive pause task from %s", s.ID, username) + p, _ := json.Marshal(map[string]string{"user": username}) + s.notifyMsgChan <- &exchange.RoomMessage{ + Event: exchange.PauseEvent, + Body: p, + } +} + +func (s *SwitchSession) ResumeOperation(username string) { + s.pausedStatus.Store(false) + s.setOperator(username) + logger.Infof("Session[%s] receive resume task from %s", s.ID, username) + p, _ := json.Marshal(map[string]string{"user": username}) + s.notifyMsgChan <- &exchange.RoomMessage{ + Event: exchange.ResumeEvent, + Body: p, + } +} + +func (s *SwitchSession) PermBecomeExpired(code, detail string) { + if s.invalidPerm.Load() { + return + } + s.invalidPerm.Store(true) + p, _ := json.Marshal(map[string]string{"code": code, "detail": detail}) + s.invalidPermData = p + s.invalidPermTime = time.Now() + s.notifyMsgChan <- &exchange.RoomMessage{ + Event: exchange.PermExpiredEvent, Body: p} } -func (s *SwitchSession) setTerminateAdmin(username string) { - s.terminateAdmin.Store(username) +func (s *SwitchSession) PermBecomeValid(code, detail string) { + if !s.invalidPerm.Load() { + return + } + s.invalidPerm.Store(false) + s.invalidPermTime = s.MaxSessionTime + p, _ := json.Marshal(map[string]string{"code": code, "detail": detail}) + s.invalidPermData = p + s.notifyMsgChan <- &exchange.RoomMessage{ + Event: exchange.PermValidEvent, Body: p} } -func (s *SwitchSession) loadTerminateAdmin() string { - return s.terminateAdmin.Load().(string) +func (s *SwitchSession) CheckPermissionExpired(now time.Time) bool { + if s.p.CheckPermissionExpired(now) { + return true + } + if s.invalidPerm.Load() { + if now.After(s.invalidPermTime.Add(10 * time.Minute)) { + return true + } + } + return false } -func (s *SwitchSession) SessionID() string { - return s.ID +func (s *SwitchSession) setOperator(username string) { + s.currentOperator.Store(username) +} + +func (s *SwitchSession) loadOperator() string { + return s.currentOperator.Load().(string) +} + +func (s *SwitchSession) filterUserInput(p []byte) []byte { + if s.pausedStatus.Load() { + return nil + } + return p } func (s *SwitchSession) recordCommand(cmdRecordChan chan *ExecutedCommand) { @@ -72,33 +143,22 @@ func (s *SwitchSession) recordCommand(cmdRecordChan chan *ExecutedCommand) { // generateCommandResult 生成命令结果 func (s *SwitchSession) generateCommandResult(item *ExecutedCommand) *model.Command { var ( - input string - output string - riskLevel int64 - user string + input string + output string + user string ) user = item.User.User - if len(item.Command) > 128 { - input = item.Command[:128] + if len(item.Command) > maxBufSize { + input = item.Command[:maxBufSize] } else { input = item.Command } - i := strings.LastIndexByte(item.Output, '\r') - if i <= 0 { - output = item.Output - } else if i > 0 && i < 1024 { - output = item.Output[:i] - } else { - output = item.Output[:1024] + output = item.Output + if len(output) > maxBufSize { + output = item.Output[:maxBufSize] } - switch item.RiskLevel { - case model.HighRiskFlag: - riskLevel = model.DangerLevel - default: - riskLevel = model.NormalLevel - } - return s.p.GenerateCommandItem(user, input, output, riskLevel, item.CreatedDate) + return s.p.GenerateCommandItem(user, input, output, item) } // Bridge 桥接两个链接 @@ -113,6 +173,7 @@ func (s *SwitchSession) Bridge(userConn UserConnection, srvConn srvconn.ServerCo userInputMessageChan := make(chan *exchange.RoomMessage, 1) // 处理数据流 userOutChan, srvOutChan := parser.ParseStream(userInputMessageChan, srvInChan) + parser.SetUserInputFilter(s.filterUserInput) defer func() { close(done) @@ -143,15 +204,43 @@ func (s *SwitchSession) Bridge(userConn UserConnection, srvConn srvconn.ServerCo go func() { var ( exitFlag bool - err error - nr int ) + buffer := bytes.NewBuffer(make([]byte, 0, 1024*2)) + /* + 这里使用了一个buffer,将用户输入的数据进行了分包,分包的依据是utf8编码的字符。 + */ + maxLen := 1024 for { - buf := make([]byte, 1024) - nr, err = srvConn.Read(buf) + buf := make([]byte, maxLen) + nr, err2 := srvConn.Read(buf) + validBytes := buf[:nr] if nr > 0 { + isZmodem := parser.zmodemParser.IsStartSession() + if !isZmodem { + bufferLen := buffer.Len() + if bufferLen > 0 || nr == maxLen { + buffer.Write(buf[:nr]) + validBytes = validBytes[:0] + } + remainBytes := buffer.Bytes() + for len(remainBytes) > 0 { + r, size := utf8.DecodeRune(remainBytes) + if r == utf8.RuneError { + // utf8 max 4 bytes + if len(remainBytes) <= 3 { + break + } + } + validBytes = append(validBytes, remainBytes[:size]...) + remainBytes = remainBytes[size:] + } + buffer.Reset() + if len(remainBytes) > 0 { + buffer.Write(remainBytes) + } + } select { - case srvInChan <- buf[:nr]: + case srvInChan <- validBytes: case <-done: exitFlag = true logger.Infof("Session[%s] done", s.ID) @@ -160,8 +249,8 @@ func (s *SwitchSession) Bridge(userConn UserConnection, srvConn srvconn.ServerCo break } } - if err != nil { - logger.Errorf("Session[%s] srv read err: %s", s.ID, err) + if err2 != nil { + logger.Errorf("Session[%s] srv read err: %s", s.ID, err2) break } } @@ -169,16 +258,18 @@ func (s *SwitchSession) Bridge(userConn UserConnection, srvConn srvconn.ServerCo exitSignal <- struct{}{} close(srvInChan) }() - user := s.p.connOpts.user + user := s.p.connOpts.authInfo.User meta := exchange.MetaMessage{ UserId: user.ID, User: user.String(), Created: common.NewNowUTCTime().String(), RemoteAddr: userConn.RemoteAddr(), + TerminalId: userConn.ID(), + Primary: true, + Writable: true, } room.Broadcast(&exchange.RoomMessage{ Event: exchange.ShareJoin, - Body: nil, Meta: meta, }) if parser.zmodemParser != nil { @@ -198,29 +289,14 @@ func (s *SwitchSession) Bridge(userConn UserConnection, srvConn srvconn.ServerCo go func() { for { buf := make([]byte, 1024) - nr, err := userConn.Read(buf) - if nr > 0 { - index := bytes.IndexFunc(buf[:nr], func(r rune) bool { - return r == '\r' || r == '\n' - }) - if index <= 0 || !parser.NeedRecord() { - room.Receive(&exchange.RoomMessage{ - Event: exchange.DataEvent, Body: buf[:nr], - Meta: meta}) - } else { - room.Receive(&exchange.RoomMessage{ - Event: exchange.DataEvent, Body: buf[:index], - Meta: meta}) - time.Sleep(time.Millisecond * 100) - room.Receive(&exchange.RoomMessage{ - Event: exchange.DataEvent, Body: buf[index:nr], - Meta: meta}) - } - } - if err != nil { - logger.Errorf("Session[%s] user read err: %s", s.ID, err) + nr, err1 := userConn.Read(buf) + if err1 != nil { + logger.Errorf("Session[%s] user read err: %s", s.ID, err1) break } + room.Receive(&exchange.RoomMessage{ + Event: exchange.DataEvent, Body: buf[:nr], + Meta: meta}) } logger.Infof("Session[%s] user read end", s.ID) exitSignal <- struct{}{} @@ -233,32 +309,37 @@ func (s *SwitchSession) Bridge(userConn UserConnection, srvConn srvconn.ServerCo select { // 检测是否超过最大空闲时间 case now := <-tick.C: + if s.MaxSessionTime.Before(now) { + msg := lang.T("Session max time reached, disconnect") + logger.Infof("Session[%s] max session time reached, disconnect", s.ID) + s.disconnection(room, parser, replayRecorder, msg) + s.recordSessionFinished(model.ReasonErrMaxSessionTimeout) + return + } + outTime := lastActiveTime.Add(maxIdleTime) if now.After(outTime) { msg := fmt.Sprintf(lang.T("Connect idle more than %d minutes, disconnect"), s.MaxIdleTime) logger.Infof("Session[%s] idle more than %d minutes, disconnect", s.ID, s.MaxIdleTime) - msg = utils.WrapperWarn(msg) - replayRecorder.Record([]byte(msg)) - room.Broadcast(&exchange.RoomMessage{Event: exchange.DataEvent, Body: []byte("\n\r" + msg)}) + s.disconnection(room, parser, replayRecorder, msg) + s.recordSessionFinished(model.ReasonErrIdleDisconnect) return } - if s.p.CheckPermissionExpired(now) { + if s.CheckPermissionExpired(now) { msg := lang.T("Permission has expired, disconnect") logger.Infof("Session[%s] permission has expired, disconnect", s.ID) - msg = utils.WrapperWarn(msg) - replayRecorder.Record([]byte(msg)) - room.Broadcast(&exchange.RoomMessage{Event: exchange.DataEvent, Body: []byte("\n\r" + msg)}) + s.disconnection(room, parser, replayRecorder, msg) + s.recordSessionFinished(model.ReasonErrPermissionExpired) return } continue // 手动结束 case <-s.ctx.Done(): - adminUser := s.loadTerminateAdmin() + adminUser := s.loadOperator() msg := fmt.Sprintf(lang.T("Terminated by admin %s"), adminUser) - msg = utils.WrapperWarn(msg) - replayRecorder.Record([]byte(msg)) logger.Infof("Session[%s]: %s", s.ID, msg) - room.Broadcast(&exchange.RoomMessage{Event: exchange.DataEvent, Body: []byte("\n\r" + msg)}) + s.disconnection(room, parser, replayRecorder, msg) + s.recordSessionFinished(model.ReasonErrAdminTerminate) return // 监控窗口大小变化 case win, ok := <-winCh: @@ -277,6 +358,7 @@ func (s *SwitchSession) Bridge(userConn UserConnection, srvConn srvconn.ServerCo // 经过parse处理的server数据,发给user case p, ok := <-srvOutChan: if !ok { + s.recordSessionFinished(model.ReasonErrConnectDisconnect) return } if parser.NeedRecord() { @@ -290,10 +372,11 @@ func (s *SwitchSession) Bridge(userConn UserConnection, srvConn srvconn.ServerCo // 经过parse处理的user数据,发给server case p, ok := <-userOutChan: if !ok { + s.recordSessionFinished(model.ReasonErrUserClose) return } - if _, err := srvConn.Write(p); err != nil { - logger.Errorf("Session[%s] srvConn write err: %s", s.ID, err) + if _, err1 := srvConn.Write(p); err1 != nil { + logger.Errorf("Session[%s] srvConn write err: %s", s.ID, err1) } case now := <-keepAliveTick.C: @@ -305,11 +388,38 @@ func (s *SwitchSession) Bridge(userConn UserConnection, srvConn srvconn.ServerCo continue case <-userConn.Context().Done(): logger.Infof("Session[%s]: user conn context done", s.ID) + s.recordSessionFinished(model.ReasonErrUserClose) return nil case <-exitSignal: logger.Debugf("Session[%s] end by exit signal", s.ID) + s.recordSessionFinished(model.ReasonErrConnectDisconnect) return + case notifyMsg := <-s.notifyMsgChan: + logger.Infof("Session[%s] notify event: %s", s.ID, notifyMsg.Event) + room.Broadcast(notifyMsg) + continue } lastActiveTime = time.Now() } } +func (s *SwitchSession) disconnection(room *exchange.Room, parser *Parser, replayRecorder *ReplyRecorder, msg string) { + msg = utils.WrapperWarn(msg) + replayRecorder.Record([]byte(msg)) + + roomMessage := &exchange.RoomMessage{Event: exchange.DataEvent, Body: []byte("\n\r" + msg)} + if parser.zmodemParser.IsStartSession() { + expectedSize := len(zmodem.SkipSequence) + len(zmodem.CancelSequence) + roomMessage.Body = make([]byte, 0, expectedSize) + roomMessage.Body = append(roomMessage.Body, zmodem.SkipSequence...) + roomMessage.Body = append(roomMessage.Body, zmodem.CancelSequence...) + } + + room.Broadcast(roomMessage) +} + +func (s *SwitchSession) recordSessionFinished(reason model.SessionLifecycleReasonErr) { + logObj := model.SessionLifecycleLog{Reason: string(reason)} + if err := s.p.jmsService.RecordSessionLifecycleLog(s.ID, model.AssetConnectFinished, logObj); err != nil { + logger.Errorf("Session[%s] record session asset_connect_finished failed: %s", s.ID, err) + } +} diff --git a/pkg/proxy/util.go b/pkg/proxy/util.go index f4bca9080..f6cd36ec6 100644 --- a/pkg/proxy/util.go +++ b/pkg/proxy/util.go @@ -1,10 +1,11 @@ package proxy import ( + "net/url" "strings" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" storage "github.com/jumpserver/koko/pkg/proxy/recorderstorage" ) @@ -12,17 +13,25 @@ type StorageType interface { TypeName() string } -type ReplayStorage interface { +type Storage interface { Upload(gZipFile, target string) error StorageType } +type ReplayStorage interface { + Storage +} + +type FTPFileStorage interface { + Storage +} + type CommandStorage interface { BulkSave(commands []*model.Command) error StorageType } -func NewReplayStorage(jmsService *service.JMService, conf *model.TerminalConfig) ReplayStorage { +func GetStorage(conf *model.TerminalConfig) Storage { cfg := conf.ReplayStorage switch cfg.TypeName { case "azure": @@ -80,10 +89,7 @@ func NewReplayStorage(jmsService *service.JMService, conf *model.TerminalConfig) secretKey = cfg.SecretKey if region == "" && endpoint != "" { - endpointArray := strings.Split(endpoint, ".") - if len(endpointArray) >= 2 { - region = endpointArray[1] - } + region = ParseEndpointRegion(endpoint) } if bucket == "" { bucket = "jumpserver" @@ -117,14 +123,30 @@ func NewReplayStorage(jmsService *service.JMService, conf *model.TerminalConfig) case "null": return storage.NewNullStorage() default: - return storage.ServerStorage{StorageType: "server", JmsService: jmsService} + return nil } } +func NewReplayStorage(jmsService *service.JMService, conf *model.TerminalConfig) ReplayStorage { + replayStorage := GetStorage(conf) + if replayStorage == nil { + replayStorage = storage.ServerStorage{StorageType: "server", JmsService: jmsService} + } + return replayStorage +} + +func NewFTPFileStorage(jmsService *service.JMService, conf *model.TerminalConfig) FTPFileStorage { + ftpStorage := GetStorage(conf) + if ftpStorage == nil { + ftpStorage = storage.FTPServerStorage{StorageType: "server", JmsService: jmsService} + } + return ftpStorage +} + func NewCommandStorage(jmsService *service.JMService, conf *model.TerminalConfig) CommandStorage { cf := conf.CommandStorage - tp, ok := cf["TYPE"] - if !ok { + tp := cf.TypeName + if tp == "" { tp = "server" } /* @@ -132,24 +154,22 @@ func NewCommandStorage(jmsService *service.JMService, conf *model.TerminalConfig 'DOC_TYPE': 'command', 'HOSTS': ['http://172.16.10.122:9200'], 'INDEX': 'jumpserver', - 'OTHER': {'IGNORE_VERIFY_CERTS': True}, + 'OTHER': {'IGNORE_VERIFY_CERTS': True, 'IS_INDEX_DATASTREAM': True}, 'TYPE': 'es' } */ switch tp { case "es", "elasticsearch": - var hosts = make([]string, len(cf["HOSTS"].([]interface{}))) - for i, item := range cf["HOSTS"].([]interface{}) { - hosts[i] = item.(string) - } + var hosts = cf.Hosts var skipVerify bool - index := cf["INDEX"].(string) - docType := cf["DOC_TYPE"].(string) - if otherMap, ok := cf["OTHER"].(map[string]interface{}); ok { - if insecureSkipVerify, ok := otherMap["IGNORE_VERIFY_CERTS"]; ok { - skipVerify = insecureSkipVerify.(bool) - } + var isDataStream bool + index := cf.Index + docType := cf.DocType + if cf.Other != nil { + skipVerify = cf.Other.IgnoreVerifyCerts + isDataStream = cf.Other.IsIndexDatastream } + if index == "" { index = "jumpserver" } @@ -160,11 +180,82 @@ func NewCommandStorage(jmsService *service.JMService, conf *model.TerminalConfig Hosts: hosts, Index: index, DocType: docType, + IsDataStream: isDataStream, InsecureSkipVerify: skipVerify, } + case "influxdb": + var ( + serverURL string + authToken string + bucket string + measurement string + ) + + serverURL = cf.ServerURL + authToken = cf.AuthToken + bucket = cf.Bucket + measurement = cf.Measurement + + if bucket == "" { + bucket = "jumpserver" + } + if measurement == "" { + measurement = "commands" + } + return storage.InfluxdbStorage{ + ServerURL: serverURL, + AuthToken: authToken, + Bucket: bucket, + Measurement: measurement, + } + case "null": return storage.NewNullStorage() default: return storage.ServerStorage{StorageType: "server", JmsService: jmsService} } } + +func ParseEndpointRegion(s string) string { + if strings.Contains(s, amazonawsSuffix) { + return ParseAWSURLRegion(s) + } + endpoint, err := url.Parse(s) + if err != nil { + return s + } + endpoints := strings.Split(endpoint.Hostname(), ".") + if len(endpoints) >= 3 { + return endpoints[len(endpoints)-3] + } + return endpoints[0] +} + +func ParseAWSURLRegion(s string) string { + endpoint, err := url.Parse(s) + if err != nil { + return "" + } + s = endpoint.Hostname() + s = strings.TrimSuffix(s, amazonawsCNSuffix) + s = strings.TrimSuffix(s, amazonawsSuffix) + regions := strings.Split(s, ".") + return regions[len(regions)-1] +} + +const ( + amazonawsCNSuffix = ".amazonaws.com.cn" + amazonawsSuffix = ".amazonaws.com" +) + +// parseBoolValue 解析配置值为布尔类型,支持布尔值和字符串类型 +func parseBoolValue(value any) bool { + switch v := value.(type) { + case bool: + return v + case string: + return v == "true" + default: + return false + } +} diff --git a/pkg/session/manager.go b/pkg/session/manager.go new file mode 100644 index 000000000..7696d6fbc --- /dev/null +++ b/pkg/session/manager.go @@ -0,0 +1,97 @@ +package session + +import ( + "sync" +) + +var ( + sessManager = newSessionManager() +) + +func GetSessionById(id string) (s *Session, ok bool) { + s, ok = sessManager.Get(id) + return +} + +func GetAliveSessionIds() []string { + return sessManager.Range() +} + +func GetAliveSessionTokenIds() []string { + return sessManager.RangeToken() +} + +func GetSessions() []*Session { + return sessManager.GetSessions() +} + +func AddSession(s *Session) { + sessManager.Add(s.ID, s) +} + +func RemoveSession(s *Session) { + sessManager.Delete(s.ID) +} + +func RemoveSessionById(id string) { + sessManager.Delete(id) +} + +func newSessionManager() *sessionManager { + return &sessionManager{ + data: make(map[string]*Session), + } +} + +type sessionManager struct { + data map[string]*Session + sync.Mutex +} + +func (s *sessionManager) Add(id string, sess *Session) { + s.Lock() + defer s.Unlock() + s.data[id] = sess +} +func (s *sessionManager) Get(id string) (sess *Session, ok bool) { + s.Lock() + defer s.Unlock() + sess, ok = s.data[id] + return +} + +func (s *sessionManager) Delete(id string) { + s.Lock() + defer s.Unlock() + delete(s.data, id) +} + +func (s *sessionManager) Range() []string { + s.Lock() + defer s.Unlock() + sids := make([]string, 0, len(s.data)) + for sid := range s.data { + sids = append(sids, sid) + } + + return sids +} + +func (s *sessionManager) RangeToken() []string { + s.Lock() + defer s.Unlock() + tIds := make([]string, 0, len(s.data)) + for _, session := range s.data { + tIds = append(tIds, session.TokenId) + } + + return tIds +} + +func (s *sessionManager) GetSessions() []*Session { + sessions := make([]*Session, 0, len(s.data)) + for _, sess := range s.data { + sessions = append(sessions, sess) + } + return sessions +} diff --git a/pkg/session/session.go b/pkg/session/session.go new file mode 100644 index 000000000..3cff69565 --- /dev/null +++ b/pkg/session/session.go @@ -0,0 +1,27 @@ +package session + +import ( + "fmt" + + "github.com/jumpserver-dev/sdk-go/model" +) + +type TaskFunc func(task *model.TerminalTask) error + +func NewSession(s *model.Session, taskFunc TaskFunc) *Session { + return &Session{Session: s, + handleTaskFunc: taskFunc, + } +} + +type Session struct { + *model.Session + handleTaskFunc func(task *model.TerminalTask) error +} + +func (s *Session) HandleTask(task *model.TerminalTask) error { + if s.handleTaskFunc != nil { + return s.handleTaskFunc(task) + } + return fmt.Errorf("no handle task func") +} diff --git a/pkg/srvconn/conn.go b/pkg/srvconn/conn.go index 7e41aa6fa..34bcf4bfa 100644 --- a/pkg/srvconn/conn.go +++ b/pkg/srvconn/conn.go @@ -2,14 +2,19 @@ package srvconn import ( "bytes" + "context" "encoding/json" "errors" "fmt" "io" + "os" "os/exec" + "path/filepath" "strings" + "sync" "time" + "github.com/jumpserver/koko/pkg/common" "github.com/jumpserver/koko/pkg/localcommand" "github.com/jumpserver/koko/pkg/logger" ) @@ -27,39 +32,91 @@ type Windows struct { const ( ProtocolSSH = "ssh" + ProtocolSFTP = "sftp" ProtocolTELNET = "telnet" ProtocolK8s = "k8s" - ProtocolMySQL = "mysql" - ProtocolMariadb = "mariadb" - ProtocolSQLServer = "sqlserver" - ProtocolRedis = "redis" - ProtocolMongoDB = "mongodb" + ProtocolRedis = "redis" + ProtocolMongoDB = "mongodb" + ProtocolClickHouse = "clickhouse" + + ProtocolMySQL = "mysql" + ProtocolMariadb = "mariadb" + ProtocolSQLServer = "sqlserver" + ProtocolPostgresql = "postgresql" + ProtocolOracle = "oracle" ) +func SupportedDBProtocols() []string { + return []string{ + ProtocolRedis, + ProtocolMongoDB, + + ProtocolMySQL, + ProtocolMariadb, + ProtocolOracle, + ProtocolSQLServer, + ProtocolPostgresql, + ProtocolClickHouse, + } +} + +func SupportedHostProtocols() []string { + return []string{ + ProtocolSSH, + ProtocolTELNET, + } +} + +func SupportedProtocols() []string { + protocols := make([]string, 0, len(supportedMap)) + for k := range supportedMap { + protocols = append(protocols, k) + } + return protocols +} + +type ErrNoClient struct { + Name string +} + +func (e ErrNoClient) Error() string { + return fmt.Sprintf("not found %s client", e.Name) +} + +type ErrUSQLNoSupported struct { + Name string +} + +func (e ErrUSQLNoSupported) Error() string { + return fmt.Sprintf("usql client not supported %s", e.Name) +} + var ( ErrUnSupportedProtocol = errors.New("unsupported protocol") - ErrKubectlClient = errors.New("not found Kubectl client") - - ErrMySQLClient = errors.New("not found MySQL client") - ErrSQLServerClient = errors.New("not found SQLServer client") + ErrKubectlClient = ErrNoClient{"Kubectl"} - ErrRedisClient = errors.New("not found Redis client") - ErrMongoDBClient = errors.New("not found MongoDB client") + ErrRedisClient = ErrNoClient{"Redis"} + ErrMongoDBClient = ErrNoClient{"MongoDB"} ) type supportedChecker func() error var supportedMap = map[string]supportedChecker{ - ProtocolSSH: builtinSupported, - ProtocolTELNET: builtinSupported, - ProtocolK8s: kubectlSupported, - ProtocolMySQL: mySQLSupported, - ProtocolMariadb: mySQLSupported, - ProtocolSQLServer: sqlServerSupported, - ProtocolRedis: redisSupported, - ProtocolMongoDB: mongoDBSupported, + ProtocolSSH: builtinSupported, + ProtocolTELNET: builtinSupported, + ProtocolK8s: kubectlSupported, + + ProtocolRedis: redisSupported, + ProtocolMongoDB: mongoDBSupported, + + ProtocolMySQL: usqlSupportedChecker(ProtocolMySQL), + ProtocolMariadb: usqlSupportedChecker(ProtocolMariadb), + ProtocolSQLServer: usqlSupportedChecker(ProtocolSQLServer), + ProtocolPostgresql: usqlSupportedChecker(ProtocolPostgresql), + ProtocolClickHouse: usqlSupportedChecker(ProtocolClickHouse), + ProtocolOracle: usqlSupportedChecker(ProtocolOracle), } func IsSupportedProtocol(p string) error { @@ -91,19 +148,6 @@ func kubectlSupported() error { return ErrKubectlClient } -func mySQLSupported() error { - checkLine := "mysql -V" - cmd := exec.Command("bash", "-c", checkLine) - out, err := cmd.CombinedOutput() - if err != nil && len(out) == 0 { - return fmt.Errorf("%w: %s", ErrMySQLClient, err) - } - if bytes.HasPrefix(out, []byte("mysql")) { - return nil - } - return ErrMySQLClient -} - func redisSupported() error { checkLine := "redis-cli -v" cmd := exec.Command("bash", "-c", checkLine) @@ -127,20 +171,32 @@ func mongoDBSupported() error { if !bytes.HasSuffix(out, []byte("command not found")) { return nil } - return ErrRedisClient + return ErrMongoDBClient } -func sqlServerSupported() error { - checkLine := "tsql -C" - cmd := exec.Command("bash", "-c", checkLine) - out, err := cmd.CombinedOutput() - if err != nil && len(out) == 0 { - return fmt.Errorf("%w: %s", ErrSQLServerClient, err) - } - if strings.Contains(string(out), "freetds") { +var usqlSupportedProtocols string +var once sync.Once + +func ensureUSQLSupported() { + once.Do(func() { + checkLine := "usql -c '\\drivers'" + cmd := exec.Command("bash", "-c", checkLine) + out, err := cmd.CombinedOutput() + if err != nil && len(out) == 0 { + return + } + usqlSupportedProtocols = string(bytes.TrimSpace(out)) + }) +} + +func usqlSupportedChecker(protocol string) func() error { + ensureUSQLSupported() + return func() error { + if !strings.Contains(usqlSupportedProtocols, protocol) { + return ErrUSQLNoSupported{Name: protocol} + } return nil } - return ErrSQLServerClient } func MatchLoginPrefix(prefix string, dbType string, lcmd *localcommand.LocalCommand) (*localcommand.LocalCommand, error) { @@ -149,18 +205,36 @@ func MatchLoginPrefix(prefix string, dbType string, lcmd *localcommand.LocalComm err error ) prompt := make([]byte, len(prefix)) - nr, err = lcmd.Read(prompt[:]) - if err != nil { - _ = lcmd.Close() - logger.Errorf("%s local pty fd read err: %s", dbType, err) - return lcmd, err - } - if !bytes.Equal(prompt[:nr], []byte(prefix)) { - _ = lcmd.Close() - logger.Errorf("%s login prompt characters did not match: %s", dbType, prompt[:nr]) - err = fmt.Errorf("%s login prompt characters did not match: %s", dbType, prompt[:nr]) - return lcmd, err + var buf strings.Builder + ctx, cancel := context.WithTimeout(context.TODO(), time.Minute) + defer cancel() + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = lcmd.Close() + logger.Errorf("%s login prompt characters matched timeout and closed", dbType) + return + case <-done: + return + } + }() + + for { + nr, err = lcmd.Read(prompt[:]) + if err != nil { + _ = lcmd.Close() + logger.Errorf("%s login prompt characters did not match: %s", dbType, buf.String()) + err = fmt.Errorf("%s login prompt characters did not match: %s", dbType, buf.String()) + return lcmd, err + } + buf.Write(bytes.TrimSpace(prompt[:nr])) + if strings.Contains(buf.String(), prefix) { + logger.Debugf("%s login prompt characters matched %s", dbType, buf.String()) + break + } } + close(done) return lcmd, nil } @@ -179,3 +253,63 @@ func DoLogin(opt *sqlOption, lcmd *localcommand.LocalCommand, dbType string) (*l _, _ = lcmd.Read(clearPassword) return lcmd, nil } + +func StoreCAFileToLocal(caCert string) (string, error) { + return createTmpFileToLocal(caCert, 0666) +} + +func StorePrivateKeyFileToLocal(caCert string) (string, error) { + return createTmpFileToLocal(caCert, 0600) +} + +func createTmpFileToLocal(content string, perm os.FileMode) (string, error) { + + if content == "" { + return "", nil + } + + baseDir := filepath.Join(os.TempDir(), ".ca_temp") + _, err := os.Stat(baseDir) + if os.IsNotExist(err) { + err = os.Mkdir(baseDir, os.ModePerm) + if err != nil { + return "", err + } + } + + filename := fmt.Sprintf("%s.pem", common.UUID()) + path := filepath.Join(baseDir, filename) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, perm) + if err != nil { + return "", err + } + defer file.Close() + _, _ = file.WriteString(content) + + return path, err +} + +func ClearTempFileDelay(sleepTime time.Duration, filepath ...string) { + go func() { + time.Sleep(sleepTime) + for _, file := range filepath { + _, err := os.Stat(file) + if err == nil { + logger.Debugf("Clean up file: %s", file) + if err = os.Remove(file); err != nil { + logger.Errorf("Clean up file err: %s", err) + } + } + } + }() +} + +var cleanLineExitCommand = []byte{ + CharCTRLE, CharCleanLine, '\r', '\n', + 'e', 'x', 'i', 't', '\r', '\n', +} + +const ( + CharCleanLine = '\x15' + CharCTRLE = '\x05' +) diff --git a/pkg/srvconn/conn_k8s.go b/pkg/srvconn/conn_k8s.go index 163b6445b..f5b5b787d 100644 --- a/pkg/srvconn/conn_k8s.go +++ b/pkg/srvconn/conn_k8s.go @@ -1,13 +1,19 @@ package srvconn import ( + "context" "errors" "fmt" "os" - "os/exec" "path/filepath" + "strconv" "strings" + authorizationv1 "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "github.com/jumpserver/koko/pkg/config" "github.com/jumpserver/koko/pkg/localcommand" "github.com/jumpserver/koko/pkg/logger" @@ -15,38 +21,39 @@ import ( ) var ( - InValidToken = errors.New("invalid token") + ErrValidToken = errors.New("invalid token") _ ServerConnection = (*K8sCon)(nil) ) const ( k8sInitFilename = "init-kubectl.sh" - - checkTokenCommand = `kubectl --insecure-skip-tls-verify=%s --token=%s --server=%s auth can-i get pods` ) -func isValidK8sUserToken(o *k8sOptions) bool { - skipVerifyTls := "true" - token := o.Token - server := o.ClusterServer - if !o.IsSkipTls { - skipVerifyTls = "false" - } - c := exec.Command("bash", "-c", - fmt.Sprintf(checkTokenCommand, skipVerifyTls, token, server)) - out, err := c.CombinedOutput() +// 类似于 `kubectl --insecure-skip-tls-verify=%s --token=%s --server=%s auth can-i get pods` + +func IsValidK8sUserToken(k8sCfg *rest.Config) bool { + client, err := kubernetes.NewForConfig(k8sCfg) if err != nil { - logger.Info(err) - } - result := strings.TrimSpace(string(out)) - switch strings.ToLower(result) { - case "yes", "no": - logger.Info("K8sCon check token success") - return true - } - logger.Errorf("K8sCon check token err: %s", result) - return false + logger.Errorf("K8sCon new config err: %s", err) + return false + } + sar := &authorizationv1.SelfSubjectAccessReview{ + Spec: authorizationv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Verb: "get", + Resource: "pods", + }, + }, + } + authClient := client.AuthorizationV1() + resp, err2 := authClient.SelfSubjectAccessReviews().Create(context.TODO(), sar, metav1.CreateOptions{}) + if err2 != nil { + logger.Errorf("K8sCon check token pods auth err: %s", err2) + return false + } + logger.Debugf("K8sCon check token pods auth resp: %+v", resp) + return true } func NewK8sConnection(ops ...K8sOption) (*K8sCon, error) { @@ -60,15 +67,20 @@ func NewK8sConnection(ops ...K8sOption) (*K8sCon, error) { for _, setter := range ops { setter(args) } - if !isValidK8sUserToken(args) { - return nil, InValidToken + + k8sCfg := args.K8sCfg() + if !IsValidK8sUserToken(k8sCfg) { + return nil, ErrValidToken } - _, err := utils.Encrypt(args.Token, config.CipherKey) + kubeProxy := NewKubectlProxyConn(args) + err := kubeProxy.Start() if err != nil { - return nil, fmt.Errorf("%w: encrypt k8s token failed %s", InValidToken, err) + logger.Errorf("K8sCon start proxy err: %s", err) + return nil, fmt.Errorf("K8sCon start proxy err: %w", err) } - lcmd, err := startK8SLocalCommand(args) + envs := kubeProxy.Env() + lcmd, err := startK8SLocalCommand(envs) if err != nil { logger.Errorf("K8sCon start local pty err: %s", err) return nil, fmt.Errorf("K8sCon start local pty err: %w", err) @@ -78,10 +90,11 @@ func NewK8sConnection(ops ...K8sOption) (*K8sCon, error) { _ = lcmd.Close() return nil, err } - return &K8sCon{options: args, LocalCommand: lcmd}, nil + return &K8sCon{options: args, LocalCommand: lcmd, proxy: kubeProxy}, nil } type K8sCon struct { + proxy *KubectlProxyConn options *k8sOptions *localcommand.LocalCommand } @@ -90,16 +103,33 @@ func (k *K8sCon) KeepAlive() error { return nil } +func (k *K8sCon) Close() error { + _ = k.LocalCommand.Close() + return k.proxy.Close() +} + type k8sOptions struct { ClusterServer string // https://172.16.10.51:8443 Username string // user 系统用户名 Token string // 授权token IsSkipTls bool ExtraEnv map[string]string + DEBUG bool win Windows } +func (o *k8sOptions) K8sCfg() *rest.Config { + kubeConf := &rest.Config{ + Host: o.ClusterServer, + BearerToken: o.Token, + } + if o.IsSkipTls { + kubeConf.Insecure = true + } + return kubeConf +} + func (o *k8sOptions) Env() []string { token, err := utils.Encrypt(o.Token, config.CipherKey) if err != nil { @@ -110,16 +140,19 @@ func (o *k8sOptions) Env() []string { if !o.IsSkipTls { skipTls = "false" } + k8sName := strings.Trim(strconv.Quote(o.ExtraEnv["K8sName"]), "\"") + k8sName = strings.ReplaceAll(k8sName, "`", "\\`") return []string{ fmt.Sprintf("KUBECTL_USER=%s", o.Username), fmt.Sprintf("KUBECTL_CLUSTER=%s", o.ClusterServer), fmt.Sprintf("KUBECTL_INSECURE_SKIP_TLS_VERIFY=%s", skipTls), fmt.Sprintf("K8S_ENCRYPTED_TOKEN=%s", token), fmt.Sprintf("WELCOME_BANNER=%s", config.KubectlBanner), + fmt.Sprintf("K8S_NAME=%s", k8sName), } } -func startK8SLocalCommand(args *k8sOptions) (*localcommand.LocalCommand, error) { +func startK8SLocalCommand(env []string) (*localcommand.LocalCommand, error) { pwd, _ := os.Getwd() shPath := filepath.Join(pwd, k8sInitFilename) argv := []string{ @@ -128,7 +161,7 @@ func startK8SLocalCommand(args *k8sOptions) (*localcommand.LocalCommand, error) "--mount-proc", shPath, } - return localcommand.New("unshare", argv, localcommand.WithEnv(args.Env())) + return localcommand.New("unshare", argv, localcommand.WithEnv(env)) } type K8sOption func(*k8sOptions) @@ -168,3 +201,9 @@ func K8sPtyWin(win Windows) K8sOption { args.win = win } } + +func K8sDebug(debug bool) K8sOption { + return func(args *k8sOptions) { + args.DEBUG = debug + } +} diff --git a/pkg/srvconn/conn_k8s_proxy.go b/pkg/srvconn/conn_k8s_proxy.go new file mode 100644 index 000000000..ba4224756 --- /dev/null +++ b/pkg/srvconn/conn_k8s_proxy.go @@ -0,0 +1,134 @@ +package srvconn + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + + "github.com/jumpserver/koko/pkg/common" + "github.com/jumpserver/koko/pkg/config" + "github.com/jumpserver/koko/pkg/logger" +) + +var k8sProxyDirname = "k8s_proxy" + +func GetK8sProxyDir() string { + pwd, _ := os.Getwd() + dirPath := filepath.Join(pwd, k8sProxyDirname) + if _, err := os.Stat(dirPath); os.IsNotExist(err) { + _ = os.Mkdir(dirPath, 0700) + } + return dirPath +} + +func NewKubectlProxyConn(opt *k8sOptions) *KubectlProxyConn { + return &KubectlProxyConn{opts: opt, Id: common.UUID()} +} + +type KubectlProxyConn struct { + Id string + opts *k8sOptions + + proxyCmd *exec.Cmd + configPath string + once sync.Once +} + +func (k *KubectlProxyConn) Close() error { + var err error + k.once.Do(func() { + if k.proxyCmd != nil { + err = k.proxyCmd.Process.Kill() + } + if k.configPath != "" { + _ = os.Remove(k.configPath) + } + gloablTokenMaps.Delete(k.Id) + _ = os.Remove(k.UnixPath()) + }) + return err +} + +func (k *KubectlProxyConn) Start() error { + var err error + k.configPath, err = k.CreateKubeConfig(k.opts.ClusterServer, k.opts.ExtraEnv["Namespace"], k.opts.Token) + if err != nil { + return err + } + + /* + kubetcl proxy --kubeconfig=path --unix-socket=port --api-prefix=/ + */ + logger.Infof("kubeconfig: %s", k.configPath) + k.proxyCmd = exec.Command("kubectl", "proxy", + "--disable-filter=true", + fmt.Sprintf("--kubeconfig=%s", k.configPath), + fmt.Sprintf("--unix-socket=%s", k.UnixPath()), + "--api-prefix=/") + + err = k.proxyCmd.Start() + go func() { + _ = k.proxyCmd.Wait() + logger.Infof("kubectl proxy id %s %s exit", k.Id, k.opts.ClusterServer) + }() + return err +} + +func (k *KubectlProxyConn) UnixPath() string { + k8sDir := GetK8sProxyDir() + return filepath.Join(k8sDir, fmt.Sprintf("proxy-%s.sock", k.Id)) +} + +func (k *KubectlProxyConn) CreateKubeConfig(server, namespace, token string) (string, error) { + k8sDir := GetK8sProxyDir() + configPath := filepath.Join(k8sDir, fmt.Sprintf("config-%s", k.Id)) + configContent := fmt.Sprintf(proxyconfigTmpl, server, namespace, token) + err := os.WriteFile(configPath, []byte(configContent), 0600) + return configPath, err +} + +func (k *KubectlProxyConn) Env() []string { + o := k.opts + skipTls := "true" + if !o.IsSkipTls { + skipTls = "false" + } + clusterServer := k.UnixPath() + gloablTokenMaps.Store(k.Id, clusterServer) + k8sName := strings.Trim(strconv.Quote(o.ExtraEnv["K8sName"]), "\"") + k8sName = strings.ReplaceAll(k8sName, "`", "\\`") + return []string{ + fmt.Sprintf("KUBECTL_USER=%s", o.Username), + fmt.Sprintf("KUBECTL_CLUSTER=%s", k8sReverseProxyURL), + fmt.Sprintf("KUBECTL_NAMESPACE=%s", o.ExtraEnv["Namespace"]), + fmt.Sprintf("KUBECTL_INSECURE_SKIP_TLS_VERIFY=%s", skipTls), + fmt.Sprintf("KUBECTL_TOKEN=%s", k.Id), + fmt.Sprintf("WELCOME_BANNER=%s", config.KubectlBanner), + fmt.Sprintf("K8S_NAME=%s", k8sName), + } +} + +var proxyconfigTmpl = `apiVersion: v1 +clusters: +- cluster: + insecure-skip-tls-verify: true + server: %s + name: kubernetes +contexts: +- context: + cluster: kubernetes + user: JumpServer-user + namespace: %s + name: kubernetes +current-context: kubernetes +kind: Config +preferences: {} +users: +- name: JumpServer-user + user: + token: %s +` diff --git a/pkg/srvconn/conn_k8s_reverse_server.go b/pkg/srvconn/conn_k8s_reverse_server.go new file mode 100644 index 000000000..be204957f --- /dev/null +++ b/pkg/srvconn/conn_k8s_reverse_server.go @@ -0,0 +1,94 @@ +package srvconn + +import ( + "context" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "strings" + "sync" + + "github.com/jumpserver/koko/pkg/logger" +) + +var ( + gloablTokenMaps = sync.Map{} + K8sReverseProxyPort = 5002 + k8sReverseProxyURL = "https://127.0.0.1:5002" +) + +func init() { + reverseProxy := NewK8sReverseProxy(K8sReverseProxyPort) + go func() { + if err := reverseProxy.Start(); err != nil { + logger.Errorf("k8s reverse proxy start failed: %v", err) + } + }() +} + +type K8sReverseProxy struct { + Port int + + server *http.Server +} + +func NewK8sReverseProxy(port int) *K8sReverseProxy { + return &K8sReverseProxy{ + Port: port, + } +} + +func (k *K8sReverseProxy) Start() error { + mux := http.NewServeMux() + mux.HandleFunc("/", k.ServeHTTP) + k.server = &http.Server{ + Addr: ":" + strconv.Itoa(k.Port), + Handler: mux, + } + return k.server.ListenAndServeTLS("server.crt", "server.key") +} + +func (k *K8sReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + bearerToken := r.Header.Get("Authorization") + if bearerToken == "" { + logger.Error("k8s proxy reverse request unauthorized: without token") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + tokens := strings.SplitN(bearerToken, " ", 2) + schemaName := strings.TrimSpace(tokens[0]) + if len(tokens) != 2 || schemaName != "Bearer" { + logger.Errorf("k8s proxy reverse request unauthorized: invalid token: %s", bearerToken) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + tokenId := strings.TrimSpace(tokens[1]) + val, ok := gloablTokenMaps.Load(tokenId) + if !ok { + logger.Error("k8s proxy reverse request unauthorized: token not found") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + unixSocketPath := val.(string) + targetUrl := &url.URL{Scheme: "http", Host: "unix"} + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", unixSocketPath) + }, + } + proxy := httputil.ReverseProxy{ + Transport: transport, + Director: func(req *http.Request) { + req.URL.Scheme = targetUrl.Scheme + req.URL.Host = targetUrl.Host + req.Header.Set("Host", targetUrl.Host) + req.Header.Del("Authorization") + }, + } + logger.Debugf("k8s reverse proxy %s request start: %s", tokenId, r.URL.Path) + proxy.ServeHTTP(w, r) + logger.Debugf("k8s reverse proxy %s request end: %s", tokenId, r.URL.Path) +} diff --git a/pkg/srvconn/conn_mongodb.go b/pkg/srvconn/conn_mongodb.go index cb47ec2c8..49082aad3 100644 --- a/pkg/srvconn/conn_mongodb.go +++ b/pkg/srvconn/conn_mongodb.go @@ -2,13 +2,18 @@ package srvconn import ( "context" - "fmt" + "net" + "net/url" "os" "strconv" + "strings" + "time" - "github.com/jumpserver/koko/pkg/localcommand" + "github.com/jumpserver/koko/pkg/logger" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/jumpserver/koko/pkg/localcommand" ) const ( @@ -25,11 +30,15 @@ func NewMongoDBConnection(ops ...SqlOption) (*MongoDBConn, error) { err error ) args := &sqlOption{ - Username: os.Getenv("USER"), - Password: os.Getenv("PASSWORD"), - Host: "127.0.0.1", - Port: 27017, - DBName: "test", + Username: os.Getenv("USER"), + Password: os.Getenv("PASSWORD"), + Host: "127.0.0.1", + Port: 27017, + DBName: "test", + UseSSL: false, + CaCert: "", + CertKey: "", + AllowInvalidCert: false, win: Windows{ Width: 80, Height: 120, @@ -38,6 +47,21 @@ func NewMongoDBConnection(ops ...SqlOption) (*MongoDBConn, error) { for _, setter := range ops { setter(args) } + + if args.UseSSL { + caCertPath, err := StoreCAFileToLocal(args.CaCert) + if err != nil { + return nil, err + } + certKeyPath, err := StoreCAFileToLocal(args.CertKey) + if err != nil { + return nil, err + } + args.CaCertPath = caCertPath + args.CertKeyPath = certKeyPath + defer ClearTempFileDelay(time.Minute, caCertPath, certKeyPath) + } + if err := checkMongoDBAccount(args); err != nil { return nil, err } @@ -64,20 +88,23 @@ func (conn *MongoDBConn) KeepAlive() error { } func (conn *MongoDBConn) Close() error { - _, _ = conn.Write([]byte("\r\nexit\r\n")) + _, _ = conn.Write(cleanLineExitCommand) return conn.LocalCommand.Close() } func startMongoDBCommand(opt *sqlOption) (lcmd *localcommand.LocalCommand, err error) { cmd := opt.MongoDBCommandArgs() - lcmd, err = localcommand.New("mongosh", cmd, localcommand.WithPtyWin(opt.win.Width, opt.win.Height)) + opts, err := BuildNobodyWithOpts(localcommand.WithPtyWin(opt.win.Width, opt.win.Height)) + if err != nil { + logger.Errorf("build nobody with opts error: %s", err) + return nil, err + } + tmpWorkDir := os.TempDir() + opts = append(opts, localcommand.WithWorkDir(tmpWorkDir)) + lcmd, err = localcommand.New("mongosh", cmd, opts...) if err != nil { return nil, err } - // 清除掉连接信息 - prefix := fmt.Sprintf("mongosh mongodb://%s:%s/%s?directConnection=true", opt.Host, strconv.Itoa(opt.Port), opt.DBName) - prompt := make([]byte, len(prefix)+7) - _, _ = lcmd.Read(prompt[:]) if opt.Password != "" { lcmd, err = MatchLoginPrefix(mongodbPrompt, "MongoDB", lcmd) if err != nil { @@ -91,27 +118,142 @@ func startMongoDBCommand(opt *sqlOption) (lcmd *localcommand.LocalCommand, err e return lcmd, nil } +func addMongoParamsWithSSL(args *sqlOption, params map[string]string) { + if args.UseSSL { + params["tls"] = "true" + if args.CaCertPath != "" { + params["tlsCAFile"] = args.CaCertPath + } + if args.CertKeyPath != "" { + params["tlsCertificateKeyFile"] = args.CertKeyPath + } + if args.AllowInvalidCert { + params["tlsInsecure"] = "true" + } + } +} + +func (opt *sqlOption) GetAuthSource() string { + // authSource 默认是 admin,通过 platform 的 protocol 设置,修改这个认证的值 + // https://www.mongodb.com/docs/manual/reference/connection-string/#mongodb-urioption-urioption.authSource + if opt.AuthSource == "" { + return "admin" + } + return opt.AuthSource +} + +func (opt *sqlOption) GetConnectionOptions() map[string]string { + if opt.ConnectionOptions == "" { + return nil + } + opts := strings.Split(opt.ConnectionOptions, "&") + if len(opts) == 0 { + return nil + } + optMap := make(map[string]string, len(opts)) + for _, item := range opts { + kv := strings.Split(item, "=") + if len(kv) != 2 { + continue + } + optMap[kv[0]] = kv[1] + + } + return optMap +} + +func (opt *sqlOption) GetParams() (params map[string]string) { + params = map[string]string{ + "authSource": opt.GetAuthSource(), + } + connectionOpts := opt.GetConnectionOptions() + if len(connectionOpts) > 0 { + for k, v := range connectionOpts { + params[k] = v + } + } + addMongoParamsWithSSL(opt, params) + return +} + func (opt *sqlOption) MongoDBCommandArgs() []string { - attr := fmt.Sprintf("mongodb://%s:%s/%s", opt.Host, strconv.Itoa(opt.Port), opt.DBName) - params := []string{ - attr, "--username", opt.Username, + host := net.JoinHostPort(opt.Host, strconv.Itoa(opt.Port)) + params := opt.GetParams() + uri := BuildMongoDBURI( + MongoHost(host), + MongoDBName(opt.DBName), + MongoParams(params), + ) + uriParams := []string{ + uri, "--username", opt.Username, } - return params + return uriParams } func checkMongoDBAccount(args *sqlOption) error { - addr := fmt.Sprintf("mongodb://%s:%s@%s:%s", args.Username, args.Password, args.Host, strconv.Itoa(args.Port)) - - clientOptions := options.Client().ApplyURI(addr) + host := net.JoinHostPort(args.Host, strconv.Itoa(args.Port)) + params := args.GetParams() + uri := BuildMongoDBURI( + MongoHost(host), + MongoAuth(args.Username, args.Password), + MongoDBName(args.DBName), + MongoParams(params), + ) + clientOptions := options.Client().ApplyURI(uri) client, err := mongo.Connect(context.TODO(), clientOptions) if err != nil { return err } - defer client.Disconnect(context.TODO()) - + defer func() { + _ = client.Disconnect(context.TODO()) + }() err = client.Ping(context.TODO(), nil) if err != nil { return err } return nil } + +type MongoOpt func(*url.URL) + +func BuildMongoDBURI(opts ...MongoOpt) string { + var mongoURI url.URL + mongoURI.Scheme = "mongodb" + for _, setter := range opts { + setter(&mongoURI) + } + return mongoURI.String() +} + +func MongoHost(host string) MongoOpt { + return func(u *url.URL) { + u.Host = host + } +} + +func MongoAuth(user, password string) MongoOpt { + return func(u *url.URL) { + if user == "" || password == "" { + return + } + u.User = url.UserPassword(user, password) + } +} + +func MongoDBName(dbName string) MongoOpt { + return func(u *url.URL) { + u.Path = dbName + } +} + +func MongoParams(params ...map[string]string) MongoOpt { + return func(u *url.URL) { + values := url.Values{} + for i := range params { + for k, v := range params[i] { + values.Set(k, v) + } + } + u.RawQuery = values.Encode() + } +} diff --git a/pkg/srvconn/conn_mysql.go b/pkg/srvconn/conn_mysql.go deleted file mode 100644 index fa7d464c9..000000000 --- a/pkg/srvconn/conn_mysql.go +++ /dev/null @@ -1,306 +0,0 @@ -package srvconn - -import ( - "bytes" - "database/sql" - "fmt" - "io/ioutil" - "net" - "os" - "os/user" - "path/filepath" - "runtime" - "strconv" - "sync" - "syscall" - "time" - - _ "github.com/go-sql-driver/mysql" - - "github.com/jumpserver/koko/pkg/localcommand" - "github.com/jumpserver/koko/pkg/logger" -) - -const ( - mysqlPrompt = "Enter password: " - - mysqlShellFilename = "mysql" -) - -var ( - mysqlShellPath = "" - - _ ServerConnection = (*MySQLConn)(nil) -) - -const mysqlTemplate = `#!/bin/bash -set -e -mkdir -p /nonexistent -mount -t tmpfs -o size=10M tmpfs /nonexistent -cd /nonexistent -export HOME=/nonexistent -export TMPDIR=/nonexistent -export LANG=en_US.UTF-8 -export TERM=xterm -exec su -s /bin/bash --command="mysql ${EXTRAARGS} --user=${USERNAME} --host=${HOSTNAME} --port=${PORT} --password ${DATABASE}" nobody -` - -var mysqlOnce sync.Once - -func NewMySQLConnection(ops ...SqlOption) (*MySQLConn, error) { - args := &sqlOption{ - Username: os.Getenv("USER"), - Password: os.Getenv("PASSWORD"), - Host: "127.0.0.1", - Port: 3306, - DBName: "", - win: Windows{ - Width: 80, - Height: 120, - }, - } - for _, setter := range ops { - setter(args) - } - if err := checkMySQLAccount(args); err != nil { - return nil, err - } - lCmd, err := startMySQLCommand(args) - if err != nil { - return nil, err - } - err = lCmd.SetWinSize(args.win.Width, args.win.Height) - if err != nil { - _ = lCmd.Close() - return nil, err - } - return &MySQLConn{options: args, LocalCommand: lCmd}, nil -} - -type MySQLConn struct { - options *sqlOption - *localcommand.LocalCommand -} - -func (conn *MySQLConn) KeepAlive() error { - return nil -} - -func (conn *MySQLConn) Close() error { - _, _ = conn.Write([]byte("\r\nexit\r\n")) - return conn.LocalCommand.Close() -} - -func startMySQLCommand(opt *sqlOption) (lcmd *localcommand.LocalCommand, err error) { - initOnceLinuxMySQLShellFile() - if mysqlShellPath != "" { - if lcmd, err = startMySQLNameSpaceCommand(opt); err == nil { - if lcmd, err = tryManualLoginMySQLServer(opt, lcmd); err == nil { - return lcmd, nil - } - } - } - if lcmd, err = startMySQLNormalCommand(opt); err != nil { - return nil, err - } - return tryManualLoginMySQLServer(opt, lcmd) -} - -func startMySQLNameSpaceCommand(opt *sqlOption) (*localcommand.LocalCommand, error) { - argv := []string{ - "--fork", - "--pid", - "--mount-proc", - mysqlShellPath, - } - return localcommand.New("unshare", argv, localcommand.WithEnv(opt.Envs())) -} - -func startMySQLNormalCommand(opt *sqlOption) (*localcommand.LocalCommand, error) { - // 使用 nobody 用户的权限 - nobody, err := user.Lookup("nobody") - if err != nil { - logger.Errorf("lookup nobody user err: %s", err) - return nil, err - } - uid, _ := strconv.Atoi(nobody.Uid) - gid, _ := strconv.Atoi(nobody.Gid) - - return localcommand.New("mysql", opt.CommandArgs(), localcommand.WithEnv(opt.Envs()), - localcommand.WithCmdCredential(&syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)})) -} - -func tryManualLoginMySQLServer(opt *sqlOption, lcmd *localcommand.LocalCommand) (*localcommand.LocalCommand, error) { - var ( - nr int - err error - ) - prompt := [len(mysqlPrompt)]byte{} - nr, err = lcmd.Read(prompt[:]) - if err != nil { - _ = lcmd.Close() - logger.Errorf("Mysql local pty fd read err: %s", err) - return lcmd, err - - } - if !bytes.Equal(prompt[:nr], []byte(mysqlPrompt)) { - _ = lcmd.Close() - logger.Errorf("Mysql login prompt characters did not match: %s", prompt[:nr]) - err = fmt.Errorf("mysql login prompt characters did not match: %s", prompt[:nr]) - return lcmd, err - } - - // 输入密码, 登录 MySQL - _, err = lcmd.Write([]byte(opt.Password + "\r\n")) - if err != nil { - _ = lcmd.Close() - logger.Errorf("Mysql local pty write err: %s", err) - return lcmd, fmt.Errorf("mysql conn err: %s", err) - } - return lcmd, nil -} - -func initOnceLinuxMySQLShellFile() { - mysqlOnce.Do(func() { - // Linux系统 初始化 MySQL 命令文件 - switch runtime.GOOS { - case "linux": - if dir, err := os.Getwd(); err == nil { - TmpMysqlShellPath := filepath.Join(dir, mysqlShellFilename) - if _, err := os.Stat(TmpMysqlShellPath); err == nil { - mysqlShellPath = TmpMysqlShellPath - logger.Infof("Already init MySQL bash file: %s", TmpMysqlShellPath) - return - } - err = ioutil.WriteFile(TmpMysqlShellPath, []byte(mysqlTemplate), os.FileMode(0755)) - if err != nil { - logger.Errorf("Init MySQL bash file failed: %s", err) - return - } - mysqlShellPath = TmpMysqlShellPath - } - logger.Infof("Init MySQL bash file: %s", mysqlShellPath) - } - }) -} - -type sqlOption struct { - Username string - Password string - DBName string - Host string - Port int - - win Windows - disableMySQLAutoRehash bool -} - -func (opt *sqlOption) CommandArgs() []string { - args := make([]string, 0, 6) - authRehashFlag := "--auto-rehash" - if opt.disableMySQLAutoRehash { - authRehashFlag = "--no-auto-rehash" - } - args = append(args, authRehashFlag) - args = append(args, fmt.Sprintf("--user=%s", opt.Username)) - args = append(args, fmt.Sprintf("--host=%s", opt.Host)) - args = append(args, fmt.Sprintf("--port=%d", opt.Port)) - args = append(args, "--password") - args = append(args, opt.DBName) - return args -} - -func (opt *sqlOption) Envs() []string { - extraArgs := "--auto-rehash" - if opt.disableMySQLAutoRehash { - extraArgs = "--no-auto-rehash" - } - envs := make([]string, 0, 6) - envs = append(envs, fmt.Sprintf("USERNAME=%s", opt.Username)) - envs = append(envs, fmt.Sprintf("HOSTNAME=%s", opt.Host)) - envs = append(envs, fmt.Sprintf("PORT=%d", opt.Port)) - envs = append(envs, fmt.Sprintf("DATABASE=%s", opt.DBName)) - envs = append(envs, fmt.Sprintf("EXTRAARGS=%s", extraArgs)) - return envs -} - -func (opt *sqlOption) DataSourceName() string { - // "user:password@tcp(127.0.0.1:3306)/hello" - addr := net.JoinHostPort(opt.Host, strconv.Itoa(opt.Port)) - return fmt.Sprintf("%s:%s@tcp(%s)/%s", - opt.Username, - opt.Password, - addr, - opt.DBName, - ) -} - -type SqlOption func(*sqlOption) - -func SqlUsername(username string) SqlOption { - return func(args *sqlOption) { - args.Username = username - } -} - -func SqlPassword(password string) SqlOption { - return func(args *sqlOption) { - args.Password = password - } -} - -func SqlDBName(dbName string) SqlOption { - return func(args *sqlOption) { - args.DBName = dbName - } -} - -func SqlHost(host string) SqlOption { - return func(args *sqlOption) { - args.Host = host - } -} - -func SqlPort(port int) SqlOption { - return func(args *sqlOption) { - args.Port = port - } -} - -func SqlPtyWin(win Windows) SqlOption { - return func(args *sqlOption) { - args.win = win - } -} - -func MySQLDisableAutoReHash() SqlOption { - return func(args *sqlOption) { - args.disableMySQLAutoRehash = true - } -} - -const ( - mySQLMaxConnCount = 1 - mySQLMaxIdleTime = time.Second * 15 -) - -func checkMySQLAccount(args *sqlOption) error { - return checkDatabaseAccountValidate("mysql", args.DataSourceName()) -} - -func checkDatabaseAccountValidate(driveName, datasourceName string) error { - db, err := sql.Open(driveName, datasourceName) - if err != nil { - return err - } - db.SetMaxOpenConns(mySQLMaxConnCount) - db.SetMaxIdleConns(mySQLMaxConnCount) - db.SetConnMaxLifetime(mySQLMaxIdleTime) - db.SetConnMaxIdleTime(mySQLMaxIdleTime) - defer db.Close() - err = db.Ping() - if err != nil { - return err - } - return nil -} diff --git a/pkg/srvconn/conn_nobody.go b/pkg/srvconn/conn_nobody.go new file mode 100644 index 000000000..3af29e1c8 --- /dev/null +++ b/pkg/srvconn/conn_nobody.go @@ -0,0 +1,30 @@ +package srvconn + +import ( + "os/user" + "strconv" + "syscall" + + "github.com/jumpserver/koko/pkg/localcommand" +) + +var debug string + +func BuildNobodyWithOpts(opts ...localcommand.Option) (nobodyOpts []localcommand.Option, err error) { + nobodyOpts = make([]localcommand.Option, 0, len(opts)+1) + nobodyOpts = append(nobodyOpts, opts...) + envs := make([]string, 0, 2) + nobodyOpts = append(nobodyOpts, localcommand.WithEnv(envs)) + if debug == "true" { + return nobodyOpts, nil + } + nobody, err := user.Lookup("nobody") + if err != nil { + return nil, err + } + uid, _ := strconv.Atoi(nobody.Uid) + gid, _ := strconv.Atoi(nobody.Gid) + nobodyCredential := &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)} + nobodyOpts = append(nobodyOpts, localcommand.WithCmdCredential(nobodyCredential)) + return nobodyOpts, nil +} diff --git a/pkg/srvconn/conn_openai.go b/pkg/srvconn/conn_openai.go new file mode 100644 index 000000000..800416362 --- /dev/null +++ b/pkg/srvconn/conn_openai.go @@ -0,0 +1,180 @@ +package srvconn + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + + "github.com/sashabaranov/go-openai" + + "github.com/jumpserver/koko/pkg/logger" +) + +type TransportOptions struct { + UseProxy bool + ProxyURL *url.URL + SkipCertificate bool +} + +type TransportOption func(*TransportOptions) + +func WithProxy(proxyURL string) TransportOption { + UseProxy := proxyURL != "" + proxy, err := url.Parse(proxyURL) + if err != nil { + proxy = nil + UseProxy = false + logger.Errorf("Proxy URL parse error: %s", err.Error()) + } + return func(opts *TransportOptions) { + opts.UseProxy = UseProxy + opts.ProxyURL = proxy + } +} + +func WithSkipCertificate(skip bool) TransportOption { + return func(opts *TransportOptions) { + opts.SkipCertificate = skip + } +} + +func NewCustomTransport(options ...TransportOption) *http.Transport { + transportOpts := &TransportOptions{} + + for _, opt := range options { + opt(transportOpts) + } + + tlsConfig := &tls.Config{InsecureSkipVerify: transportOpts.SkipCertificate} + transport := &http.Transport{TLSClientConfig: tlsConfig} + + if transportOpts.UseProxy { + transport.Proxy = http.ProxyURL(transportOpts.ProxyURL) + } + + return transport +} + +func NewOpenAIClient(authToken, baseURL, proxy string) *openai.Client { + config := openai.DefaultConfig(authToken) + if baseURL != "" { + config.BaseURL = strings.TrimRight(baseURL, "/") + } + transport := NewCustomTransport( + WithProxy(proxy), WithSkipCertificate(true), + ) + config.HTTPClient = &http.Client{ + Transport: transport, + } + return openai.NewClientWithConfig(config) +} + +type OpenAIConn struct { + Id string + Client *openai.Client + Model string + Prompt string + Question string + Context []openai.ChatCompletionMessage + IsReasoning bool + AnswerCh chan string + DoneCh chan string + Type string +} + +func (conn *OpenAIConn) Chat(interruptCurrentChat *bool) { + ctx := context.Background() + + messages := append(conn.Context, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleUser, + Content: conn.Question, + }) + + systemPrompt := conn.Prompt + if conn.Type == "gpt" { + systemPrompt += " 请不要提供与政治相关的信息。" + } + + if systemPrompt != "" { + messages = append([]openai.ChatCompletionMessage{ + { + Role: openai.ChatMessageRoleSystem, + Content: systemPrompt, + }, + }, messages...) + } + + req := openai.ChatCompletionRequest{ + Model: conn.Model, + Messages: messages, + Stream: true, + } + + stream, err := conn.Client.CreateChatCompletionStream(ctx, req) + if err != nil { + conn.DoneCh <- err.Error() + return + } + defer func(stream *openai.ChatCompletionStream) { + err := stream.Close() + if err != nil { + logger.Errorf("openai stream close error: %s", err) + } + }(stream) + + var content string + for { + var response = openai.ChatCompletionStreamResponse{} + rawLine, streamErr := stream.RecvRaw() + + if errors.Is(streamErr, io.EOF) { + conn.DoneCh <- content + return + } + + if streamErr != nil { + logger.Errorf("openai receive error: %s", streamErr) + conn.DoneCh <- streamErr.Error() + return + } + + if *interruptCurrentChat { + *interruptCurrentChat = false + conn.DoneCh <- content + return + } + + jsonErr := json.Unmarshal(rawLine, &response) + if jsonErr != nil { + logger.Errorf("openai json unmarshal err: %s", jsonErr) + conn.DoneCh <- jsonErr.Error() + return + } + + var newContent string + if len(response.Choices) == 0 { + continue + } + delta := response.Choices[0].Delta + + if delta.ReasoningContent != "" { + newContent = delta.ReasoningContent + conn.IsReasoning = true + } else { + newContent = delta.Content + if conn.IsReasoning { + conn.IsReasoning = false + content = "" + continue + } + } + + content += newContent + conn.AnswerCh <- content + } +} diff --git a/pkg/srvconn/conn_redis.go b/pkg/srvconn/conn_redis.go index 6ccca1eac..bdb704e5a 100644 --- a/pkg/srvconn/conn_redis.go +++ b/pkg/srvconn/conn_redis.go @@ -1,11 +1,15 @@ package srvconn import ( + "crypto/tls" + "crypto/x509" "fmt" "os" "strconv" + "time" "github.com/jumpserver/koko/pkg/localcommand" + "github.com/jumpserver/koko/pkg/logger" "github.com/mediocregopher/radix/v3" ) @@ -23,11 +27,15 @@ func NewRedisConnection(ops ...SqlOption) (*RedisConn, error) { err error ) args := &sqlOption{ - Username: os.Getenv("USER"), - Password: os.Getenv("PASSWORD"), - Host: "127.0.0.1", - Port: 6379, - DBName: "0", + Username: os.Getenv("USER"), + Password: os.Getenv("PASSWORD"), + Host: "127.0.0.1", + Port: 6379, + DBName: "0", + UseSSL: false, + CaCert: "", + ClientCert: "", + CertKey: "", win: Windows{ Width: 80, Height: 120, @@ -36,6 +44,26 @@ func NewRedisConnection(ops ...SqlOption) (*RedisConn, error) { for _, setter := range ops { setter(args) } + + if args.UseSSL { + caCertPath, err := StoreCAFileToLocal(args.CaCert) + if err != nil { + return nil, err + } + certKeyPath, err := StoreCAFileToLocal(args.CertKey) + if err != nil { + return nil, err + } + clientCertPath, err := StoreCAFileToLocal(args.ClientCert) + if err != nil { + return nil, err + } + args.CaCertPath = caCertPath + args.CertKeyPath = certKeyPath + args.ClientCertPath = clientCertPath + defer ClearTempFileDelay(time.Minute, caCertPath, certKeyPath, clientCertPath) + } + if err := checkRedisAccount(args); err != nil { return nil, err } @@ -62,13 +90,27 @@ func (conn *RedisConn) KeepAlive() error { } func (conn *RedisConn) Close() error { - _, _ = conn.Write([]byte("\r\nexit\r\n")) + _, _ = conn.Write(cleanLineExitCommand) return conn.LocalCommand.Close() } func startRedisCommand(opt *sqlOption) (lcmd *localcommand.LocalCommand, err error) { cmd := opt.RedisCommandArgs() - lcmd, err = localcommand.New("redis-cli", cmd, localcommand.WithPtyWin(opt.win.Width, opt.win.Height)) + envs := make([]string, 0, 2) + redisCliFile := os.Getenv("REDISCLI_RCFILE") + if redisCliFile != "" { + envs = append(envs, "REDISCLI_RCFILE="+redisCliFile) + logger.Infof("rediscli rcfile: %s", redisCliFile) + } + ptyOpt := localcommand.WithPtyWin(opt.win.Width, opt.win.Height) + envOpt := localcommand.WithEnv(envs) + opts, err := BuildNobodyWithOpts(ptyOpt, envOpt) + if err != nil { + logger.Errorf("build nobody with opts error: %s", err) + return nil, err + } + opts = append(opts, envOpt) + lcmd, err = localcommand.New("redis-cli", cmd, opts...) if err != nil { return nil, err } @@ -86,16 +128,33 @@ func startRedisCommand(opt *sqlOption) (lcmd *localcommand.LocalCommand, err err } func (opt *sqlOption) RedisCommandArgs() []string { - params := []string{ + params := make([]string, 0, 15) + if opt.ClusterMode { + params = append(params, "-c") + } + connArgs := []string{ "-h", opt.Host, "-p", strconv.Itoa(opt.Port), "-n", opt.DBName, } + params = append(params, connArgs...) + + if opt.UseSSL { + params = append(params, "--tls") + if opt.CaCertPath != "" { + params = append(params, "--cacert", opt.CaCertPath) + } + if opt.ClientCertPath != "" && opt.CertKeyPath != "" { + params = append(params, "--cert", opt.ClientCertPath) + params = append(params, "--key", opt.CertKeyPath) + } + } if opt.Username != "" { params = append(params, "--user", opt.Username) } if opt.Password != "" { params = append(params, "--askpass") } + params = append(params, "--raw") return params } @@ -107,10 +166,35 @@ func checkRedisAccount(args *sqlOption) error { } else { dialOptions = append(dialOptions, radix.DialAuthPass(args.Password)) } + + if args.UseSSL { + tlsConfig := tls.Config{} + // 连接使用的是内部地址或者localhost时,跳过证书验证 + if args.Host == "127.0.0.1" || args.Host == "localhost" { + tlsConfig.InsecureSkipVerify = true + } + if args.CaCert != "" { + rootCAs := x509.NewCertPool() + rootCAs.AppendCertsFromPEM([]byte(args.CaCert)) + tlsConfig.RootCAs = rootCAs + tlsConfig.InsecureSkipVerify = true + } + if args.CertKey != "" && args.ClientCert != "" { + var err error + tlsConfig.Certificates = make([]tls.Certificate, 1) + tlsConfig.Certificates[0], err = tls.X509KeyPair([]byte(args.ClientCert), []byte(args.CertKey)) + if err != nil { + return err + } + } + dialOptions = append(dialOptions, radix.DialUseTLS(&tlsConfig)) + } + conn, err := radix.Dial("tcp", addr, dialOptions...) if err != nil || conn == nil { return err } defer conn.Close() - return nil + err = conn.Do(radix.Cmd(nil, "PING")) + return err } diff --git a/pkg/srvconn/conn_sql_opt.go b/pkg/srvconn/conn_sql_opt.go new file mode 100644 index 000000000..885d1aaf4 --- /dev/null +++ b/pkg/srvconn/conn_sql_opt.go @@ -0,0 +1,150 @@ +package srvconn + +import "github.com/jumpserver-dev/sdk-go/model" + +type sqlOption struct { + AssetName string + Schema string + Username string + Password string + DBName string + Host string + Port int + UseSSL bool + SqlPgSSLMode string + CaCert string + CaCertPath string + ClientCert string + ClientCertPath string + CertKey string + CertKeyPath string + AllowInvalidCert bool + + SQLServerDisableEncrypt bool // for sqlserver 2008 + + ClusterMode bool + + win Windows + + AuthSource string + ConnectionOptions string + + DataMaskingRules []model.DataMaskingRule +} + +type SqlOption func(*sqlOption) + +func SqlSchema(schema string) SqlOption { + return func(args *sqlOption) { + args.Schema = schema + } +} + +func SqlAssetName(assetName string) SqlOption { + return func(args *sqlOption) { + args.AssetName = assetName + } +} + +func SqlUsername(username string) SqlOption { + return func(args *sqlOption) { + args.Username = username + } +} + +func SqlPassword(password string) SqlOption { + return func(args *sqlOption) { + args.Password = password + } +} + +func SqlDBName(dbName string) SqlOption { + return func(args *sqlOption) { + args.DBName = dbName + } +} + +func SqlHost(host string) SqlOption { + return func(args *sqlOption) { + args.Host = host + } +} + +func SqlPort(port int) SqlOption { + return func(args *sqlOption) { + args.Port = port + } +} + +func SqlUseSSL(useSSL bool) SqlOption { + return func(args *sqlOption) { + args.UseSSL = useSSL + } +} + +func SqlPGSSLMode(mode string) SqlOption { + return func(args *sqlOption) { + args.SqlPgSSLMode = mode + + } +} + +func SqlCaCert(caCert string) SqlOption { + return func(args *sqlOption) { + args.CaCert = caCert + } +} + +func SqlCertKey(certKey string) SqlOption { + return func(args *sqlOption) { + args.CertKey = certKey + } +} + +func SqlClientCert(clientCert string) SqlOption { + return func(args *sqlOption) { + args.ClientCert = clientCert + } +} + +func SqlAllowInvalidCert(allowInvalidCert bool) SqlOption { + return func(args *sqlOption) { + args.AllowInvalidCert = allowInvalidCert + } +} + +func SqlPtyWin(win Windows) SqlOption { + return func(args *sqlOption) { + args.win = win + } +} + +func SqlAuthSource(authSource string) SqlOption { + return func(args *sqlOption) { + args.AuthSource = authSource + } +} + +func SqlConnectionOptions(options string) SqlOption { + return func(args *sqlOption) { + args.ConnectionOptions = options + } +} + +func SqlDisableSqlServerEncrypt(disbale bool) SqlOption { + return func(args *sqlOption) { + args.SQLServerDisableEncrypt = disbale + } +} + +func SqlClusterMode(mode bool) SqlOption { + return func(args *sqlOption) { + args.ClusterMode = mode + } +} + +func SqlMaskingRules(rules []model.DataMaskingRule) SqlOption { + return func(args *sqlOption) { + args.DataMaskingRules = rules + } +} diff --git a/pkg/srvconn/conn_sqlserver.go b/pkg/srvconn/conn_sqlserver.go deleted file mode 100644 index afb6fb1e0..000000000 --- a/pkg/srvconn/conn_sqlserver.go +++ /dev/null @@ -1,128 +0,0 @@ -package srvconn - -import ( - "bytes" - "fmt" - "os" - "strconv" - - _ "github.com/denisenkom/go-mssqldb" - "github.com/jumpserver/koko/pkg/localcommand" - "github.com/jumpserver/koko/pkg/logger" -) - -const ( - sqlServerPrompt = "Password:" -) - -var ( - _ ServerConnection = (*SQLServerConn)(nil) -) - -func NewSQLServerConnection(ops ...SqlOption) (*SQLServerConn, error) { - args := &sqlOption{ - Username: os.Getenv("USER"), - Password: os.Getenv("PASSWORD"), - Host: "127.0.0.1", - Port: 1433, - DBName: "", - win: Windows{ - Width: 80, - Height: 120, - }, - } - for _, setter := range ops { - setter(args) - } - if err := checkSQLServerAccount(args); err != nil { - return nil, err - } - lCmd, err := startSQLServerCommand(args) - if err != nil { - return nil, err - } - err = lCmd.SetWinSize(args.win.Width, args.win.Height) - if err != nil { - _ = lCmd.Close() - return nil, err - } - return &SQLServerConn{options: args, LocalCommand: lCmd}, nil -} - -type SQLServerConn struct { - options *sqlOption - *localcommand.LocalCommand -} - -func (conn *SQLServerConn) KeepAlive() error { - return nil -} - -func (conn *SQLServerConn) Close() error { - _, _ = conn.Write([]byte("\r\nexit\r\n")) - return conn.LocalCommand.Close() -} - -func startSQLServerCommand(opt *sqlOption) (lcmd *localcommand.LocalCommand, err error) { - if lcmd, err = startSQLServerNormalCommand(opt); err != nil { - return nil, err - } - return tryManualLoginSQLServerServer(opt, lcmd) -} - -func startSQLServerNormalCommand(opt *sqlOption) (lcmd *localcommand.LocalCommand, err error) { - //tsql 是启动sqlserver的客户端 - return localcommand.New("tsql", opt.SQLServerCommandArgs()) -} - -func tryManualLoginSQLServerServer(opt *sqlOption, lcmd *localcommand.LocalCommand) (*localcommand.LocalCommand, error) { - var ( - nr int - err error - ) - prompt := [len(sqlServerPrompt)]byte{} - nr, err = lcmd.Read(prompt[:]) - if err != nil { - _ = lcmd.Close() - logger.Errorf("sqlserver local pty fd read err: %s", err) - return lcmd, err - } - if !bytes.Equal(prompt[:nr], []byte(sqlServerPrompt)) { - _ = lcmd.Close() - logger.Errorf("sqlserver login prompt characters did not match: %s", prompt[:nr]) - err = fmt.Errorf("sqlserver login prompt characters did not match: %s", prompt[:nr]) - return lcmd, err - } - // 输入密码, 登录 sqlserver - _, err = lcmd.Write([]byte(opt.Password + "\r\n")) - if err != nil { - _ = lcmd.Close() - logger.Errorf("sqlserver local pty write err: %s", err) - return lcmd, fmt.Errorf("sqlserver conn err: %s", err) - } - return lcmd, nil -} - -func (opt *sqlOption) SQLServerCommandArgs() []string { - return []string{ - "-U", opt.Username, - "-H", opt.Host, - "-p", strconv.Itoa(opt.Port), - "-J", "UTF-8", - "-D", opt.DBName, - } -} - -func (opt *sqlOption) SQLServerSourceName() string { - return fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s", - opt.Host, - strconv.Itoa(opt.Port), - opt.DBName, - opt.Username, - opt.Password, - ) -} - -func checkSQLServerAccount(args *sqlOption) error { - return checkDatabaseAccountValidate("mssql", args.SQLServerSourceName()) -} diff --git a/pkg/srvconn/conn_ssh.go b/pkg/srvconn/conn_ssh.go index c49d73402..5199b1a14 100644 --- a/pkg/srvconn/conn_ssh.go +++ b/pkg/srvconn/conn_ssh.go @@ -56,10 +56,10 @@ func NewSSHConnection(sess *gossh.Session, opts ...SSHOption) (*SSHConnection, e stdout: stdout, options: options, } - if !options.isLoginToSu { + if options.suConfig == nil { err = sess.Shell() } else { - err = LoginToSu(conn) + err = LoginToSSHSu(conn) } if err != nil { _ = sess.Close() @@ -103,10 +103,7 @@ type SSHOptions struct { win Windows term string - isLoginToSu bool - sudoCommand string - sudoUsername string - sudoPassword string + suConfig *SuConfig } func SSHCharset(charset string) SSHOption { @@ -127,26 +124,8 @@ func SSHTerm(termType string) SSHOption { } } -func SSHLoginToSudo(ok bool) SSHOption { +func SSHSudoConfig(cfg *SuConfig) SSHOption { return func(opt *SSHOptions) { - opt.isLoginToSu = ok - } -} - -func SSHSudoCommand(cmd string) SSHOption { - return func(opt *SSHOptions) { - opt.sudoCommand = cmd - } -} - -func SSHSudoUsername(username string) SSHOption { - return func(opt *SSHOptions) { - opt.sudoUsername = username - } -} - -func SSHSudoPassword(password string) SSHOption { - return func(opt *SSHOptions) { - opt.sudoPassword = password + opt.suConfig = cfg } } diff --git a/pkg/srvconn/conn_ssh_su.go b/pkg/srvconn/conn_ssh_su.go index a430e27ec..619861ff3 100644 --- a/pkg/srvconn/conn_ssh_su.go +++ b/pkg/srvconn/conn_ssh_su.go @@ -3,123 +3,195 @@ package srvconn import ( "errors" "fmt" - "regexp" "strings" - "time" - - "github.com/jumpserver/koko/pkg/logger" ) -func LoginToSu(sc *SSHConnection) error { - successPattern := createSuccessPattern(sc.options.sudoUsername) - steps := make([]stepItem, 0, 2) - steps = append(steps, - stepItem{ - Input: sc.options.sudoCommand, - ExpectPattern: passwordMatchPattern, - FinishedPattern: successPattern, - IsCommand: true, - }, - stepItem{ - Input: sc.options.sudoPassword, - ExpectPattern: successPattern, - FinishedPattern: successPattern, - }, - ) - for i := 0; i < len(steps); i++ { - finished, err := executeStep(&steps[i], sc) - if err != nil { - return err - } - if finished { - break +func LoginToSSHSu(sc *SSHConnection) error { + cfg := sc.options.suConfig + suService, err := NewSuService(cfg, sc) + if err != nil { + return err + } + switch cfg.MethodType { + case SuMethodSu, SuMethodSudo, + SuMethodOnlySudo, SuMethodOnlySu: + startCmd := cfg.SuCommand() + suService.execCommand = func() { + _ = sc.session.Start(startCmd) } + default: + _ = sc.session.Shell() } - return nil + return suService.RunSwitchUser() +} + +type ExecuteResult struct { + Finished bool + Err error +} + +func createLinuxSuccessPattern(username string) string { + pattern := fmt.Sprintf("%s@", username) + pattern = fmt.Sprintf("(?i)%s|%s|%s", pattern, + normalUserMark, superUserMark) + return pattern } -func executeStep(step *stepItem, sc *SSHConnection) (bool, error) { - return step.Execute(sc) +func createCiscoSuccessPattern(username string) string { + return fmt.Sprintf("%s|%s", normalUserMark, superUserMark) +} + +func createHuaweiH3CSuccessPattern(username string) string { + return huaweiH3CPs1Mark } const ( + normalUserMark = "\\s*\\$" + superUserMark = "\\s*#" +) + +const ( + /* + huawei、h3c 的终端提示符 + */ + huaweiH3CPs1Mark = "^<.*>" +) + +const ( + /* + Linux 相关 + */ + LinuxSuCommand = "su - %s; exit" + LinuxSudoCommand = "sudo su - %s; exit" + + LinuxOnlySuCommand = "su %s; exit" + + LinuxOnlySudoCommand = "sudo su %s; exit" + + /* + Cisco 相关 + */ + + SuCommandEnable = "enable" + + /* + huawei 相关 + */ + + SuCommandSuper = "super 15" + + /* + h3c super 相关 + */ + + SuCommandSuperH3C = "super level-15" + /* \b: word boundary 即: 匹配某个单词边界 */ - passwordMatchPattern = "(?i)\\bpassword\\b|密码" + passwordMatchPattern = "(?i)\\bpassword\\b\\s*[::]|密码\\s*[::]|password\\s*[::]\\s*" + + usernameMatchPattern = "(?i)username:?\\s*$|name:?\\s*$|用户名:?\\s*$" ) -var ErrorTimeout = errors.New("time out") +// 收集完善切换用户失败的提示信息 -type stepItem struct { - Input string - ExpectPattern string - IsCommand bool - FinishedPattern string +var switchPasswordFailures = []string{ + "password has not been set", + "wrong\\s*passwords", + "bad\\s*secrets", + "access\\s*denied", + "authentication\\s*failure", + "invalid\\s*password", } -func (s *stepItem) Execute(sc *SSHConnection) (bool, error) { - resultChan := make(chan *ExecuteResult, 1) - matchReg, err := regexp.Compile(s.ExpectPattern) - if err != nil { - logger.Error(err) - } - successReg, err := regexp.Compile(s.FinishedPattern) - if err != nil { - logger.Error(err) - } - if s.IsCommand { - _ = sc.session.Start(s.Input) - } else { - _, _ = sc.Write([]byte(s.Input + "\r\n")) +func createFailedPattern() string { + allFailure := strings.Join(switchPasswordFailures, "|") + return fmt.Sprintf("(?i)%s", allFailure) +} + +var ErrorTimeout = errors.New("i/o timeout") + +type SUMethodType string + +const ( + SuMethodSudo SUMethodType = "sudo" + SuMethodSu SUMethodType = "su" + SuMethodOnlySudo SUMethodType = "only_sudo" + SuMethodOnlySu SUMethodType = "only_su" + SuMethodEnable SUMethodType = "enable" + SuMethodSuper SUMethodType = "super" + SuMethodSuperLevel SUMethodType = "super_level" +) + +func NewSuMethodType(suMethod string) SUMethodType { + method := strings.ToLower(suMethod) + switch method { + case "enable": + return SuMethodEnable + case "super": + return SuMethodSuper + case "super_level": + return SuMethodSuperLevel + case "su": + return SuMethodSu + case "sudo": + return SuMethodSudo + case "only_sudo": + return SuMethodOnlySudo + case "only_su": + return SuMethodOnlySu + default: + } - go func() { - buf := make([]byte, 8192) - var recStr strings.Builder - for { - nr, err2 := sc.Read(buf) - if err2 != nil { - resultChan <- &ExecuteResult{Err: err2} - return - } - recStr.Write(buf[:nr]) - result := strings.TrimSpace(recStr.String()) - if successReg != nil && successReg.MatchString(result) { - resultChan <- &ExecuteResult{Finished: true} - return - } - if matchReg != nil && matchReg.MatchString(result) { - resultChan <- &ExecuteResult{} - return - } - } - }() - ticker := time.NewTicker(time.Second * 30) - defer ticker.Stop() - select { - case ret := <-resultChan: - return ret.Finished, ret.Err - case <-ticker.C: + return SuMethodSu +} + +type SuConfig struct { + MethodType SUMethodType + SudoUsername string + SudoPassword string +} + +func (s *SuConfig) SuCommand() string { + switch s.MethodType { + case SuMethodEnable: + return SuCommandEnable + case SuMethodSuper: + return SuCommandSuper + case SuMethodSuperLevel: + return SuCommandSuperH3C + case SuMethodSudo: + return fmt.Sprintf(LinuxSudoCommand, s.SudoUsername) + case SuMethodOnlySudo: + return fmt.Sprintf(LinuxOnlySudoCommand, s.SudoUsername) + case SuMethodOnlySu: + return fmt.Sprintf(LinuxOnlySuCommand, s.SudoUsername) + default: + } - return false, ErrorTimeout + return fmt.Sprintf(LinuxSuCommand, s.SudoUsername) } -type ExecuteResult struct { - Finished bool - Err error +func (s *SuConfig) UsernameMatchPattern() string { + return usernameMatchPattern } -func createSuccessPattern(username string) string { - pattern := fmt.Sprintf("%s@", username) - pattern = fmt.Sprintf("(?i)%s|%s|%s", pattern, - normalUserMark, superUserMark) - return pattern +func (s *SuConfig) PasswordMatchPattern() string { + return passwordMatchPattern } -const ( - normalUserMark = "\\s*\\$" - superUserMark = "\\s*#" -) +func (s *SuConfig) SuccessPattern() string { + switch s.MethodType { + case SuMethodEnable: + return createCiscoSuccessPattern(s.SudoUsername) + case SuMethodSuper, SuMethodSuperLevel: + return createHuaweiH3CSuccessPattern(s.SudoUsername) + default: + + } + return createLinuxSuccessPattern(s.SudoUsername) +} diff --git a/pkg/srvconn/conn_telnet.go b/pkg/srvconn/conn_telnet.go index aa001b4d9..415aa6981 100644 --- a/pkg/srvconn/conn_telnet.go +++ b/pkg/srvconn/conn_telnet.go @@ -12,6 +12,7 @@ import ( "golang.org/x/text/transform" "github.com/jumpserver/koko/pkg/common" + "github.com/jumpserver/koko/pkg/logger" ) func NewTelnetConnection(opts ...TelnetOption) (*TelnetConnection, error) { @@ -72,13 +73,23 @@ func NewTelnetConnection(opts ...TelnetOption) (*TelnetConnection, error) { transformWriter = transform.NewWriter(client, writerEncode) } } - return &TelnetConnection{ + tCon := &TelnetConnection{ cfg: cfg, conn: client, proxyConn: proxyClient, transformReader: transformReader, transformWriter: transformWriter, - }, nil + } + if cfg.suCfg != nil { + if err = LoginToTelnetSu(tCon); err != nil { + _ = tCon.Close() + logger.Errorf("Telnet Login to su user %s failed: %s", + cfg.suCfg.SudoUsername, err) + return nil, err + } + } + _, _ = tCon.Write([]byte("\r\n")) + return tCon, nil } func newTelnetClient(conn net.Conn, cfg *TelnetConfig) (*tclientlib.Client, error) { @@ -108,7 +119,9 @@ func newTelnetClient(conn net.Conn, cfg *TelnetConfig) (*tclientlib.Client, erro High: cfg.win.Height, TermType: cfg.Term, }, - LoginSuccessRegex: cfg.CustomSuccessPattern, + LoginSuccessPromptRegex: cfg.CustomSuccessPattern, + UsernamePromptRegex: cfg.CustomUsernamePattern, + PasswordPromptRegex: cfg.CustomPasswordPattern, }) close(done) if err != nil { @@ -127,10 +140,6 @@ type TelnetConnection struct { once sync.Once } -func (tc *TelnetConnection) Protocol() string { - return "telnet" -} - func (tc *TelnetConnection) KeepAlive() error { return nil } @@ -167,11 +176,15 @@ type TelnetConfig struct { Term string Charset string + suCfg *SuConfig + Timeout time.Duration win Windows - CustomSuccessPattern *regexp.Regexp + CustomSuccessPattern *regexp.Regexp + CustomUsernamePattern *regexp.Regexp + CustomPasswordPattern *regexp.Regexp proxySSHClientOptions []SSHClientOptions } @@ -229,3 +242,21 @@ func TelnetCustomSuccessPattern(successPattern *regexp.Regexp) TelnetOption { opt.CustomSuccessPattern = successPattern } } + +func TelnetCustomUsernamePattern(usernamePrompt *regexp.Regexp) TelnetOption { + return func(opt *TelnetConfig) { + opt.CustomUsernamePattern = usernamePrompt + } +} + +func TelnetCustomPasswordPattern(passwordPrompt *regexp.Regexp) TelnetOption { + return func(opt *TelnetConfig) { + opt.CustomPasswordPattern = passwordPrompt + } +} + +func TelnetSuConfig(cfg *SuConfig) TelnetOption { + return func(opt *TelnetConfig) { + opt.suCfg = cfg + } +} diff --git a/pkg/srvconn/conn_telnet_su.go b/pkg/srvconn/conn_telnet_su.go new file mode 100644 index 000000000..1c3e2437e --- /dev/null +++ b/pkg/srvconn/conn_telnet_su.go @@ -0,0 +1,211 @@ +package srvconn + +import ( + "bytes" + "fmt" + "io" + "regexp" + "time" + + "github.com/jumpserver/koko/pkg/logger" +) + +func LoginToTelnetSu(sc *TelnetConnection) error { + cfg := sc.cfg.suCfg + suService, err := NewSuService(cfg, sc) + if err != nil { + return err + } + return suService.RunSwitchUser() +} + +/* + +切换用户的执行流程 + +一、根据系统不同,切换用户执行流程不同 + Linux 系统 sudo 执行流程 + 1、执行 su - username; exit (这里的 exit 是为了退出 sudo) + 2、等待密码输入的 prompt (如果是 root 切换普通 可能直接切换成功) + + Cisco 交换机 切换执行流程 + 1、执行 enable + 2、等待密码输入的 prompt + + Huawei 交换机 切换执行流程 + 1、执行 super 15 (这里的 15 是 user privilege level) + 2、等待密码输入的 prompt + + H3C 交换机 切换执行流程 + 1、执行 super level-15 (这里的 15 是 user privilege level) + 2、等待输入 username + 3、等待输入 password + +二、等待匹配成功提示字符,如果匹配到失败提示字符,就返回密码错误失败 +三、如果成功,返回 切换的提示信息,并通过 \r 换行 + +关于成功提示符: +Linux 和 Cisco 交换机的成功提示符中,包含 + Huawei: [root@HUAWEI-xxx] + + +*/ + +func NewSuService(cfg *SuConfig, srv io.ReadWriteCloser) (*SuSwitchService, error) { + successReg, err := regexp.Compile(cfg.SuccessPattern()) + if err != nil { + return nil, fmt.Errorf("success pattern %s compile failed: %s", cfg.SuccessPattern(), err) + } + passwordReg, err := regexp.Compile(cfg.PasswordMatchPattern()) + if err != nil { + return nil, fmt.Errorf("password pattern %s compile failed: %s", cfg.PasswordMatchPattern(), err) + } + usernameReg, err := regexp.Compile(cfg.UsernameMatchPattern()) + if err != nil { + return nil, fmt.Errorf("username pattern %s compile failed: %s", cfg.UsernameMatchPattern(), err) + } + failedPattern := createFailedPattern() + failedReg, err := regexp.Compile(failedPattern) + if err != nil { + return nil, fmt.Errorf("failed pattern %s compile failed: %s", failedPattern, err) + } + suService := SuSwitchService{ + cfg: cfg, + SrvConn: srv, + successRegexp: successReg, + usernameRegexp: usernameReg, + passwordRegexp: passwordReg, + failureRegexp: failedReg, + } + return &suService, nil +} + +type SuSwitchService struct { + cfg *SuConfig + execCommand func() + + SrvConn io.ReadWriteCloser + + successRegexp *regexp.Regexp + usernameRegexp *regexp.Regexp + passwordRegexp *regexp.Regexp + failureRegexp *regexp.Regexp + + inputAuthOnce bool + needAuthOnce bool +} + +func (s *SuSwitchService) RunSwitchUser() error { + s.runSwitchCommand() + resultChan := make(chan error, 1) + go s.loginUsernameOrPassword(resultChan) + ticker := time.NewTicker(time.Second * 30) + defer ticker.Stop() + select { + case ret := <-resultChan: + return ret + case <-ticker.C: + } + return ErrorTimeout +} + +func (s *SuSwitchService) runSwitchCommand() { + if s.execCommand != nil { + s.execCommand() + } else { + cmd := s.cfg.SuCommand() + _, _ = s.SrvConn.Write([]byte(cmd + "\r")) + s.needAuthOnce = true + } +} + +func (s *SuSwitchService) loginUsernameOrPassword(resultChan chan<- error) { + buf := make([]byte, 8192) + var recStr bytes.Buffer + for { + nr, err2 := s.SrvConn.Read(buf) + if err2 != nil { + resultChan <- err2 + return + } + recStr.Write(buf[:nr]) + status := s.handleResult(recStr.Bytes()) + switch status { + case StatusSuccess: + // 成功后,结束切换 + resultChan <- nil + return + case StatusMatch: + // 匹配到了,清空缓存 + recStr.Reset() + logger.Debug("Sudo step result matched and rest") + continue + case StatusFailed: + resultChan <- fmt.Errorf("failed login: %s", recStr.String()) + case StatusUnMatch: + default: + + } + logger.Debugf("Sudo step result do not match any: %s", recStr.String()) + // 没有匹配到,继续等待 + time.Sleep(time.Millisecond * 100) + } +} + +func (s *SuSwitchService) handleResult(p []byte) matchStatus { + newBytes := bytes.ReplaceAll(p, []byte("\r"), []byte("\n")) + newBytes = bytes.ReplaceAll(newBytes, []byte("\n\n"), []byte("\n")) + lineBytes := bytes.Split(newBytes, []byte("\n")) + + if s.usernameRegexp != nil && s.usernameRegexp.Match(p) { + for _, line := range lineBytes { + if s.usernameRegexp.Match(line) { + _, _ = s.SrvConn.Write([]byte(s.cfg.SudoUsername + "\r")) + logger.Debugf("Su switch step username pattern ok: %s", p) + return StatusMatch + } + } + } + if s.passwordRegexp != nil { + for _, line := range lineBytes { + if s.passwordRegexp.Match(line) { + _, _ = s.SrvConn.Write([]byte(s.cfg.SudoPassword + "\r")) + s.inputAuthOnce = true + logger.Debugf("Su switch step password pattern ok: %s", p) + return StatusMatch + } + } + } + if s.needAuthOnce && s.inputAuthOnce { + if s.failureRegexp != nil { + for _, line := range lineBytes { + if s.failureRegexp.Match(line) { + logger.Debugf("Su switch step failed pattern ok: %s", p) + return StatusFailed + } + } + } + } + if s.successRegexp != nil { + if s.needAuthOnce && !s.inputAuthOnce { + logger.Debug("Su switch step need auth once but not input password") + return StatusUnMatch + } + for _, line := range lineBytes { + if s.successRegexp.Match(line) { + logger.Debugf("Su switch step success pattern ok: %s", p) + return StatusSuccess + } + } + } + return StatusUnMatch +} + +type matchStatus int + +const ( + StatusUnMatch matchStatus = 1 + StatusMatch matchStatus = 2 + StatusSuccess matchStatus = 3 + StatusFailed matchStatus = 4 +) diff --git a/pkg/srvconn/conn_usql.go b/pkg/srvconn/conn_usql.go new file mode 100644 index 000000000..61a1d1aca --- /dev/null +++ b/pkg/srvconn/conn_usql.go @@ -0,0 +1,144 @@ +package srvconn + +import ( + "encoding/json" + "github.com/jumpserver-dev/sdk-go/model" + "net" + "net/url" + "sort" + "strconv" + "time" + + "github.com/jumpserver/koko/pkg/localcommand" +) + +var ( + _ ServerConnection = (*USQLConn)(nil) +) + +func NewUSQLConnection(opts ...SqlOption) (*USQLConn, error) { + var ( + lCmd *localcommand.LocalCommand + err error + ) + + var args = &sqlOption{} + + for _, setter := range opts { + setter(args) + } + + lCmd, err = startUSQLCommand(args) + if err != nil { + return nil, err + } + err = lCmd.SetWinSize(args.win.Width, args.win.Height) + if err != nil { + _ = lCmd.Close() + return nil, err + } + return &USQLConn{options: args, LocalCommand: lCmd}, nil +} + +type USQLConn struct { + options *sqlOption + *localcommand.LocalCommand +} + +func (conn *USQLConn) KeepAlive() error { return nil } + +func (conn *USQLConn) Close() error { + _, _ = conn.Write(cleanLineExitCommand) + return conn.LocalCommand.Close() +} + +func startUSQLCommand(opt *sqlOption) (*localcommand.LocalCommand, error) { + args, err := opt.USQLCommandArgs() + if err != nil { + return nil, err + } + lcmd, err := localcommand.New("usql", args, localcommand.WithEnv([]string{ + "PAGE=", + })) + if err != nil { + return nil, err + } + return lcmd, nil +} + +func (o *sqlOption) USQLCommandArgs() ([]string, error) { + var dsnURL url.URL + dsnURL.Scheme = o.Schema + dsnURL.Host = net.JoinHostPort(o.Host, strconv.Itoa(o.Port)) + dsnURL.User = url.UserPassword(o.Username, o.Password) + dsnURL.Path = o.DBName + params := url.Values{} + if o.UseSSL { + clientCertKeyPath, err := StorePrivateKeyFileToLocal(o.CertKey) + if err != nil { + return nil, err + } + clientCertPath, err := StoreCAFileToLocal(o.ClientCert) + if err != nil { + return nil, err + } + caCertPath, err := StoreCAFileToLocal(o.CaCert) + if err != nil { + return nil, err + } + + defer ClearTempFileDelay(time.Minute, clientCertPath, clientCertKeyPath, caCertPath) + + switch o.Schema { + case "postgres": + params.Set("sslmode", o.SqlPgSSLMode) + + if caCertPath != "" { + params.Set("sslrootcert", caCertPath) + } + + if clientCertPath != "" { + params.Set("sslcert", clientCertPath) + } + + if clientCertKeyPath != "" { + params.Set("sslkey", clientCertKeyPath) + } + + case "mysql": + params.Set("tls", "custom") + params.Set("ssl-ca", caCertPath) + params.Set("ssl-cert", clientCertPath) + params.Set("ssl-key", clientCertKeyPath) + } + } + + if len(o.DataMaskingRules) > 0 { + rules := model.DataMaskingRules(o.DataMaskingRules) + sort.Sort(rules) + var activeRules []model.DataMaskingRule + for _, rule := range rules { + if rule.IsActive { + activeRules = append(activeRules, rule) + } + } + rulesJson, err := json.Marshal(activeRules) + if err != nil { + return nil, err + } + params.Set("data-masking-rules", string(rulesJson)) + } + + switch o.Schema { + case "sqlserver": + if o.SQLServerDisableEncrypt { + params.Set("encrypt", "disable") + } + default: + } + dsnURL.RawQuery = params.Encode() + dsn := dsnURL.String() + prompt1 := "--variable=PROMPT1=" + o.Schema + "%R%#" + + return []string{dsn, prompt1}, nil +} diff --git a/pkg/srvconn/connmanager.go b/pkg/srvconn/connmanager.go index e11c67f18..1bc743678 100644 --- a/pkg/srvconn/connmanager.go +++ b/pkg/srvconn/connmanager.go @@ -6,10 +6,6 @@ import ( var sshManager = newSSHManager() -func searchSSHClientFromCache(prefixKey string) (client *SSHClient, ok bool) { - return sshManager.searchSSHClientFromCache(prefixKey) -} - func GetClientFromCache(key string) (client *SSHClient, ok bool) { return sshManager.getClientFromCache(key) } @@ -18,8 +14,12 @@ func AddClientCache(key string, client *SSHClient) { sshManager.AddClientCache(key, client) } -func MakeReuseSSHClientKey(userId, assetId, systemUserId, +func ReleaseClientCacheKey(key string, client *SSHClient) { + sshManager.ReleaseClientCacheKey(key, client) +} + +func MakeReuseSSHClientKey(userId, assetId, account, ip, username string) string { return fmt.Sprintf("%s_%s_%s_%s_%s", userId, assetId, - systemUserId, ip, username) + account, ip, username) } diff --git a/pkg/srvconn/sftp_asset.go b/pkg/srvconn/sftp_asset.go index 500cf055c..ad6949ba0 100644 --- a/pkg/srvconn/sftp_asset.go +++ b/pkg/srvconn/sftp_asset.go @@ -13,41 +13,38 @@ import ( "github.com/pkg/sftp" gossh "golang.org/x/crypto/ssh" + "github.com/jumpserver-dev/sdk-go/common" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" + com "github.com/jumpserver/koko/pkg/common" "github.com/jumpserver/koko/pkg/config" - "github.com/jumpserver/koko/pkg/jms-sdk-go/common" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/session" ) type AssetDir struct { - ID string - folderName string - addr string - modeTime time.Time + opts folderOptions - user *model.User - detailAsset *model.Asset - domain *model.Domain - - suMaps map[string]*model.SystemUser - - logChan chan<- *model.FTPLog + modeTime time.Time - sftpClients map[string]*SftpConn // systemUser_id + user *model.User + detailAsset *model.PermAsset + once sync.Once + suMaps map[string]*model.PermAccount - once sync.Once + mu sync.Mutex + sftpSessions sync.Map - reuse bool ShowHidden bool - mu sync.Mutex - jmsService *service.JMService + + isFromWebTerminal bool + CurrentPath string } func (ad *AssetDir) Name() string { - return ad.folderName + return ad.opts.Name } func (ad *AssetDir) Size() int64 { return 0 } @@ -69,44 +66,79 @@ func (ad *AssetDir) Sys() interface{} { func (ad *AssetDir) loadSystemUsers() { ad.once.Do(func() { - sus := make(map[string]*model.SystemUser) - SystemUsers, err := ad.jmsService.GetSystemUsersByUserIdAndAssetId(ad.user.ID, ad.ID) - if err != nil { - logger.Errorf("Get asset %s systemUsers err: %s", ad.ID, err) - return - } - matchFunc := func(s string) bool { - _, ok := sus[s] - return ok + if ad.suMaps == nil { + ad.loadSubAccountDirs() } - for i := 0; i < len(SystemUsers); i++ { - if SystemUsers[i].IsProtocol(ProtocolSSH) && !SystemUsers[i].SuEnabled { - folderName := cleanFolderName(SystemUsers[i].Name) - folderName = findAvailableKeyByPaddingSuffix(matchFunc, folderName, paddingCharacter) - sus[folderName] = &SystemUsers[i] - } - } - ad.suMaps = sus if ad.detailAsset == nil { - detailAsset, err := ad.jmsService.GetAssetById(ad.ID) - if err != nil { - logger.Errorf("Get asset err: %s", err) - return - } - ad.detailAsset = &detailAsset - } - if ad.detailAsset.Domain != "" { - domainGateways, err := ad.jmsService.GetDomainGateways(ad.detailAsset.Domain) - if err != nil { - logger.Errorf("Get asset %s domain err: %s", ad.detailAsset.Hostname, err) - return - } - ad.domain = &domainGateways + ad.loadAssetDetail() } }) } -func (ad *AssetDir) Create(path string) (*sftp.File, error) { +func (ad *AssetDir) loadSubAccountDirs() { + permAssetDetail, err := ad.jmsService.GetUserPermAssetDetailById(ad.user.ID, ad.opts.ID) + if err != nil { + logger.Errorf("Get asset %s perm asset detail err: %s", ad.opts.ID, err) + return + } + accounts := make([]model.PermAccount, 0, len(permAssetDetail.PermedAccounts)) + for i := 0; i < len(permAssetDetail.PermedAccounts); i++ { + pAccount := permAssetDetail.PermedAccounts[i] + if ad.opts.accountUsername != "" && ad.opts.accountUsername != pAccount.Username { + continue + } + accounts = append(accounts, pAccount) + } + ad.suMaps = generateSubAccountsFolderMap(accounts) + +} + +func generateSubAccountsFolderMap(accounts []model.PermAccount) map[string]*model.PermAccount { + if len(accounts) == 0 { + return nil + } + sus := make(map[string]*model.PermAccount) + matchFunc := func(s string) bool { + _, ok := sus[s] + return ok + } + for i := 0; i < len(accounts); i++ { + // 不支持 @USER 和 @INPUT, + switch accounts[i].Username { + case model.InputUser, model.DynamicUser, model.ANONUser: + logger.Debugf("Skip unSupported account %s", accounts[i].Name) + continue + default: + } + folderName := cleanFolderName(accounts[i].Name) + folderName = findAvailableKeyByPaddingSuffix(matchFunc, folderName, paddingCharacter) + sus[folderName] = &accounts[i] + } + return sus +} + +func (ad *AssetDir) loadAssetDetail() { + detailAsset, err := ad.jmsService.GetUserPermAssetDetailById(ad.user.ID, ad.opts.ID) + if err != nil { + logger.Errorf("Load asset detail err: %s", err) + return + } + permAsset := &model.PermAsset{ + ID: detailAsset.ID, + Name: detailAsset.Name, + Address: detailAsset.Address, + Comment: detailAsset.Comment, + Platform: detailAsset.Platform, + OrgID: detailAsset.OrgID, + OrgName: detailAsset.OrgName, + IsActive: detailAsset.IsActive, + Type: detailAsset.Type, + Category: detailAsset.Category, + } + ad.detailAsset = permAsset +} + +func (ad *AssetDir) Create(path string) (*SftpFile, error) { pathData := ad.parsePath(path) folderName, ok := ad.IsUniqueSu() if !ok { @@ -118,16 +150,27 @@ func (ad *AssetDir) Create(path string) (*sftp.File, error) { } su, ok := ad.suMaps[folderName] if !ok { - return nil, errNoSystemUser + return nil, errNoAccountUser } - if !ad.validatePermission(su, model.UploadAction) { + if !su.Actions.EnableUpload() { return nil, sftp.ErrSshFxPermissionDenied } con, realPath := ad.GetSFTPAndRealPath(su, strings.Join(pathData, "/")) - if con == nil { + if con == nil || con.isClosed { return nil, sftp.ErrSshFxConnectionLost } + con.IncreaseRef() + for !con.IsOverwriteFile() { + if exitFile := IsExistPath(con.client, realPath); !exitFile { + break + } + oldPath := realPath + ext := filepath.Ext(realPath) + realPath = fmt.Sprintf("%s_duplicate_%s%s", realPath[:len(realPath)-len(ext)], + strconv.FormatInt(time.Now().Unix(), 10), realPath[len(realPath)-len(ext):]) + logger.Infof("Change duplicate dir path %s to %s", oldPath, realPath) + } sf, err := con.client.Create(realPath) filename := realPath isSuccess := false @@ -135,8 +178,9 @@ func (ad *AssetDir) Create(path string) (*sftp.File, error) { if err == nil { isSuccess = true } - ad.CreateFTPLog(su, operate, filename, isSuccess) - return sf, err + ftpLog := ad.CreateFTPLog(su, operate, filename, isSuccess) + f := &SftpFile{File: sf, FTPLog: ftpLog, cleanupFunc: con.DecreaseRef} + return f, err } func (ad *AssetDir) MkdirAll(path string) (err error) { @@ -151,16 +195,27 @@ func (ad *AssetDir) MkdirAll(path string) (err error) { } su, ok := ad.suMaps[folderName] if !ok { - return errNoSystemUser + return errNoAccountUser } - if !ad.validatePermission(su, model.UploadAction) { + if !su.Actions.EnableUpload() { return sftp.ErrSshFxPermissionDenied } con, realPath := ad.GetSFTPAndRealPath(su, strings.Join(pathData, "/")) - if con == nil { + if con == nil || con.isClosed { return sftp.ErrSshFxConnectionLost } + for !con.IsOverwriteFile() { + if exitFile := IsExistPath(con.client, realPath); !exitFile { + break + } + oldPath := realPath + realPath = fmt.Sprintf("%s_duplicate__%s", realPath, + strconv.FormatInt(time.Now().Unix(), 10)) + logger.Infof("Change duplicate dir path %s to %s", oldPath, realPath) + } + con.IncreaseRef() + defer con.DecreaseRef() err = con.client.MkdirAll(realPath) filename := realPath isSuccess := false @@ -172,7 +227,7 @@ func (ad *AssetDir) MkdirAll(path string) (err error) { return } -func (ad *AssetDir) Open(path string) (*sftp.File, error) { +func (ad *AssetDir) Open(path string) (*SftpFile, error) { pathData := ad.parsePath(path) folderName, ok := ad.IsUniqueSu() if !ok { @@ -184,15 +239,16 @@ func (ad *AssetDir) Open(path string) (*sftp.File, error) { } su, ok := ad.suMaps[folderName] if !ok { - return nil, errNoSystemUser + return nil, errNoAccountUser } - if !ad.validatePermission(su, model.DownloadAction) { + if !su.Actions.EnableDownload() { return nil, sftp.ErrSshFxPermissionDenied } con, realPath := ad.GetSFTPAndRealPath(su, strings.Join(pathData, "/")) if con == nil { return nil, sftp.ErrSshFxConnectionLost } + con.IncreaseRef() sf, err := con.client.Open(realPath) filename := realPath isSuccess := false @@ -200,17 +256,18 @@ func (ad *AssetDir) Open(path string) (*sftp.File, error) { if err == nil { isSuccess = true } - ad.CreateFTPLog(su, operate, filename, isSuccess) - return sf, err + ftpLog := ad.CreateFTPLog(su, operate, filename, isSuccess) + f := &SftpFile{File: sf, FTPLog: ftpLog, cleanupFunc: con.DecreaseRef} + return f, err } func (ad *AssetDir) ReadDir(path string) (res []os.FileInfo, err error) { pathData := ad.parsePath(path) folderName, ok := ad.IsUniqueSu() - if !ok { + if !ok && !ad.isFromWebTerminal { if len(pathData) == 1 && pathData[0] == "" { - for folderName := range ad.suMaps { - res = append(res, NewFakeFile(folderName, true)) + for accountName := range ad.suMaps { + res = append(res, NewFakeFile(accountName, true)) } return } @@ -219,27 +276,38 @@ func (ad *AssetDir) ReadDir(path string) (res []os.FileInfo, err error) { } su, ok := ad.suMaps[folderName] if !ok { - return nil, errNoSystemUser - } - if !ad.validatePermission(su, model.ConnectAction) { - return res, sftp.ErrSshFxPermissionDenied + return nil, errNoAccountUser } con, realPath := ad.GetSFTPAndRealPath(su, strings.Join(pathData, "/")) - if con == nil { + ad.CurrentPath = realPath + + if con == nil || con.isClosed { return nil, sftp.ErrSshFxConnectionLost } + con.IncreaseRef() + defer con.DecreaseRef() res, err = con.client.ReadDir(realPath) - if !ad.ShowHidden { - noHiddenFiles := make([]os.FileInfo, 0, len(res)) - for i := 0; i < len(res); i++ { - if !strings.HasPrefix(res[i].Name(), ".") { - noHiddenFiles = append(noHiddenFiles, res[i]) + isRootAccount := con.token.Account.Username == "root" + fileInfoList := make([]os.FileInfo, 0, len(res)) + for i := 0; i < len(res); i++ { + info := NewSftpFileInfo(res[i], isRootAccount, ad.isFromWebTerminal) + if !ad.ShowHidden && strings.HasPrefix(info.Name(), ".") { + continue + } + // 兼容 MobaXterm, 打开软连接目录 + if info.Mode()&os.ModeSymlink != 0 { + linkPath := filepath.Join(realPath, info.Name()) + linkInfo, err1 := con.client.Stat(linkPath) + if err1 != nil { + logger.Errorf("ReadDir get link info err: %s", err1) + continue } + info = NewSftpFileInfo(linkInfo, isRootAccount, ad.isFromWebTerminal) } - return noHiddenFiles, err + fileInfoList = append(fileInfoList, info) } - return + return fileInfoList, err } func (ad *AssetDir) ReadLink(path string) (res string, err error) { @@ -254,16 +322,15 @@ func (ad *AssetDir) ReadLink(path string) (res string, err error) { } su, ok := ad.suMaps[folderName] if !ok { - return "", errNoSystemUser - } - if !ad.validatePermission(su, model.ConnectAction) { - return res, sftp.ErrSshFxPermissionDenied + return "", errNoAccountUser } con, realPath := ad.GetSFTPAndRealPath(su, strings.Join(pathData, "/")) - if con == nil { + if con == nil || con.isClosed { return "", sftp.ErrSshFxConnectionLost } + con.IncreaseRef() + defer con.DecreaseRef() res, err = con.client.ReadLink(realPath) return } @@ -280,15 +347,21 @@ func (ad *AssetDir) RemoveDirectory(path string) (err error) { } su, ok := ad.suMaps[folderName] if !ok { - return errNoSystemUser + return errNoAccountUser } - if !ad.validatePermission(su, model.UploadAction) { + if !su.Actions.EnableDelete() { return sftp.ErrSshFxPermissionDenied } con, realPath := ad.GetSFTPAndRealPath(su, strings.Join(pathData, "/")) - if con == nil { + if con == nil || con.isClosed { return sftp.ErrSshFxConnectionLost } + if con.IsRootPath(realPath) { + logger.Errorf("Diable to remove root setting path %s", realPath) + return sftp.ErrSshFxPermissionDenied + } + con.IncreaseRef() + defer con.DecreaseRef() err = ad.removeDirectoryAll(con.client, realPath) filename := realPath isSuccess := false @@ -315,23 +388,32 @@ func (ad *AssetDir) Rename(oldNamePath, newNamePath string) (err error) { } su, ok := ad.suMaps[folderName] if !ok { - return errNoSystemUser + return errNoAccountUser + } + if !su.Actions.EnableUpload() { + return sftp.ErrSshFxPermissionDenied } conn1, oldRealPath := ad.GetSFTPAndRealPath(su, strings.Join(oldPathData, "/")) conn2, newRealPath := ad.GetSFTPAndRealPath(su, strings.Join(newPathData, "/")) if conn1 != conn2 { return sftp.ErrSshFxOpUnsupported } - - err = conn1.client.Rename(oldRealPath, newRealPath) - + if conn1 == nil || conn1.isClosed { + return sftp.ErrSshFxConnectionLost + } + conn1.IncreaseRef() + defer conn1.DecreaseRef() filename := fmt.Sprintf("%s=>%s", oldRealPath, newRealPath) - isSuccess := false operate := model.OperateRename - if err == nil { - isSuccess = true + err = conn1.client.Rename(oldRealPath, newRealPath) + if err != nil { + ad.CreateFTPLog(su, operate, filename, false) + return err } - ad.CreateFTPLog(su, operate, filename, isSuccess) + if fileInfo, err1 := conn2.client.Stat(newRealPath); err1 == nil && fileInfo.IsDir() { + operate = model.OperateRenameDir + } + ad.CreateFTPLog(su, operate, filename, true) return } @@ -347,17 +429,18 @@ func (ad *AssetDir) Remove(path string) (err error) { } su, ok := ad.suMaps[folderName] if !ok { - return errNoSystemUser + return errNoAccountUser } - if !ad.validatePermission(su, model.UploadAction) { + if !su.Actions.EnableDelete() { return sftp.ErrSshFxPermissionDenied } con, realPath := ad.GetSFTPAndRealPath(su, strings.Join(pathData, "/")) - if con == nil { + if con == nil || con.isClosed { return sftp.ErrSshFxConnectionLost } + con.IncreaseRef() + defer con.DecreaseRef() err = con.client.Remove(realPath) - filename := realPath isSuccess := false operate := model.OperateDelete @@ -380,17 +463,17 @@ func (ad *AssetDir) Stat(path string) (res os.FileInfo, err error) { } su, ok := ad.suMaps[folderName] if !ok { - return nil, errNoSystemUser - } - if !ad.validatePermission(su, model.ConnectAction) { - return res, sftp.ErrSshFxPermissionDenied + return nil, errNoAccountUser } con, realPath := ad.GetSFTPAndRealPath(su, strings.Join(pathData, "/")) - if con == nil { + if con == nil || con.isClosed { return nil, sftp.ErrSshFxConnectionLost } + con.IncreaseRef() + defer con.DecreaseRef() res, err = con.client.Stat(realPath) - return + isRootAccount := con.token.Account.Username == "root" + return NewSftpFileInfo(res, isRootAccount, ad.isFromWebTerminal), err } func (ad *AssetDir) Symlink(oldNamePath, newNamePath string) (err error) { @@ -400,7 +483,7 @@ func (ad *AssetDir) Symlink(oldNamePath, newNamePath string) (err error) { folderName, ok := ad.IsUniqueSu() if !ok { if oldPathData[0] != newPathData[0] { - return errNoSystemUser + return errNoAccountUser } folderName = oldPathData[0] oldPathData = oldPathData[1:] @@ -408,9 +491,9 @@ func (ad *AssetDir) Symlink(oldNamePath, newNamePath string) (err error) { } su, ok := ad.suMaps[folderName] if !ok { - return errNoSystemUser + return errNoAccountUser } - if !ad.validatePermission(su, model.UploadAction) { + if !su.Actions.EnableUpload() { return sftp.ErrSshFxPermissionDenied } conn1, oldRealPath := ad.GetSFTPAndRealPath(su, strings.Join(oldPathData, "/")) @@ -418,6 +501,8 @@ func (ad *AssetDir) Symlink(oldNamePath, newNamePath string) (err error) { if conn1 != conn2 { return sftp.ErrSshFxOpUnsupported } + conn1.IncreaseRef() + defer conn1.DecreaseRef() err = conn1.client.Symlink(oldRealPath, newRealPath) filename := fmt.Sprintf("%s=>%s", oldRealPath, newRealPath) isSuccess := false @@ -454,31 +539,91 @@ func (ad *AssetDir) removeDirectoryAll(conn *sftp.Client, path string) error { return conn.RemoveDirectory(path) } -func (ad *AssetDir) GetSFTPAndRealPath(su *model.SystemUser, path string) (conn *SftpConn, realPath string) { +func (ad *AssetDir) checkExpired() { + ad.sftpSessions.Range(func(key, value interface{}) bool { + if value == nil { + return true + } + conn := value.(*SftpSession) + if conn.isClosed { + return true + } + if conn.client == nil { + return true + } + if conn.IsExpired() { + conn.CloseWithReason(model.ReasonErrIdleDisconnect) + logger.Infof("SFTP session %s idle timeout closed", conn.sess.ID) + } + return true + }) +} + +func (ad *AssetDir) GetRealPath(sftpSess *SftpSession, path string) string { + realPath := filepath.Join(sftpSess.rootDirPath, strings.TrimPrefix(path, "/")) + if ad.isFromWebTerminal && path != "" && strings.HasPrefix(path, sftpSess.rootDirPath) { + return path + } + return realPath +} + +func (ad *AssetDir) GetSFTPAndRealPath(su *model.PermAccount, path string) (conn *SftpConn, realPath string) { ad.mu.Lock() defer ad.mu.Unlock() - var ok bool - conn, ok = ad.sftpClients[su.ID] - if !ok { - var err error - conn, err = ad.GetSftpClient(su) - if err != nil { - logger.Errorf("Get Sftp Client err: %s", err.Error()) - return nil, "" - } - ad.sftpClients[su.ID] = conn + key := su.String() + if val, ok := ad.sftpSessions.Load(key); ok { + sftpSess := val.(*SftpSession) + realPath = ad.GetRealPath(sftpSess, path) + return sftpSess.SftpConn, realPath } - switch strings.ToLower(su.SftpRoot) { - case "home", "~", "": - realPath = filepath.Join(conn.HomeDirPath, strings.TrimPrefix(path, "/")) - default: - if strings.Index(su.SftpRoot, "/") != 0 { - su.SftpRoot = fmt.Sprintf("/%s", su.SftpRoot) + sftpSession, err := ad.createSftpSession(su) + if err != nil { + logger.Errorf("Create sftp session err: %s", err.Error()) + return nil, "" + } + ad.sftpSessions.Store(key, sftpSession) + + realPath = ad.GetRealPath(sftpSession, path) + return sftpSession.SftpConn, realPath +} + +func (ad *AssetDir) createSftpSession(su *model.PermAccount) (sftpSess *SftpSession, err error) { + conn, err := ad.GetSftpClient(su) + if err != nil { + return nil, err + } + reqSession := conn.token.CreateSession(ad.opts.RemoteAddr, ad.opts.fromType, model.SFTPType) + respSession, err1 := ad.jmsService.CreateSession(reqSession) + if err1 != nil { + logger.Errorf("Create sftp Session err: %s", err1.Error()) + return nil, err1 + } + respSession.TokenId = conn.token.Id + sftpSession := &SftpSession{SftpConn: conn, sess: &respSession, jmsService: ad.jmsService} + terminalFunc := func(task *model.TerminalTask) error { + switch task.Name { + case model.TaskKillSession: + sftpSession.CloseWithReason(model.ReasonErrAdminTerminate) + return nil + case model.TaskPermExpired: + sftpSession.CloseWithReason(model.ReasonErrPermissionExpired) + return nil + case model.TaskPermValid: + return nil } - realPath = filepath.Join(su.SftpRoot, strings.TrimPrefix(path, "/")) + return fmt.Errorf("sftp session not support task: %s", task.Name) } - return + traceSession := session.NewSession(&respSession, terminalFunc) + session.AddSession(traceSession) + ad.recordSessionLifecycle(traceSession.ID, model.AssetConnectSuccess, "") + + go func() { + _ = conn.client.Wait() + sftpSession.Close() + logger.Infof("SFTP session %s closed", sftpSession.sess.ID) + }() + return sftpSession, nil } func (ad *AssetDir) IsUniqueSu() (folderName string, ok bool) { @@ -497,133 +642,46 @@ func (ad *AssetDir) getSubFolderNames() []string { return sus } -func (ad *AssetDir) validatePermission(su *model.SystemUser, action string) bool { - for _, pemAction := range su.Actions { - if pemAction == action || pemAction == model.AllAction { - return true - } +func (ad *AssetDir) GetSftpClient(su *model.PermAccount) (conn *SftpConn, err error) { + connectToken, err2 := ad.createConnectToken(su) + if err2 != nil { + return nil, fmt.Errorf("get connect token account err: %s", err2) } - return false + return ad.getNewSftpConn(&connectToken, su) } -func (ad *AssetDir) GetSftpClient(su *model.SystemUser) (conn *SftpConn, err error) { - if su.Password == "" && su.PrivateKey == "" { - var info model.SystemUserAuthInfo - info, err = ad.jmsService.GetSystemUserAuthById(su.ID, ad.ID, ad.user.ID, ad.user.Username) - if err != nil { - return nil, err - } - su.Username = info.Username - su.Password = info.Password - su.PrivateKey = info.PrivateKey - } - - if ad.reuse { - if sftpConn, ok := ad.getCacheSftpConn(su); ok { - return sftpConn, nil - } +func (ad *AssetDir) createConnectToken(su *model.PermAccount) (model.ConnectToken, error) { + if ad.opts.token != nil { + return *ad.opts.token, nil } - - return ad.getNewSftpConn(su) -} - -func (ad *AssetDir) getCacheSftpConn(su *model.SystemUser) (*SftpConn, bool) { - if ad.detailAsset == nil { - return nil, false - } - var ( - sshClient *SSHClient - ok bool - ) - key := MakeReuseSSHClientKey(ad.user.ID, ad.ID, su.ID, ad.detailAsset.IP, su.Username) - switch su.Username { - case "": - sshClient, ok = searchSSHClientFromCache(key) - if ok { - su.Username = sshClient.Cfg.Username - } - default: - sshClient, ok = GetClientFromCache(key) + req := service.SuperConnectTokenReq{ + UserId: ad.user.ID, + AssetId: ad.opts.ID, + Account: su.Alias, + Protocol: model.ProtocolSFTP, + ConnectMethod: model.ProtocolSFTP, + RemoteAddr: ad.opts.RemoteAddr, } - - if ok { - logger.Infof("User %s get reuse ssh client(%s)", ad.user, sshClient) - sess, err := sshClient.AcquireSession() - if err != nil { - logger.Errorf("User %s reuse ssh client(%s) new session err: %s", ad.user, sshClient, err) - return nil, false - } - sftpClient, err := NewSftpConn(sess) - if err != nil { - _ = sess.Close() - sshClient.ReleaseSession(sess) - logger.Errorf("User %s reuse ssh client(%s) start sftp conn err: %s", - ad.user.String(), sshClient, err) - return nil, false - } - go func() { - _ = sftpClient.Wait() - sshClient.ReleaseSession(sess) - logger.Infof("Reuse ssh client(%s) for SFTP release", sshClient) - }() - HomeDirPath, err := sftpClient.Getwd() - if err != nil { - logger.Errorf("Reuse ssh client(%s) get home dir err: %s", sshClient, err) - _ = sftpClient.Close() - _ = sess.Close() - return nil, false + // sftp 不支持 ACL 复核的资产,需要从 web terminal 中登录 + tokenInfo, err := ad.jmsService.CreateSuperConnectToken(&req) + if err != nil { + msg := err.Error() + if tokenInfo.Detail != "" { + msg = tokenInfo.Detail } - conn := &SftpConn{client: sftpClient, HomeDirPath: HomeDirPath} - logger.Infof("Reuse ssh client(%s) for SFTP, current ref: %d", sshClient, sshClient.RefCount()) - return conn, true + logger.Errorf("Create super connect token failed: %s", msg) + return model.ConnectToken{}, fmt.Errorf("create super connect token failed: %s", msg) } - logger.Debugf("User %s do not found reuse ssh client for SFTP", ad.user) - return nil, false + return ad.jmsService.GetConnectTokenInfo(tokenInfo.ID, true) } -func (ad *AssetDir) getNewSftpConn(su *model.SystemUser) (conn *SftpConn, err error) { +func (ad *AssetDir) getNewSftpConn(connectToken *model.ConnectToken, + su *model.PermAccount) (conn *SftpConn, err error) { if ad.detailAsset == nil { return nil, errNoSelectAsset } - key := MakeReuseSSHClientKey(ad.user.ID, ad.ID, su.ID, ad.detailAsset.IP, su.Username) timeout := config.GlobalConfig.SSHTimeout - - sshAuthOpts := make([]SSHClientOption, 0, 6) - sshAuthOpts = append(sshAuthOpts, SSHClientUsername(su.Username)) - sshAuthOpts = append(sshAuthOpts, SSHClientHost(ad.detailAsset.IP)) - sshAuthOpts = append(sshAuthOpts, SSHClientPort(ad.detailAsset.ProtocolPort(su.Protocol))) - sshAuthOpts = append(sshAuthOpts, SSHClientPassword(su.Password)) - sshAuthOpts = append(sshAuthOpts, SSHClientTimeout(timeout)) - if su.PrivateKey != "" { - // 先使用 password 解析 PrivateKey - if signer, err1 := gossh.ParsePrivateKeyWithPassphrase([]byte(su.PrivateKey), - []byte(su.Password)); err1 == nil { - sshAuthOpts = append(sshAuthOpts, SSHClientPrivateAuth(signer)) - } else { - // 如果之前使用password解析失败,则去掉 password, 尝试直接解析 PrivateKey 防止错误的passphrase - if signer, err1 = gossh.ParsePrivateKey([]byte(su.PrivateKey)); err1 == nil { - sshAuthOpts = append(sshAuthOpts, SSHClientPrivateAuth(signer)) - } - } - } - if ad.domain != nil && len(ad.domain.Gateways) > 0 { - proxyArgs := make([]SSHClientOptions, 0, len(ad.domain.Gateways)) - for i := range ad.domain.Gateways { - gateway := ad.domain.Gateways[i] - proxyArg := SSHClientOptions{ - Host: gateway.IP, - Port: strconv.Itoa(gateway.Port), - Username: gateway.Username, - Password: gateway.Password, - Passphrase: gateway.Password, // 兼容 带密码的private_key, - PrivateKey: gateway.PrivateKey, - Timeout: timeout, - } - proxyArgs = append(proxyArgs, proxyArg) - } - sshAuthOpts = append(sshAuthOpts, SSHClientProxyClient(proxyArgs...)) - } - sshClient, err := NewSSHClient(sshAuthOpts...) + sshClient, err := NewSSHClientWithToken(connectToken, timeout) if err != nil { logger.Errorf("Get new SSH client err: %s", err) return nil, err @@ -634,56 +692,196 @@ func (ad *AssetDir) getNewSftpConn(su *model.SystemUser) (conn *SftpConn, err er _ = sshClient.Close() return nil, err } - AddClientCache(key, sshClient) sftpClient, err := NewSftpConn(sess) if err != nil { logger.Errorf("SSH client(%s) start sftp conn err %s", sshClient, err) _ = sess.Close() sshClient.ReleaseSession(sess) + _ = sshClient.Close() return nil, err } - go func() { - _ = sftpClient.Wait() - sshClient.ReleaseSession(sess) - logger.Infof("ssh client(%s) for SFTP release", sshClient) - }() - HomeDirPath, err := sftpClient.Getwd() + homeDirPath, err := sftpClient.Getwd() if err != nil { logger.Errorf("SSH client sftp (%s) get home dir err %s", sshClient, err) _ = sftpClient.Close() + sshClient.ReleaseSession(sess) + _ = sshClient.Close() return nil, err } logger.Infof("SSH client %s start sftp client session success", sshClient) - conn = &SftpConn{client: sftpClient, HomeDirPath: HomeDirPath} - return conn, err + + platform := connectToken.Platform + sftpRoot := platform.Protocols.GetSftpPath(model.ProtocolSFTP) + accountUsername := su.Username + username := ad.user.Username + switch strings.ToLower(sftpRoot) { + case "home", "~", "": + sftpRoot = homeDirPath + default: + // ${ACCOUNT} 连接的账号用户名, ${USER} 当前用户用户名, ${HOME} 当前家目录 + homeDir := homeDirPath + sftpRoot = strings.ReplaceAll(sftpRoot, "${ACCOUNT}", accountUsername) + sftpRoot = strings.ReplaceAll(sftpRoot, "${USER}", username) + sftpRoot = strings.ReplaceAll(sftpRoot, "${HOME}", homeDir) + if strings.Index(sftpRoot, "/") != 0 { + sftpRoot = fmt.Sprintf("/%s", sftpRoot) + } + } + maxIdleInt := ad.opts.terminalCfg.MaxIdleTime + conn = &SftpConn{ + sshClient: sshClient, + sshSession: sess, + permAccount: su, + rootDirPath: sftpRoot, + client: sftpClient, + HomeDirPath: homeDirPath, + token: connectToken, + maxIdleTime: time.Duration(maxIdleInt) * time.Minute, + } + return conn, nil +} + +func NewSSHClientWithToken(connectToken *model.ConnectToken, timeout int) (*SSHClient, error) { + asset := connectToken.Asset + account := connectToken.Account + username := account.Username + protocol := connectToken.Protocol + + sshAuthOpts := make([]SSHClientOption, 0, 6) + sshAuthOpts = append(sshAuthOpts, SSHClientUsername(username)) + sshAuthOpts = append(sshAuthOpts, SSHClientHost(asset.Address)) + sshAuthOpts = append(sshAuthOpts, SSHClientPort(asset.ProtocolPort(protocol))) + sshAuthOpts = append(sshAuthOpts, SSHClientTimeout(timeout)) + if account.IsSSHKey() { + if signer, err1 := gossh.ParsePrivateKey([]byte(account.Secret)); err1 == nil { + sshAuthOpts = append(sshAuthOpts, SSHClientPrivateAuth(signer)) + } else { + logger.Errorf("ssh private key parse failed: %s", err1) + } + } else { + sshAuthOpts = append(sshAuthOpts, SSHClientPassword(account.Secret)) + } + + if connectToken.Gateway != nil { + gateway := connectToken.Gateway + proxyArgs := make([]SSHClientOptions, 0, 1) + loginAccount := gateway.Account + port := gateway.Protocols.GetProtocolPort(model.ProtocolSSH) + proxyArg := SSHClientOptions{ + Host: gateway.Address, + Port: strconv.Itoa(port), + Username: loginAccount.Username, + Timeout: timeout, + } + if loginAccount.IsSSHKey() { + proxyArg.PrivateKey = loginAccount.Secret + } else { + proxyArg.Password = loginAccount.Secret + } + proxyArgs = append(proxyArgs, proxyArg) + sshAuthOpts = append(sshAuthOpts, SSHClientProxyClient(proxyArgs...)) + } + return NewSSHClient(sshAuthOpts...) } func (ad *AssetDir) parsePath(path string) []string { + if ad.isFromWebTerminal { + return []string{path} + } path = strings.TrimPrefix(path, "/") return strings.Split(path, "/") } func (ad *AssetDir) close() { - ad.mu.Lock() - defer ad.mu.Unlock() - for _, conn := range ad.sftpClients { - if conn != nil { + ad.sftpSessions.Range(func(key, value interface{}) bool { + if conn, ok := value.(*SftpSession); ok { conn.Close() } - } + return true + }) } -func (ad *AssetDir) CreateFTPLog(su *model.SystemUser, operate, filename string, isSuccess bool) { +func (ad *AssetDir) CreateFTPLog(su *model.PermAccount, operate, filename string, isSuccess bool) *model.FTPLog { + sessionId := "" + if val, ok := ad.sftpSessions.Load(su.String()); ok { + traceSession := val.(*SftpSession) + sessionId = traceSession.sess.ID + } else { + logger.Errorf("Not found sftp session for asset %s account %s", + ad.detailAsset.String(), su.String()) + } + data := model.FTPLog{ + ID: com.UUID(), User: ad.user.String(), - Hostname: ad.detailAsset.String(), + Asset: ad.detailAsset.String(), OrgID: ad.detailAsset.OrgID, - SystemUser: su.String(), - RemoteAddr: ad.addr, + Account: su.String(), + RemoteAddr: ad.opts.RemoteAddr, Operate: operate, Path: filename, DateStart: common.NewNowUTCTime(), IsSuccess: isSuccess, + Session: sessionId, } - ad.logChan <- &data + if err := ad.jmsService.CreateFileOperationLog(data); err != nil { + logger.Errorf("Create ftp log err: %s", err) + } + return &data +} + +func (ad *AssetDir) recordSessionLifecycle(sid string, event model.LifecycleEvent, reason string) { + logObj := model.SessionLifecycleLog{Reason: reason} + if err := ad.jmsService.RecordSessionLifecycleLog(sid, event, logObj); err != nil { + logger.Errorf("Update session %s lifecycle %s failed: %s", sid, event, err) + } +} + +func IsExistPath(client *sftp.Client, path string) bool { + _, err := client.Stat(path) + return err == nil +} + +func NewSftpFileInfo(info os.FileInfo, isRoot, isFromWebTerminal bool) os.FileInfo { + if !isRoot { + return info + } + return &SftpFileInfo{info: info, isRoot: isRoot, isFromWebTerminal: isFromWebTerminal} +} + +type SftpFileInfo struct { + info os.FileInfo + isRoot bool + isFromWebTerminal bool +} + +func (ad *SftpFileInfo) Name() string { + return ad.info.Name() +} + +func (ad *SftpFileInfo) Size() int64 { + return ad.info.Size() +} + +/* + 特殊处理: + 如果是 root 账号,获取的目录信息,手动修改其文件权限可读写, + 允许其在 web sftp 可以上传文件 +*/ + +func (ad *SftpFileInfo) Mode() os.FileMode { + if ad.isRoot && ad.info.IsDir() { + return ad.info.Mode() | os.ModePerm + } + return ad.info.Mode() +} + +func (ad *SftpFileInfo) ModTime() time.Time { + return ad.info.ModTime() +} + +func (ad *SftpFileInfo) IsDir() bool { return ad.info.IsDir() } + +func (ad *SftpFileInfo) Sys() interface{} { + return ad.info.Sys() } diff --git a/pkg/srvconn/sftp_node.go b/pkg/srvconn/sftp_node.go index fc07ae0e9..f91137420 100644 --- a/pkg/srvconn/sftp_node.go +++ b/pkg/srvconn/sftp_node.go @@ -11,7 +11,8 @@ type NodeDir struct { ID string folderName string - subDirs map[string]os.FileInfo + subDirs map[string]os.FileInfo + _subDirs sync.Map modeTime time.Time @@ -29,6 +30,7 @@ func (nd *NodeDir) Size() int64 { return 0 } func (nd *NodeDir) Mode() os.FileMode { return os.FileMode(0444) | os.ModeDir } + func (nd *NodeDir) ModTime() time.Time { return nd.modeTime } func (nd *NodeDir) IsDir() bool { return true } @@ -49,6 +51,9 @@ func (nd *NodeDir) loadSubNodeTree() { if nd.loadSubFunc != nil { nd.subDirs = nd.loadSubFunc() } + for k, v := range nd.subDirs { + nd._subDirs.Store(k, v) + } }) } @@ -61,6 +66,18 @@ func (nd *NodeDir) close() { if assetDir, ok := dir.(*AssetDir); ok { assetDir.close() } - } } + +func (nd *NodeDir) checkExpired() { + nd._subDirs.Range(func(key, value interface{}) bool { + if nodeDir, ok := value.(*NodeDir); ok { + nodeDir.checkExpired() + return true + } + if assetDir, ok := value.(*AssetDir); ok { + assetDir.checkExpired() + } + return true + }) +} diff --git a/pkg/srvconn/sftp_session.go b/pkg/srvconn/sftp_session.go new file mode 100644 index 000000000..8658af1b1 --- /dev/null +++ b/pkg/srvconn/sftp_session.go @@ -0,0 +1,38 @@ +package srvconn + +import ( + "sync" + + "github.com/jumpserver-dev/sdk-go/common" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" + "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/session" +) + +type SftpSession struct { + *SftpConn + sess *model.Session + once sync.Once + jmsService *service.JMService +} + +func (s *SftpSession) CloseWithReason(reason model.SessionLifecycleReasonErr) { + s.once.Do(func() { + s.SftpConn.Close() + session.RemoveSessionById(s.sess.ID) + if _, err := s.jmsService.SessionFinished(s.sess.ID, common.NewNowUTCTime()); err != nil { + logger.Errorf("SFTP Session finished err: %s", err) + } + logger.Debugf("SFTP Session finished %s", s.sess.ID) + logObj := model.SessionLifecycleLog{Reason: reason.String()} + if err := s.jmsService.RecordSessionLifecycleLog(s.sess.ID, model.AssetConnectFinished, logObj); err != nil { + logger.Errorf("Update session %s lifecycle asset_connect_finished failed: %s", s.sess.ID, err) + } + }) + +} + +func (s *SftpSession) Close() { + s.CloseWithReason(model.ReasonErrConnectDisconnect) +} diff --git a/pkg/srvconn/sftpconn.go b/pkg/srvconn/sftpconn.go index bf4e7c3b3..fdebccb1a 100644 --- a/pkg/srvconn/sftpconn.go +++ b/pkg/srvconn/sftpconn.go @@ -10,28 +10,43 @@ import ( "github.com/pkg/sftp" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" "github.com/jumpserver/koko/pkg/logger" ) var errNoSelectAsset = errors.New("please select one of the assets") type UserSftpConn struct { - User *model.User - Addr string + User *model.User + Addr string + loginFrom model.LabelField + Dirs map[string]os.FileInfo modeTime time.Time - logChan chan *model.FTPLog closed chan struct{} searchDir *SearchResultDir jmsService *service.JMService + + opts *userSftpOption + + assetDir *AssetDir +} + +func (u *UserSftpConn) GetCurrentPath() string { + if u.assetDir != nil { + return u.assetDir.CurrentPath + } + return "" } func (u *UserSftpConn) ReadDir(path string) (res []os.FileInfo, err error) { + if u.assetDir != nil { + return u.assetDir.ReadDir(path) + } fi, restPath := u.ParsePath(path) if rootDir, ok := fi.(*UserSftpConn); ok { return rootDir.List() @@ -48,6 +63,10 @@ func (u *UserSftpConn) ReadDir(path string) (res []os.FileInfo, err error) { } func (u *UserSftpConn) Stat(path string) (res os.FileInfo, err error) { + if u.assetDir != nil { + return u.assetDir.Stat(path) + } + fi, restPath := u.ParsePath(path) if rootDir, ok := fi.(*UserSftpConn); ok { return rootDir, nil @@ -64,6 +83,9 @@ func (u *UserSftpConn) Stat(path string) (res os.FileInfo, err error) { } func (u *UserSftpConn) ReadLink(path string) (name string, err error) { + if u.assetDir != nil { + return u.assetDir.ReadLink(path) + } fi, restPath := u.ParsePath(path) if _, ok := fi.(*UserSftpConn); ok && restPath == "" { return "", sftp.ErrSshFxOpUnsupported @@ -80,6 +102,9 @@ func (u *UserSftpConn) ReadLink(path string) (name string, err error) { } func (u *UserSftpConn) Rename(oldNamePath, newNamePath string) (err error) { + if u.assetDir != nil { + return u.assetDir.Rename(oldNamePath, newNamePath) + } oldFi, oldRestPath := u.ParsePath(oldNamePath) newFi, newRestPath := u.ParsePath(newNamePath) if oldAssetDir, ok := oldFi.(*AssetDir); ok { @@ -94,6 +119,9 @@ func (u *UserSftpConn) Rename(oldNamePath, newNamePath string) (err error) { } func (u *UserSftpConn) RemoveDirectory(path string) (err error) { + if u.assetDir != nil { + return u.assetDir.RemoveDirectory(path) + } fi, restPath := u.ParsePath(path) if _, ok := fi.(*UserSftpConn); ok && restPath == "" { return sftp.ErrSshFxPermissionDenied @@ -109,6 +137,9 @@ func (u *UserSftpConn) RemoveDirectory(path string) (err error) { } func (u *UserSftpConn) Remove(path string) (err error) { + if u.assetDir != nil { + return u.assetDir.Remove(path) + } fi, restPath := u.ParsePath(path) if _, ok := fi.(*UserSftpConn); ok && restPath == "" { return sftp.ErrSshFxPermissionDenied @@ -124,6 +155,10 @@ func (u *UserSftpConn) Remove(path string) (err error) { } func (u *UserSftpConn) MkdirAll(path string) (err error) { + if u.assetDir != nil { + return u.assetDir.MkdirAll(path) + } + fi, restPath := u.ParsePath(path) if _, ok := fi.(*UserSftpConn); ok && restPath == "" { return sftp.ErrSshFxPermissionDenied @@ -139,6 +174,9 @@ func (u *UserSftpConn) MkdirAll(path string) (err error) { } func (u *UserSftpConn) Symlink(oldNamePath, newNamePath string) (err error) { + if u.assetDir != nil { + return u.assetDir.Symlink(oldNamePath, newNamePath) + } oldFi, oldRestPath := u.ParsePath(oldNamePath) newFi, newRestPath := u.ParsePath(newNamePath) if oldAssetDir, ok := oldFi.(*AssetDir); ok { @@ -151,7 +189,11 @@ func (u *UserSftpConn) Symlink(oldNamePath, newNamePath string) (err error) { return sftp.ErrSshFxPermissionDenied } -func (u *UserSftpConn) Create(path string) (*sftp.File, error) { +func (u *UserSftpConn) Create(path string) (*SftpFile, error) { + if u.assetDir != nil { + return u.assetDir.Create(path) + } + fi, restPath := u.ParsePath(path) if _, ok := fi.(*UserSftpConn); ok { return nil, sftp.ErrSshFxPermissionDenied @@ -167,7 +209,10 @@ func (u *UserSftpConn) Create(path string) (*sftp.File, error) { return nil, errNoSelectAsset } -func (u *UserSftpConn) Open(path string) (*sftp.File, error) { +func (u *UserSftpConn) Open(path string) (*SftpFile, error) { + if u.assetDir != nil { + return u.assetDir.Open(path) + } fi, restPath := u.ParsePath(path) if _, ok := fi.(*UserSftpConn); ok { return nil, sftp.ErrSshFxPermissionDenied @@ -219,6 +264,9 @@ func (u *UserSftpConn) Sys() interface{} { } func (u *UserSftpConn) List() (res []os.FileInfo, err error) { + if u.assetDir != nil { + return u.assetDir.ReadDir("/") + } for _, item := range u.Dirs { res = append(res, item) } @@ -261,18 +309,17 @@ func (u *UserSftpConn) ParsePath(path string) (fi os.FileInfo, restPath string) return } -func (u *UserSftpConn) initial() { +func (u *UserSftpConn) generateSubFoldersFromRootTree() map[string]os.FileInfo { nodeTrees, err := u.jmsService.GetNodeTreeByUserAndNodeKey(u.User.ID, "") if err != nil { logger.Errorf("User sftp initial err: %s", err) - return + return map[string]os.FileInfo{} } u.searchDir = &SearchResultDir{ folderName: SearchFolderName, modeTime: time.Now().UTC(), subDirs: map[string]os.FileInfo{}} - dirs := u.generateSubFoldersFromNodeTree(nodeTrees, true) - u.Dirs = dirs + return u.generateSubFoldersFromNodeTree(nodeTrees, true) } func (u *UserSftpConn) LoadNodeSubFoldersByKey(nodeKey string) SubFoldersLoadFunc { @@ -307,80 +354,85 @@ func (u *UserSftpConn) generateSubFoldersFromNodeTree(nodeTrees model.NodeTreeLi folderName := cleanFolderName(node.Value) folderName = findAvailableKeyByPaddingSuffix(matchFunc, folderName, paddingCharacter) loadFunc := u.LoadNodeSubFoldersByKey(node.Key) - nodeDir := NewNodeDir(WithFolderID(node.ID), - WithFolderName(folderName), WithSubFoldersLoadFunc(loadFunc)) + opts := make([]FolderBuilderOption, 0, 3) + opts = append(opts, WithFolderID(item.ID)) + opts = append(opts, WithFolderName(folderName)) + opts = append(opts, WithSubFoldersLoadFunc(loadFunc)) + nodeDir := NewNodeDir(opts...) dirs[folderName] = &nodeDir case model.TreeTypeAsset: - asset := item.Meta.Data - if !asset.IsSupportProtocol(ProtocolSSH) { + assetMeta := item.Meta.Data + if !assetMeta.SupportSFTP { + logger.Debugf("Asset %s not support sftp protocol ignore", item.Name) continue } - folderName := cleanFolderName(asset.Hostname) + folderName := cleanFolderName(item.Name) folderName = findAvailableKeyByPaddingSuffix(matchFunc, folderName, paddingCharacter) - assetDir := NewAssetDir(u.jmsService, u.User, u.logChan, WithFolderID(asset.ID), - WithFolderName(folderName), WitRemoteAddr(u.Addr)) - dirs[folderName] = &assetDir + opts := make([]FolderBuilderOption, 0, 4) + opts = append(opts, WithFolderID(item.ID)) + opts = append(opts, WithFolderName(folderName)) + opts = append(opts, WitRemoteAddr(u.Addr)) + opts = append(opts, WithFromType(u.loginFrom)) + opts = append(opts, WithTerminalConfig(u.opts.terminalCfg)) + assetDir := NewAssetDir(u.jmsService, u.User, opts...) + dirs[folderName] = assetDir } } return dirs } -func (u *UserSftpConn) generateSubFoldersFromAssets(assets ...model.Asset) map[string]os.FileInfo { +func (u *UserSftpConn) generateSubFoldersFromToken(token *model.ConnectToken) map[string]os.FileInfo { + dirs := make(map[string]os.FileInfo) + asset := token.Asset + if !asset.IsSupportProtocol(ProtocolSFTP) { + return dirs + } + folderName := cleanFolderName(asset.Name) + opts := make([]FolderBuilderOption, 0, 5) + opts = append(opts, WithFolderID(asset.ID)) + opts = append(opts, WithFolderName(folderName)) + opts = append(opts, WitRemoteAddr(u.Addr)) + opts = append(opts, WithToken(token)) + opts = append(opts, WithFromType(u.loginFrom)) + opts = append(opts, WithTerminalConfig(u.opts.terminalCfg)) + assetDir := NewAssetDir(u.jmsService, u.User, opts...) + assetDir.isFromWebTerminal = true + assetDir.loadSystemUsers() + dirs[folderName] = assetDir + u.assetDir = assetDir + return dirs +} + +func (u *UserSftpConn) generateSubFoldersFromAssets(assets []model.PermAsset) map[string]os.FileInfo { dirs := make(map[string]os.FileInfo) matchFunc := func(s string) bool { _, ok := dirs[s] return ok } - for _, asset := range assets { - if asset.IsSupportProtocol(ProtocolSSH) { - folderName := cleanFolderName(asset.Hostname) - folderName = findAvailableKeyByPaddingSuffix(matchFunc, folderName, paddingCharacter) - assetDir := NewAssetDir(u.jmsService, u.User, u.logChan, WithFolderID(asset.ID), - WithFolderName(folderName), WitRemoteAddr(u.Addr)) - dirs[folderName] = &assetDir - } - } - return dirs -} - -func (u *UserSftpConn) loopPushFTPLog() { - ftpLogList := make([]*model.FTPLog, 0, 1024) - maxRetry := 0 - var err error - tick := time.NewTicker(time.Second * 20) - defer tick.Stop() - for { - select { - case <-u.closed: - if len(ftpLogList) == 0 { - return - } - case <-tick.C: - if len(ftpLogList) == 0 { - continue - } - case logData, ok := <-u.logChan: - if !ok { - return - } - ftpLogList = append(ftpLogList, logData) - } - - data := ftpLogList[len(ftpLogList)-1] - err = u.jmsService.CreateFileOperationLog(*data) - if err == nil { - ftpLogList = ftpLogList[:len(ftpLogList)-1] - maxRetry = 0 + for i := range assets { + // todo: 后期优化 API 循环查询的情况 + permAssetDetail, err := u.jmsService.GetUserPermAssetDetailById(u.User.ID, assets[i].ID) + if err != nil { + logger.Errorf("Get perm detail asset %s err: %s", assets[i].Name, err) continue - } else { - logger.Errorf("Create FTP log err: %s", err.Error()) } - - if maxRetry > 5 { - ftpLogList = ftpLogList[1:] + if !permAssetDetail.SupportProtocol(ProtocolSFTP) { + continue } - maxRetry++ + folderName := cleanFolderName(assets[i].Name) + folderName = findAvailableKeyByPaddingSuffix(matchFunc, folderName, paddingCharacter) + opts := make([]FolderBuilderOption, 0, 5) + opts = append(opts, WithFolderID(assets[i].ID)) + opts = append(opts, WithFolderName(folderName)) + opts = append(opts, WitRemoteAddr(u.Addr)) + opts = append(opts, WithAsset(assets[i])) + opts = append(opts, WithFromType(u.loginFrom)) + opts = append(opts, WithTerminalConfig(u.opts.terminalCfg)) + opts = append(opts, WithFolderUsername(u.opts.accountUsername)) + assetDir := NewAssetDir(u.jmsService, u.User, opts...) + dirs[folderName] = assetDir } + return dirs } func (u *UserSftpConn) Search(key string) (res []os.FileInfo, err error) { @@ -393,42 +445,118 @@ func (u *UserSftpConn) Search(key string) (res []os.FileInfo, err error) { logger.Errorf("search asset err: %s", err) return nil, err } - dirs := u.generateSubFoldersFromAssets(assets...) + dirs := u.generateSubFoldersFromAssets(assets) u.searchDir.SetSubDirs(dirs) return u.searchDir.List() } -func NewUserSftpConn(jmsService *service.JMService, user *model.User, addr string) *UserSftpConn { - u := UserSftpConn{ - User: user, - Addr: addr, - Dirs: map[string]os.FileInfo{}, - modeTime: time.Now().UTC(), - logChan: make(chan *model.FTPLog, 1024), - closed: make(chan struct{}), - jmsService: jmsService, +type userSftpOption struct { + user *model.User + RemoteAddr string + loginFrom model.LabelField + assets []model.PermAsset + token *model.ConnectToken + + accountUsername string + + terminalCfg *model.TerminalConfig +} + +type UserSftpOption func(*userSftpOption) + +func WithUser(user *model.User) UserSftpOption { + return func(o *userSftpOption) { + o.user = user + } +} + +func WithRemoteAddr(addr string) UserSftpOption { + return func(o *userSftpOption) { + o.RemoteAddr = addr + } +} + +func WithLoginFrom(loginFrom model.LabelField) UserSftpOption { + return func(o *userSftpOption) { + o.loginFrom = loginFrom + } +} + +func WithAssets(assets []model.PermAsset) UserSftpOption { + return func(o *userSftpOption) { + o.assets = assets + } +} + +func WithConnectToken(token *model.ConnectToken) UserSftpOption { + return func(o *userSftpOption) { + o.token = token } - u.initial() - go u.loopPushFTPLog() - return &u } -func NewUserSftpConnWithAssets(jmsService *service.JMService, user *model.User, addr string, assets ...model.Asset) *UserSftpConn { +func WithAccountUsername(username string) UserSftpOption { + return func(o *userSftpOption) { + o.accountUsername = username + } +} + +func WithTerminalCfg(cfg *model.TerminalConfig) UserSftpOption { + return func(o *userSftpOption) { + o.terminalCfg = cfg + } +} + +func NewUserSftpConn(jmsService *service.JMService, opts ...UserSftpOption) *UserSftpConn { + var sftpOpts userSftpOption + for _, setter := range opts { + setter(&sftpOpts) + } u := UserSftpConn{ - User: user, - Addr: addr, + User: sftpOpts.user, + Addr: sftpOpts.RemoteAddr, + loginFrom: sftpOpts.loginFrom, Dirs: map[string]os.FileInfo{}, modeTime: time.Now().UTC(), - logChan: make(chan *model.FTPLog, 1024), closed: make(chan struct{}), jmsService: jmsService, + opts: &sftpOpts, + } + + switch { + case sftpOpts.token != nil: + u.Dirs = u.generateSubFoldersFromToken(sftpOpts.token) + case len(sftpOpts.assets) > 0: + u.Dirs = u.generateSubFoldersFromAssets(sftpOpts.assets) + default: + u.Dirs = u.generateSubFoldersFromRootTree() } - dirs := u.generateSubFoldersFromAssets(assets...) - u.Dirs = dirs - go u.loopPushFTPLog() + go u.run() return &u } +func (u *UserSftpConn) run() { + tick := time.NewTicker(time.Minute) + defer tick.Stop() + for { + select { + case <-u.closed: + logger.Infof("User %s sftp conn closed", u.User.String()) + return + case <-tick.C: + logger.Debugf("User %s sftp conn check expired", u.User.String()) + } + for _, dir := range u.Dirs { + if nodeDir, ok := dir.(*NodeDir); ok { + nodeDir.checkExpired() + continue + } + if assetDir, ok := dir.(*AssetDir); ok { + assetDir.checkExpired() + } + } + } +} + func cleanFolderName(folderName string) string { return strings.ReplaceAll(folderName, SFTPPathSeparator, "_") } diff --git a/pkg/srvconn/sftpfile.go b/pkg/srvconn/sftpfile.go index 1756c66ea..ac4be7d6c 100644 --- a/pkg/srvconn/sftpfile.go +++ b/pkg/srvconn/sftpfile.go @@ -3,22 +3,25 @@ package srvconn import ( "errors" "os" + "strings" "sync" + "sync/atomic" "syscall" "time" "github.com/pkg/sftp" + gossh "golang.org/x/crypto/ssh" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" "github.com/jumpserver/koko/pkg/config" - "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/jms-sdk-go/service" ) const ( SearchFolderName = "_Search" ) -var errNoSystemUser = errors.New("please select one of the systemUsers") +var errNoAccountUser = errors.New("please select one of the account user") type SearchResultDir struct { subDirs map[string]os.FileInfo @@ -70,8 +73,8 @@ func (sd *SearchResultDir) close() { } } -func NewNodeDir(builders ...FolderBuilderFunc) NodeDir { - var dirConf folderConfiguration +func NewNodeDir(builders ...FolderBuilderOption) NodeDir { + var dirConf folderOptions for i := range builders { builders[i](&dirConf) } @@ -79,79 +82,205 @@ func NewNodeDir(builders ...FolderBuilderFunc) NodeDir { ID: dirConf.ID, folderName: dirConf.Name, subDirs: map[string]os.FileInfo{}, + _subDirs: sync.Map{}, modeTime: time.Now().UTC(), once: new(sync.Once), loadSubFunc: dirConf.loadSubFunc, } } -type FolderBuilderFunc func(info *folderConfiguration) +type FolderBuilderOption func(info *folderOptions) type SubFoldersLoadFunc func() map[string]os.FileInfo -type folderConfiguration struct { +type folderOptions struct { ID string Name string RemoteAddr string + fromType model.LabelField loadSubFunc SubFoldersLoadFunc + + asset *model.PermAsset + + token *model.ConnectToken + + accountUsername string + + terminalCfg *model.TerminalConfig +} + +func WithFolderUsername(username string) FolderBuilderOption { + return func(info *folderOptions) { + info.accountUsername = username + } } -func WithFolderName(name string) FolderBuilderFunc { - return func(info *folderConfiguration) { +func WithFolderName(name string) FolderBuilderOption { + return func(info *folderOptions) { info.Name = name } } -func WithFolderID(id string) FolderBuilderFunc { - return func(info *folderConfiguration) { +func WithFolderID(id string) FolderBuilderOption { + return func(info *folderOptions) { info.ID = id } } -func WitRemoteAddr(addr string) FolderBuilderFunc { - return func(info *folderConfiguration) { +func WitRemoteAddr(addr string) FolderBuilderOption { + return func(info *folderOptions) { info.RemoteAddr = addr } } -func WithSubFoldersLoadFunc(loadFunc SubFoldersLoadFunc) FolderBuilderFunc { - return func(info *folderConfiguration) { +func WithSubFoldersLoadFunc(loadFunc SubFoldersLoadFunc) FolderBuilderOption { + return func(info *folderOptions) { info.loadSubFunc = loadFunc } } -func NewAssetDir(jmsService *service.JMService, user *model.User, logChan chan<- *model.FTPLog, - builders ...FolderBuilderFunc) AssetDir { - var dirConf folderConfiguration - for i := range builders { - builders[i](&dirConf) +func WithAsset(asset model.PermAsset) FolderBuilderOption { + return func(info *folderOptions) { + info.asset = &asset + } +} + +func WithToken(token *model.ConnectToken) FolderBuilderOption { + return func(info *folderOptions) { + info.token = token + } +} + +func WithFromType(fromType model.LabelField) FolderBuilderOption { + return func(info *folderOptions) { + info.fromType = fromType + } +} + +func WithTerminalConfig(cfg *model.TerminalConfig) FolderBuilderOption { + return func(info *folderOptions) { + info.terminalCfg = cfg + } +} + +func NewAssetDir(jmsService *service.JMService, user *model.User, opts ...FolderBuilderOption) *AssetDir { + var dirOpts folderOptions + for _, setter := range opts { + setter(&dirOpts) } conf := config.GetConf() - return AssetDir{ - ID: dirConf.ID, - folderName: dirConf.Name, - addr: dirConf.RemoteAddr, + detailAsset := dirOpts.asset + var permAccounts []model.PermAccount + if dirOpts.token != nil { + account := dirOpts.token.Account + actions := dirOpts.token.Actions + permAccount := model.PermAccount{ + Name: account.Name, + Username: account.Username, + SecretType: account.SecretType.Value, + Actions: actions, + } + permAccounts = append(permAccounts, permAccount) + detailAsset = dirOpts.asset + } + return &AssetDir{ + opts: dirOpts, user: user, + detailAsset: detailAsset, modeTime: time.Now().UTC(), - suMaps: nil, - logChan: logChan, + suMaps: generateSubAccountsFolderMap(permAccounts), ShowHidden: conf.ShowHiddenFile, - reuse: conf.ReuseConnection, - sftpClients: map[string]*SftpConn{}, - jmsService: jmsService, + + sftpSessions: sync.Map{}, + jmsService: jmsService, } } +type SftpFile struct { + *sftp.File + FTPLog *model.FTPLog + + cleanupFunc func() +} + +func (s *SftpFile) Close() error { + if s.cleanupFunc != nil { + s.cleanupFunc() + } + return s.File.Close() +} + type SftpConn struct { + permAccount *model.PermAccount HomeDirPath string client *sftp.Client + sshClient *SSHClient + sshSession *gossh.Session + token *model.ConnectToken + isClosed bool + rootDirPath string + + nextExpiredTime time.Time + refs atomic.Int32 + lock sync.Mutex + maxIdleTime time.Duration +} + +func (s *SftpConn) IsExpired() bool { + if s.Ref() > 0 { + // some client is using + return false + } + s.lock.Lock() + defer s.lock.Unlock() + now := time.Now() + return now.Sub(s.nextExpiredTime) > 0 || s.token.ExpireAt.IsExpired(now) +} + +func (s *SftpConn) UpdateExpiredTime() { + s.lock.Lock() + defer s.lock.Unlock() + s.nextExpiredTime = time.Now().Add(s.maxIdleTime) +} + +func (s *SftpConn) IncreaseRef() { + s.refs.Add(1) + s.UpdateExpiredTime() } +func (s *SftpConn) DecreaseRef() { + s.refs.Add(-1) + s.UpdateExpiredTime() +} + +func (s *SftpConn) Ref() int32 { + return s.refs.Load() +} + +func (s *SftpConn) IsOverwriteFile() bool { + resolution := s.token.ConnectOptions.FilenameConflictResolution + return !strings.EqualFold(resolution, FilenamePolicySuffix) +} + +// check if the path is root path and disable to remove + +func (s *SftpConn) IsRootPath(path string) bool { + return s.rootDirPath == path +} + +const ( + FilenamePolicyReplace = "replace" + FilenamePolicySuffix = "suffix" +) + func (s *SftpConn) Close() { - if s.client == nil { - return + if s.client != nil { + _ = s.client.Close() + } + if s.sshClient != nil { + _ = s.sshClient.Close() } - _ = s.client.Close() + s.isClosed = true } func NewFakeFile(name string, isDir bool) *FakeFileInfo { diff --git a/pkg/srvconn/ssh.go b/pkg/srvconn/ssh.go index e2733a32a..a6740f06f 100644 --- a/pkg/srvconn/ssh.go +++ b/pkg/srvconn/ssh.go @@ -168,6 +168,8 @@ func NewSSHClientWithCfg(cfg *SSHClientOptions) (*SSHClient, error) { Timeout: time.Duration(cfg.Timeout) * time.Second, HostKeyCallback: gossh.InsecureIgnoreHostKey(), Config: createSSHConfig(), + + HostKeyAlgorithms: allHostKeyAlgorithms(), } destAddr := net.JoinHostPort(cfg.Host, cfg.Port) if len(cfg.proxySSHClientOptions) > 0 { @@ -211,6 +213,21 @@ type SSHClient struct { traceSessionMap map[*gossh.Session]time.Time refCount int32 + _selfRef int32 + + KeyId string +} + +func (s *SSHClient) increaseSelfRef() { + s._selfRef++ +} + +func (s *SSHClient) decreaseSelfRef() { + s._selfRef-- +} + +func (s *SSHClient) selfRef() int32 { + return s._selfRef } func (s *SSHClient) String() string { @@ -257,18 +274,34 @@ func (s *SSHClient) ReleaseSession(sess *gossh.Session) { func createSSHConfig() gossh.Config { var cfg gossh.Config cfg.SetDefaults() - cfg.Ciphers = append(cfg.Ciphers, notRecommendCiphers...) - cfg.KeyExchanges = append(cfg.KeyExchanges, notRecommandKeyExchanges...) + algos := gossh.SupportedAlgorithms() + insecureAlgos := gossh.InsecureAlgorithms() + ciphers := make([]string, 0, len(algos.Ciphers)+len(insecureAlgos.Ciphers)) + /* + Change the ciphers order, placing aes128-ctr first. + Compatible with old ssh servers. + */ + ciphers = append(ciphers, gossh.CipherAES128CTR) + ciphers = append(ciphers, insecureAlgos.Ciphers...) + ciphers = append(ciphers, algos.Ciphers...) + keyExchanges := make([]string, 0, len(algos.KeyExchanges)+len(insecureAlgos.KeyExchanges)) + keyExchanges = append(keyExchanges, insecureAlgos.KeyExchanges...) + keyExchanges = append(keyExchanges, algos.KeyExchanges...) + cfg.Ciphers = ciphers + cfg.KeyExchanges = keyExchanges return cfg } -var ( - notRecommendCiphers = []string{ - "arcfour256", "arcfour128", "arcfour", - "aes128-cbc", "3des-cbc", - } - - notRecommandKeyExchanges = []string{ - "diffie-hellman-group1-sha1", - } -) +func allHostKeyAlgorithms() []string { + supportedAlgos := gossh.SupportedAlgorithms() + insecureAlgos := gossh.InsecureAlgorithms() + hostKeyAlgos := make([]string, 0, len(supportedAlgos.HostKeys)+len(insecureAlgos.HostKeys)+1) + /* + Change the algorithm order, placing KeyAlgoED25519 first. + Compatible with certain SSH servers. + */ + hostKeyAlgos = append(hostKeyAlgos, gossh.KeyAlgoED25519) + hostKeyAlgos = append(hostKeyAlgos, supportedAlgos.HostKeys...) + hostKeyAlgos = append(hostKeyAlgos, insecureAlgos.HostKeys...) + return hostKeyAlgos +} diff --git a/pkg/srvconn/sshclients.go b/pkg/srvconn/sshclients.go index be3c22af1..6c076f931 100644 --- a/pkg/srvconn/sshclients.go +++ b/pkg/srvconn/sshclients.go @@ -1,14 +1,13 @@ package srvconn import ( - "strings" "time" "github.com/jumpserver/koko/pkg/logger" ) type UserSSHClient struct { - ID string // userID_assetID_systemUserID_systemUsername + ID string // 这个 user ssh client key 参考 MakeReuseSSHClientKey data map[*SSHClient]int64 name string } @@ -33,7 +32,7 @@ func (u *UserSSHClient) GetClient() *SSHClient { func (u *UserSSHClient) recycleClients() { needRemovedClients := make([]*SSHClient, 0, len(u.data)) for client := range u.data { - if client.RefCount() <= 0 { + if client.RefCount() <= 0 && client.selfRef() <= 0 { needRemovedClients = append(needRemovedClients, client) _ = client.Close() } @@ -56,7 +55,8 @@ func newSSHManager() *SSHManager { storeChan: make(chan *storeClient), reqChan: make(chan string), resultChan: make(chan *SSHClient), - searchChan: make(chan string), + + releaseChan: make(chan *storeClient), } go m.run() return &m @@ -66,7 +66,8 @@ type SSHManager struct { storeChan chan *storeClient reqChan chan string // reqId resultChan chan *SSHClient - searchChan chan string // prefix + + releaseChan chan *storeClient } func (s *SSHManager) run() { @@ -78,7 +79,7 @@ func (s *SSHManager) run() { select { case now := <-tick.C: /* - 1. 5 分钟无访问则 让所有的 UserSSHClient recycleClients + 1. 1 分钟无访问则 让所有的 UserSSHClient recycleClients 2. 并清理 count==0 的 UserSSHClient */ if now.After(latestVisited.Add(time.Minute)) { @@ -93,7 +94,7 @@ func (s *SSHManager) run() { for i := range needRemovedClients { delete(data, needRemovedClients[i]) } - logger.Infof("Remove %d user clients remain %d", + logger.Infof("Remove %d cache ssh clients remain %d", len(needRemovedClients), len(data)) } } @@ -105,31 +106,34 @@ func (s *SSHManager) run() { logger.Infof("Found client(%s) and remain %d", foundClient, userClient.Count()) } - s.resultChan <- foundClient - - case prefixKey := <-s.searchChan: - var foundClient *SSHClient - for key, userClient := range data { - if strings.HasPrefix(key, prefixKey) { - foundClient = userClient.GetClient() - logger.Infof("Found client(%s) and remain %d", - foundClient, userClient.Count()) - break - } + if foundClient != nil { + foundClient.increaseSelfRef() } s.resultChan <- foundClient + case reqClient := <-s.storeChan: - userClient, ok := data[reqClient.reqId] + reqClient.SSHClient.increaseSelfRef() + userClient, ok := data[reqClient.key] if !ok { userClient = &UserSSHClient{ - ID: reqClient.reqId, + ID: reqClient.key, name: reqClient.SSHClient.String(), data: make(map[*SSHClient]int64), } - data[reqClient.reqId] = userClient + data[reqClient.key] = userClient } userClient.AddClient(reqClient.SSHClient) + reqClient.SSHClient.KeyId = reqClient.key logger.Infof("Store new client(%s) remain %d", reqClient.String(), userClient.Count()) + case reqClient := <-s.releaseChan: + // 收到释放请求,及时释放对应的 SSHClient + reqClient.decreaseSelfRef() + if userClient, ok := data[reqClient.key]; ok { + userClient.recycleClients() + } else { + _ = reqClient.Close() + logger.Infof("SSH client(%s) not found in user ssh cache and close", reqClient.String()) + } } latestVisited = time.Now() @@ -144,18 +148,19 @@ func (s *SSHManager) getClientFromCache(key string) (*SSHClient, bool) { func (s *SSHManager) AddClientCache(key string, client *SSHClient) { s.storeChan <- &storeClient{ - reqId: key, + key: key, SSHClient: client, } } -func (s *SSHManager) searchSSHClientFromCache(prefixKey string) (client *SSHClient, ok bool) { - s.searchChan <- prefixKey - client = <-s.resultChan - return client, client != nil +func (s *SSHManager) ReleaseClientCacheKey(key string, client *SSHClient) { + s.releaseChan <- &storeClient{ + key: key, + SSHClient: client, + } } type storeClient struct { - reqId string + key string *SSHClient } diff --git a/pkg/sshd/server.go b/pkg/sshd/server.go index 9e72c9562..7fe4d0a24 100644 --- a/pkg/sshd/server.go +++ b/pkg/sshd/server.go @@ -10,6 +10,9 @@ import ( "github.com/pires/go-proxyproto" gossh "golang.org/x/crypto/ssh" + "github.com/jumpserver-dev/sdk-go/service" + "github.com/jumpserver/koko/pkg/config" + "github.com/jumpserver/koko/pkg/handler" "github.com/jumpserver/koko/pkg/logger" ) @@ -17,10 +20,25 @@ const ( sshChannelSession = "session" sshChannelDirectTCPIP = "direct-tcpip" sshSubSystemSFTP = "sftp" + + ChannelTCPIPForward = "tcpip-forward" + ChannelCancelTCPIPForward = "cancel-tcpip-forward" + ChannelForwardedTCPIP = "forwarded-tcpip" +) + +var ( + supportedMACs = []string{"hmac-sha2-256-etm@openssh.com", + "hmac-sha2-256", "hmac-sha1"} + + supportedKexAlgos = []string{ + "curve25519-sha256", "curve25519-sha256@libssh.org", + "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", + } ) type Server struct { - Srv *ssh.Server + Srv *ssh.Server + Handler *handler.Server } func (s *Server) Start() { @@ -39,50 +57,33 @@ func (s *Server) Stop() { logger.Fatal(s.Srv.Shutdown(ctx)) } -type SSHHandler interface { - GetSSHAddr() string - GetSSHSigner() ssh.Signer - KeyboardInteractiveAuth(ctx ssh.Context, challenger gossh.KeyboardInteractiveChallenge) AuthStatus - PasswordAuth(ctx ssh.Context, password string) AuthStatus - PublicKeyAuth(ctx ssh.Context, key ssh.PublicKey) AuthStatus - NextAuthMethodsHandler(ctx ssh.Context) []string - SessionHandler(ssh.Session) - SFTPHandler(ssh.Session) - LocalPortForwardingPermission(ctx ssh.Context, destinationHost string, destinationPort uint32) bool - DirectTCPIPChannelHandler(ctx ssh.Context, newChan gossh.NewChannel, destAddr string) -} - -type AuthStatus ssh.AuthResult - -const ( - AuthFailed = AuthStatus(ssh.AuthFailed) - AuthSuccessful = AuthStatus(ssh.AuthSuccessful) - AuthPartiallySuccessful = AuthStatus(ssh.AuthPartiallySuccessful) -) - -func NewSSHServer(handler SSHHandler) *Server { +func NewSSHServer(jmsService *service.JMService) *Server { + cf := config.GlobalConfig + addr := net.JoinHostPort(cf.BindHost, cf.SSHPort) + termCfg, err := jmsService.GetTerminalConfig() + if err != nil { + logger.Fatal(err) + } + singer, err := ParsePrivateKeyFromString(termCfg.HostKey) + if err != nil { + logger.Fatalf("Parse Terminal private key failed: %s\n", err) + } + sshHandler := handler.NewServer(termCfg, jmsService) srv := &ssh.Server{ - LocalPortForwardingCallback: func(ctx ssh.Context, destinationHost string, destinationPort uint32) bool { - return handler.LocalPortForwardingPermission(ctx, destinationHost, destinationPort) - }, - Addr: handler.GetSSHAddr(), - KeyboardInteractiveHandler: func(ctx ssh.Context, challenger gossh.KeyboardInteractiveChallenge) ssh.AuthResult { - return ssh.AuthResult(handler.KeyboardInteractiveAuth(ctx, challenger)) - }, - PasswordHandler: func(ctx ssh.Context, password string) ssh.AuthResult { - return ssh.AuthResult(handler.PasswordAuth(ctx, password)) - }, - PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) ssh.AuthResult { - return ssh.AuthResult(handler.PublicKeyAuth(ctx, key)) - }, - NextAuthMethodsHandler: func(ctx ssh.Context) []string { - return handler.NextAuthMethodsHandler(ctx) - }, - HostSigners: []ssh.Signer{handler.GetSSHSigner()}, - Handler: handler.SessionHandler, - SubsystemHandlers: map[string]ssh.SubsystemHandler{ - sshSubSystemSFTP: handler.SFTPHandler, + Addr: addr, + PasswordHandler: sshHandler.PasswordAuth, + PublicKeyHandler: sshHandler.PublicKeyAuth, + Version: "JumpServer", + HostSigners: []ssh.Signer{singer}, + MaxSessions: int32(cf.SshMaxSessions), + ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig { + cfg := gossh.Config{MACs: supportedMACs, KeyExchanges: supportedKexAlgos} + return &gossh.ServerConfig{Config: cfg} }, + Handler: sshHandler.SessionHandler, + LocalPortForwardingCallback: sshHandler.LocalPortForwardingPermission, + ReversePortForwardingCallback: sshHandler.ReversePortForwardingPermission, + SubsystemHandlers: map[string]ssh.SubsystemHandler{sshSubSystemSFTP: sshHandler.SFTPHandler}, ChannelHandlers: map[string]ssh.ChannelHandler{ sshChannelSession: ssh.DefaultSessionHandler, sshChannelDirectTCPIP: func(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) { @@ -97,11 +98,15 @@ func NewSSHServer(handler SSHHandler) *Server { return } dest := net.JoinHostPort(localD.DestAddr, strconv.FormatInt(int64(localD.DestPort), 10)) - handler.DirectTCPIPChannelHandler(ctx, newChan, dest) + sshHandler.DirectTCPIPChannelHandler(ctx, newChan, dest) }, }, + RequestHandlers: map[string]ssh.RequestHandler{ + ChannelTCPIPForward: sshHandler.HandleSSHRequest, + ChannelCancelTCPIPForward: sshHandler.HandleSSHRequest, + }, } - return &Server{srv} + return &Server{srv, sshHandler} } type localForwardChannelData struct { diff --git a/pkg/utils/aes_test.go b/pkg/utils/aes_test.go index e344c5488..aec2226db 100644 --- a/pkg/utils/aes_test.go +++ b/pkg/utils/aes_test.go @@ -1,6 +1,9 @@ package utils import ( + "bytes" + "crypto/aes" + "encoding/base64" "testing" ) @@ -23,3 +26,34 @@ func TestDecrypt(t *testing.T) { } } + +func TestEncrypt(t *testing.T) { + secret := "4bd477efa46d4acea8016af7b332589d" + src := "abc" + ret, err := encryptECB([]byte(src), []byte(secret)) + if err != nil { + t.Fatal(err) + } + + t.Log(base64.StdEncoding.EncodeToString(ret)) + +} + +func encryptECB(plaintext []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + if len(plaintext)%aes.BlockSize != 0 { + padding := aes.BlockSize - len(plaintext)%aes.BlockSize + plaintext = append(plaintext, bytes.Repeat([]byte{byte(0x00)}, padding)...) + } + + ciphertext := make([]byte, len(plaintext)) + for i := 0; i < len(plaintext); i += aes.BlockSize { + block.Encrypt(ciphertext[i:i+aes.BlockSize], plaintext[i:i+aes.BlockSize]) + } + + return ciphertext, nil +} diff --git a/pkg/utils/buffer.go b/pkg/utils/buffer.go new file mode 100644 index 000000000..867607957 --- /dev/null +++ b/pkg/utils/buffer.go @@ -0,0 +1,37 @@ +package utils + +import ( + "bytes" + "sync" +) + +type SyncBuffer struct { + maxSize int + mu sync.Mutex + b bytes.Buffer +} + +func (s *SyncBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + if s.maxSize > 0 && s.b.Len()+len(p) > s.maxSize { + // Discard the write if it would exceed maxSize + s.mu.Unlock() + return len(p), nil + } + n, err := s.b.Write(p) + s.mu.Unlock() + return n, err +} + +func (s *SyncBuffer) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.String() +} + +func NewMaxSizeBuffer(maxSize int) *SyncBuffer { + return &SyncBuffer{ + maxSize: maxSize, + b: bytes.Buffer{}, + } +} diff --git a/pkg/utils/k8scmd_wrapper.go b/pkg/utils/k8scmd_wrapper.go new file mode 100644 index 000000000..0ae941ebe --- /dev/null +++ b/pkg/utils/k8scmd_wrapper.go @@ -0,0 +1,95 @@ +package utils + +import ( + "bytes" + "fmt" + "io" + "log" + "os" + "os/exec" + "os/signal" + + "github.com/jumpserver/koko/pkg/config" +) + +const ( + envName = "K8S_ENCRYPTED_TOKEN" +) + +func GetDecryptedToken() (token string, err error) { + encryptToken := os.Getenv(envName) + if encryptToken != "" { + token, err = Decrypt(encryptToken, config.CipherKey) + } + return +} + +func WrappedExec(commandString string, secretToHide string) { + gracefulStop := make(chan os.Signal, 1) + // Ctrl + C 中断操作特殊处理,防止命令无法终止 + signal.Notify(gracefulStop, os.Interrupt) + go func() { + <-gracefulStop + // 增加换行符 + fmt.Println("") + os.Exit(1) + }() + + c := exec.Command("bash", "-c", commandString) + c.Stdin, c.Stdout = os.Stdin, os.Stdout + stderr, err := c.StderrPipe() + if err != nil { + log.Fatalln(err) + return + } + redirectStream := func() { + _, _ = io.Copy(os.Stderr, stderr) + } + if secretToHide != "" { + redirectStream = func() { + hiddenTokenOutput(stderr, os.Stderr, secretToHide) + } + } + go redirectStream() + _ = c.Run() +} + +func hiddenTokenOutput(src io.ReadCloser, dst io.WriteCloser, token string) { + tokenBuf := []byte(token) + buf := make([]byte, 1024*8) + var ( + index int + remain []byte + buffer bytes.Buffer + ) + for { + nr, err2 := src.Read(buf) + if nr > 0 { + for i := range buf[:nr] { + if index == len(tokenBuf) { + index = 0 + remain = nil + buffer.WriteString("*****") + buffer.WriteByte(buf[i]) + continue + } + if buf[i] == tokenBuf[index] { + index++ + remain = append(remain, buf[i]) + continue + } + if len(remain) > 0 { + buffer.Write(remain) + remain = nil + } + index = 0 + buffer.WriteByte(buf[i]) + } + _, _ = buffer.WriteTo(dst) + buffer.Reset() + } + if err2 != nil { + return + } + } +} diff --git a/pkg/utils/stat.go b/pkg/utils/stat.go index ce24d07a9..72f075654 100644 --- a/pkg/utils/stat.go +++ b/pkg/utils/stat.go @@ -21,6 +21,10 @@ func CpuLoad1Usage() float64 { avgLoadStat *load.AvgStat ) cpuCount, err = cpu.Counts(true) + if err != nil { + logger.Errorf("Get cpu load 1min err: %s", err) + return -1 + } avgLoadStat, err = load.Avg() if err != nil { logger.Errorf("Get cpu load 1min err: %s", err) diff --git a/pkg/zmodem/frame_type.go b/pkg/zmodem/frame_type.go index 3de4f43b6..5511d462c 100644 --- a/pkg/zmodem/frame_type.go +++ b/pkg/zmodem/frame_type.go @@ -1,6 +1,6 @@ package zmodem -//FRAME TYPES +// FRAME TYPES const ( ZRQINIT = 0x00 /* request receive init (s->r) */ ZRINIT = 0x01 /* receive init (r->s) */ @@ -90,7 +90,7 @@ const ( CAN = 0x18 ) -//ZDLE SEQUENCES +// ZDLE SEQUENCES const ( ZCRCE = 0x68 /* CRC next, frame ends, header packet follows */ ZCRCG = 0x69 /* CRC next, frame continues nonstop */ diff --git a/pkg/zmodem/zmodem.go b/pkg/zmodem/zmodem.go index 126004061..5ffd4e101 100644 --- a/pkg/zmodem/zmodem.go +++ b/pkg/zmodem/zmodem.go @@ -36,6 +36,8 @@ type ZmodemParser struct { hasDataTransfer bool FireStatusEvent func(event StatusEvent) + + AbnormalFinish bool } // rz sz 解析的入口 @@ -47,6 +49,7 @@ func (z *ZmodemParser) Parse(p []byte) { zSession := z.currentSession zSession.consume(p) if zSession.IsEnd() { + z.AbnormalFinish = zSession.AbnormalFinish z.currentSession = nil if z.FileEventCallback != nil && z.currentZFileInfo != nil { info := z.currentZFileInfo @@ -84,7 +87,6 @@ func (z *ZmodemParser) Parse(p []byte) { z.currentSession = &ZSession{ Type: TypeDownload, endCallback: func() { - z.setStatus(ZParserStatusNone) if z.FireStatusEvent != nil { z.FireStatusEvent(EndEvent) } @@ -101,7 +103,6 @@ func (z *ZmodemParser) Parse(p []byte) { z.currentSession = &ZSession{ Type: TypeUpload, endCallback: func() { - z.setStatus(ZParserStatusNone) if z.FireStatusEvent != nil { z.FireStatusEvent(EndEvent) } diff --git a/pkg/zmodem/zsession.go b/pkg/zmodem/zsession.go index 378c0ef08..5669afd46 100644 --- a/pkg/zmodem/zsession.go +++ b/pkg/zmodem/zsession.go @@ -39,6 +39,7 @@ func DecodeHexFrameHeader(p []byte) (h ZmodemHeader, offset int, ok bool) { hexBytes := p[:endPos] hexBytes = bytes.TrimSpace(hexBytes) + hexBytes = bytes.TrimSuffix(hexBytes, []byte{0x8d}) if len(hexBytes) != 18 { return } @@ -184,6 +185,8 @@ type ZSession struct { ZFileHeaderCallback func(zInfo *ZFileInfo) zOnHeader func(hd *ZmodemHeader) + + AbnormalFinish bool } // zsession 入口 @@ -198,6 +201,7 @@ func (s *ZSession) consume(p []byte) { return } logger.Infof("Zmodem session %s abnormally finish", s.Type) + s.AbnormalFinish = true return } if s.checkAbort(p) { @@ -346,7 +350,7 @@ func (s *ZSession) onHeader(hd *ZmodemHeader) { s.transferStatus = TransferStatusFinished s.zFileInfo = nil case ZFIN: - s.haveEnd = true + //s.haveEnd = true if s.endCallback != nil { s.endCallback() } diff --git a/static/plugins/elfinder/css/elfinder.full.css b/static/plugins/elfinder/css/elfinder.full.css index ad9e694f9..65bdac6b3 100755 --- a/static/plugins/elfinder/css/elfinder.full.css +++ b/static/plugins/elfinder/css/elfinder.full.css @@ -2883,6 +2883,7 @@ tr.elfinder-cwd-file td .elfinder-cwd-select { -moz-user-select: text; -khtml-user-select: text; user-select: text; + overflow: visible; } .elfinder .std42-dialog .ui-dialog-content label { @@ -4524,7 +4525,7 @@ embed.elfinder-quicklook-preview-audio { -moz-box-shadow: 0 0 12px #999999; -webkit-box-shadow: 0 0 12px #999999; box-shadow: 0 0 12px #999999; - color: #FFFFFF; + color: #fff; opacity: 0.9; filter: alpha(opacity=90); background-color: #030303; @@ -5196,5 +5197,4 @@ embed.elfinder-quicklook-preview-audio { rgba(216, 223, 230, 0.3) 5px, rgba(0, 0, 0, 0.1) 95%, rgba(0, 0, 0, 0) 100%); -} - +} \ No newline at end of file diff --git a/static/plugins/elfinder/css/theme-jms.css b/static/plugins/elfinder/css/theme-jms.css new file mode 100644 index 000000000..dad141c1d --- /dev/null +++ b/static/plugins/elfinder/css/theme-jms.css @@ -0,0 +1,1590 @@ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + src: local('Open Sans Italic'), local('OpenSans-Italic'), url("../fonts/OpenSans-Italic.ttf") format('truetype'); +} +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 700; + src: local('Open Sans Bold Italic'), local('OpenSans-BoldItalic'), url("../fonts/OpenSans-BoldItalic.ttf") format('truetype'); +} +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + src: local('Open Sans'), local('OpenSans'), url("../fonts/OpenSans-Regular.ttf") format('truetype'); +} +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 700; + src: local('Open Sans Bold'), local('OpenSans-Bold'), url("../fonts/OpenSans-Bold.ttf") format('truetype'); +} +.elfinder { + color: #546e7a; + font-family: "Open Sans", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.elfinder.ui-widget.ui-widget-content { + font-family: "Open Sans", sans-serif; + -webkit-box-shadow: 0 1px 8px rgba(0, 0, 0, 0.6); + box-shadow: 0 1px 8px rgba(0, 0, 0, 0.6); + -webkit-border-radius: 0; + border-radius: 0; + border: 0; +} +.elfinder * { + outline: 0 !important; +} +/** + * Input & Select + */ +input.elfinder-tabstop, +input.elfinder-tabstop.ui-state-hover, +select.elfinder-tabstop, +select.elfinder-tabstop.ui-state-hover { + padding: 5px; + color: #666; + background: #fff; + -webkit-border-radius: 3px; + border-radius: 3px; + font-weight: normal; + border-color: #888; + -webkit-box-shadow: none !important; + box-shadow: none !important; +} +/** + * Loading + */ +.elfinder-info-spinner, +.elfinder-navbar-spinner, +.elfinder-button-icon-spinner { + background: url("../images/loading.svg") center center no-repeat !important; + width: 16px; + height: 16px; +} +/** + * Progress Bar + */ +@-webkit-keyframes progress-animation { + from { + background-position: 1rem 0; + } + to { + background-position: 0 0; + } +} +@-moz-keyframes progress-animation { + from { + background-position: 1rem 0; + } + to { + background-position: 0 0; + } +} +@-o-keyframes progress-animation { + from { + background-position: 1rem 0; + } + to { + background-position: 0 0; + } +} +@keyframes progress-animation { + from { + background-position: 1rem 0; + } + to { + background-position: 0 0; + } +} +.elfinder-notify-progressbar { + border: 0; +} +.elfinder-notify-progress, +.elfinder-notify-progressbar { + -webkit-border-radius: 0; + border-radius: 0; +} +.elfinder-notify-progress, +.elfinder-resize-spinner { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: -moz-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + -webkit-background-size: 1rem 1rem; + -o-background-size: 1rem 1rem; + background-size: 1rem 1rem; + -webkit-animation: progress-animation 1s linear infinite; + -moz-animation: progress-animation 1s linear infinite; + -o-animation: progress-animation 1s linear infinite; + animation: progress-animation 1s linear infinite; + background-color: #0275d8; + height: 1rem; +} +/** + * Quick Look + */ +.elfinder-quicklook { + background: #232323; + -webkit-border-radius: 2px; + border-radius: 2px; +} +.elfinder-quicklook-titlebar { + background: inherit; +} +.elfinder-quicklook-fullscreen .elfinder-quicklook-navbar { + border: inherit; + opacity: inherit; + -webkit-border-radius: 4px; + border-radius: 4px; + background: rgba(66, 66, 66, 0.73); +} +.elfinder .elfinder-navdock { + border: 0; +} +.std42-dialog .ui-dialog-titlebar .ui-dialog-titlebar-close:hover .ui-icon, +.elfinder-mobile .std42-dialog .ui-dialog-titlebar .ui-dialog-titlebar-close .ui-icon, +.elfinder-quicklook-titlebar-icon .ui-icon.elfinder-icon-close:hover, +.elfinder-mobile .elfinder-quicklook-titlebar-icon .ui-icon.elfinder-icon-close, +.std42-dialog .ui-dialog-titlebar .elfinder-titlebar-minimize:hover .ui-icon, +.elfinder-mobile .std42-dialog .ui-dialog-titlebar .elfinder-titlebar-minimize .ui-icon, +.elfinder-quicklook-titlebar-icon .ui-icon.elfinder-icon-minimize:hover, +.elfinder-mobile .elfinder-quicklook-titlebar-icon .ui-icon.elfinder-icon-minimize, +.std42-dialog .ui-dialog-titlebar .elfinder-titlebar-full:hover .ui-icon, +.elfinder-mobile .std42-dialog .ui-dialog-titlebar .elfinder-titlebar-full .ui-icon, +.elfinder-quicklook-titlebar-icon .ui-icon.elfinder-icon-full:hover, +.elfinder-mobile .elfinder-quicklook-titlebar-icon .ui-icon.elfinder-icon-full { + background-image: none; +} +/** + * Toast Notification + */ +.elfinder .elfinder-toast > div { + background-color: #323232 !important; + color: #d6d6d6; + -webkit-box-shadow: none; + box-shadow: none; + opacity: inherit; + padding: 10px 60px; +} +.elfinder .elfinder-toast > div button.ui-button { + color: #fff; +} +.elfinder .elfinder-toast > .toast-info button.ui-button { + background-color: #3498db; +} +.elfinder .elfinder-toast > .toast-error button.ui-button { + background-color: #f44336; +} +.elfinder .elfinder-toast > .toast-success button.ui-button { + background-color: #4caf50; +} +.elfinder .elfinder-toast > .toast-warning button.ui-button { + background-color: #ff9800; +} +.elfinder-toast-msg { + font-family: "Open Sans", sans-serif; + font-size: 17px; +} +/** + * For Ace Editor + */ +#ace_settingsmenu { + font-family: "Open Sans", sans-serif; + -webkit-box-shadow: 0 1px 30px rgba(0, 0, 0, 0.6) !important; + box-shadow: 0 1px 30px rgba(0, 0, 0, 0.6) !important; + background-color: #1d2736 !important; + color: #e6e6e6 !important; +} +#ace_settingsmenu, +#kbshortcutmenu { + padding: 0; +} +.ace_optionsMenuEntry { + padding: 5px 10px; +} +.ace_optionsMenuEntry:hover { + background-color: #111721; +} +.ace_optionsMenuEntry label { + font-size: 13px; +} +#ace_settingsmenu input[type="text"], +#ace_settingsmenu select { + margin: 1px 2px 2px; + padding: 2px 5px; + -webkit-border-radius: 3px; + border-radius: 3px; + border: 0; + background: rgba(9, 53, 121, 0.75); + color: white; +} +/** + * Icons + * Webfont is generated by Fontello http://fontello.com + */ +@font-face { + font-family: material; + src: url("../icons/material.eot?7028746"); + src: url("../icons/material.eot?7028746#iefix") format("embedded-opentype"), url("../icons/material.woff2?7028746") format("woff2"), url("../icons/material.woff?7028746") format("woff"), url("../icons/material.ttf?7028746") format("truetype"), url("../icons/material.svg?7028746#material") format("svg"); + font-weight: normal; + font-style: normal; +} +@media screen and (-webkit-min-device-pixel-ratio: 0) { + @font-face { + font-family: material; + src: url("../icons/material.svg?7028746#material") format("svg"); + } +} +.ui-icon, +.elfinder-button-icon, +.ui-widget-header .ui-icon, +.ui-widget-content .ui-icon { + font: normal normal normal 14px/1 material; + background-image: inherit; + text-indent: inherit; +} +.ui-button-icon-only .ui-icon { + font: normal normal normal 14px/1 material; + background-image: inherit !important; + text-indent: 0; + font-size: 16px; +} +.elfinder-toolbar .elfinder-button-icon { + font-size: 20px; + color: #ddd; + margin-top: -2px; +} +.elfinder-button-icon { + background: inherit; +} +.elfinder-button-icon-home:before { + content: '\e800'; +} +.elfinder-button-icon-back:before { + content: '\e801'; +} +.elfinder-button-icon-forward:before { + content: '\e802'; +} +.elfinder-button-icon-up:before { + content: '\e803'; +} +.elfinder-button-icon-dir:before { + content: '\e804'; +} +.elfinder-button-icon-opendir:before { + content: '\e805'; +} +.elfinder-button-icon-reload:before { + content: '\e806'; +} +.elfinder-button-icon-open:before { + content: '\e807'; +} +.elfinder-button-icon-mkdir:before { + content: '\e808'; +} +.elfinder-button-icon-mkfile:before { + content: '\e809'; +} +.elfinder-button-icon-rm:before { + content: '\e80a'; +} +.elfinder-button-icon-trash:before { + content: '\e80b'; +} +.elfinder-button-icon-restore:before { + content: '\e80c'; +} +.elfinder-button-icon-copy:before { + content: '\e80d'; +} +.elfinder-button-icon-cut:before { + content: '\e80e'; +} +.elfinder-button-icon-paste:before { + content: '\e80f'; +} +.elfinder-button-icon-getfile:before { + content: '\e810'; +} +.elfinder-button-icon-duplicate:before { + content: '\e811'; +} +.elfinder-button-icon-rename:before { + content: '\e812'; +} +.elfinder-button-icon-edit:before { + content: '\e813'; +} +.elfinder-button-icon-quicklook:before { + content: '\e814'; +} +.elfinder-button-icon-upload:before { + content: '\e815'; +} +.elfinder-button-icon-download:before { + content: '\e816'; +} +.elfinder-button-icon-info:before { + content: '\e817'; +} +.elfinder-button-icon-extract:before { + content: '\e818'; +} +.elfinder-button-icon-archive:before { + content: '\e819'; +} +.elfinder-button-icon-view:before { + content: '\e81a'; +} +.elfinder-button-icon-view-list:before { + content: '\e81b'; +} +.elfinder-button-icon-help:before { + content: '\e81c'; +} +.elfinder-button-icon-resize:before { + content: '\e81d'; +} +.elfinder-button-icon-link:before { + content: '\e81e'; +} +.elfinder-button-icon-search:before { + content: '\e81f'; +} +.elfinder-button-icon-sort:before { + content: '\e820'; +} +.elfinder-button-icon-rotate-r:before { + content: '\e821'; +} +.elfinder-button-icon-rotate-l:before { + content: '\e822'; +} +.elfinder-button-icon-netmount:before { + content: '\e823'; +} +.elfinder-button-icon-netunmount:before { + content: '\e824'; +} +.elfinder-button-icon-places:before { + content: '\e825'; +} +.elfinder-button-icon-chmod:before { + content: '\e826'; +} +.elfinder-button-icon-accept:before { + content: '\e827'; +} +.elfinder-button-icon-menu:before { + content: '\e828'; +} +.elfinder-button-icon-colwidth:before { + content: '\e829'; +} +.elfinder-button-icon-fullscreen:before { + content: '\e82a'; +} +.elfinder-button-icon-unfullscreen:before { + content: '\e82b'; +} +.elfinder-button-icon-empty:before { + content: '\e82c'; +} +.elfinder-button-icon-undo:before { + content: '\e82d'; +} +.elfinder-button-icon-redo:before { + content: '\e82e'; +} +.elfinder-button-icon-preference:before { + content: '\e82f'; +} +.elfinder-button-icon-mkdirin:before { + content: '\e830'; +} +.elfinder-button-icon-selectall:before { + content: '\e831'; +} +.elfinder-button-icon-selectnone:before { + content: '\e832'; +} +.elfinder-button-icon-selectinvert:before { + content: '\e833'; +} +.elfinder-button-icon-theme:before { + content: '\e859'; +} +.elfinder-button-icon-logout:before { + content: '\e85a'; +} +.elfinder-button-icon-opennew:before { + content: '\e85b'; +} +.elfinder-button-search .ui-icon.ui-icon-search { + font-size: 17px; +} +.elfinder-button-search .ui-icon:hover { + opacity: 1; +} +.elfinder-navbar-icon { + font: normal normal normal 16px/1 material; + background-image: inherit !important; +} +.elfinder-navbar-icon:before { + content: '\e804'; +} +.elfinder-droppable-active .elfinder-navbar-icon:before, +.ui-state-active .elfinder-navbar-icon:before, +.ui-state-hover .elfinder-navbar-icon:before { + content: '\e805'; +} +.elfinder-navbar-root-local .elfinder-navbar-icon:before { + content: '\e83d'; +} +.elfinder-navbar-root-ftp .elfinder-navbar-icon:before { + content: '\e823'; +} +.elfinder-navbar-root-sql .elfinder-navbar-icon:before { + content: '\e83e'; +} +.elfinder-navbar-root-dropbox .elfinder-navbar-icon:before { + content: '\e83f'; +} +.elfinder-navbar-root-googledrive .elfinder-navbar-icon:before { + content: '\e840'; +} +.elfinder-navbar-root-onedrive .elfinder-navbar-icon:before { + content: '\e841'; +} +.elfinder-navbar-root-box .elfinder-navbar-icon:before { + content: '\e842'; +} +.elfinder-navbar-root-trash .elfinder-navbar-icon:before { + content: '\e80b'; +} +.elfinder-navbar-root-zip .elfinder-navbar-icon:before { + content: '\e85c'; +} +.elfinder-navbar-root-network .elfinder-navbar-icon:before { + content: '\e823'; +} +.elfinder-places .elfinder-navbar-root .elfinder-navbar-icon:before { + content: '\e825'; +} +.elfinder-navbar-arrow { + background-image: inherit !important; + font: normal normal normal 14px/1 material; + font-size: 10px; + padding-top: 3px; + padding-left: 2px; + color: #a9a9a9; +} +.ui-state-active .elfinder-navbar-arrow { + color: #fff; +} +.elfinder-ltr .elfinder-navbar-collapsed .elfinder-navbar-arrow:before { + content: '\e857'; +} +.elfinder-rtl .elfinder-navbar-collapsed .elfinder-navbar-arrow:before { + content: '\e858'; +} +.elfinder-ltr .elfinder-navbar-expanded .elfinder-navbar-arrow:before, +.elfinder-rtl .elfinder-navbar-expanded .elfinder-navbar-arrow:before { + content: '\e851'; +} +div.elfinder-cwd-wrapper-list tr.ui-state-default td span.ui-icon { + font-size: 8px; + margin-top: 5px; + margin-right: 5px; +} +div.elfinder-cwd-wrapper-list .ui-icon-grip-dotted-vertical { + margin: 1px; + float: right; +} +.elfinder-cwd-view-list .elfinder-navbar-root-local td .elfinder-cwd-icon, +.elfinder-navbar-root-local .elfinder-cwd-icon, +.elfinder-cwd-view-list .elfinder-navbar-root-ftp td .elfinder-cwd-icon, +.elfinder-navbar-root-ftp .elfinder-cwd-icon, +.elfinder-cwd-view-list .elfinder-navbar-root-sql td .elfinder-cwd-icon, +.elfinder-navbar-root-sql .elfinder-cwd-icon, +.elfinder-cwd-view-list .elfinder-navbar-root-dropbox td .elfinder-cwd-icon, +.elfinder-navbar-root-dropbox .elfinder-cwd-icon, +.elfinder-cwd-view-list .elfinder-navbar-root-googledrive td .elfinder-cwd-icon, +.elfinder-navbar-root-googledrive .elfinder-cwd-icon, +.elfinder-cwd-view-list .elfinder-navbar-root-onedrive td .elfinder-cwd-icon, +.elfinder-navbar-root-onedrive .elfinder-cwd-icon, +.elfinder-cwd-view-list .elfinder-navbar-root-box td .elfinder-cwd-icon, +.elfinder-navbar-root-box .elfinder-cwd-icon, +.elfinder-cwd-view-list .elfinder-navbar-root-trash td .elfinder-cwd-icon, +.elfinder-navbar-root-trash .elfinder-cwd-icon, +.elfinder-cwd-view-list .elfinder-navbar-root-zip td .elfinder-cwd-icon, +.elfinder-navbar-root-zip .elfinder-cwd-icon, +.elfinder-cwd-view-list .elfinder-navbar-root-network td .elfinder-cwd-icon, +.elfinder-navbar-root-network .elfinder-cwd-icon { + background-image: inherit; +} +.elfinder-cwd-view-list .elfinder-navbar-root-local td .elfinder-cwd-icon:before, +.elfinder-navbar-root-local .elfinder-cwd-icon:before, +.elfinder-cwd-view-list .elfinder-navbar-root-ftp td .elfinder-cwd-icon:before, +.elfinder-navbar-root-ftp .elfinder-cwd-icon:before, +.elfinder-cwd-view-list .elfinder-navbar-root-sql td .elfinder-cwd-icon:before, +.elfinder-navbar-root-sql .elfinder-cwd-icon:before, +.elfinder-cwd-view-list .elfinder-navbar-root-dropbox td .elfinder-cwd-icon:before, +.elfinder-navbar-root-dropbox .elfinder-cwd-icon:before, +.elfinder-cwd-view-list .elfinder-navbar-root-googledrive td .elfinder-cwd-icon:before, +.elfinder-navbar-root-googledrive .elfinder-cwd-icon:before, +.elfinder-cwd-view-list .elfinder-navbar-root-onedrive td .elfinder-cwd-icon:before, +.elfinder-navbar-root-onedrive .elfinder-cwd-icon:before, +.elfinder-cwd-view-list .elfinder-navbar-root-box td .elfinder-cwd-icon:before, +.elfinder-navbar-root-box .elfinder-cwd-icon:before, +.elfinder-cwd-view-list .elfinder-navbar-root-trash td .elfinder-cwd-icon:before, +.elfinder-navbar-root-trash .elfinder-cwd-icon:before, +.elfinder-cwd-view-list .elfinder-navbar-root-zip td .elfinder-cwd-icon:before, +.elfinder-navbar-root-zip .elfinder-cwd-icon:before, +.elfinder-cwd-view-list .elfinder-navbar-root-network td .elfinder-cwd-icon:before, +.elfinder-navbar-root-network .elfinder-cwd-icon:before { + font-family: material; + background-color: transparent; + color: #525252; + font-size: 55px; + position: relative; + top: -10px !important; + padding: 0; + display: contents !important; +} +.elfinder-cwd-view-list .elfinder-navbar-root-local td .elfinder-cwd-icon:before, +.elfinder-navbar-root-local .elfinder-cwd-icon:before { + content: '\e83d'; +} +.elfinder-cwd-view-list .elfinder-navbar-root-ftp td .elfinder-cwd-icon:before, +.elfinder-navbar-root-ftp .elfinder-cwd-icon:before { + content: '\e823'; +} +.elfinder-cwd-view-list .elfinder-navbar-root-sql td .elfinder-cwd-icon:before, +.elfinder-navbar-root-sql .elfinder-cwd-icon:before { + content: '\e83e'; +} +.elfinder-cwd-view-list .elfinder-navbar-roor-dropbox td .elfinder-cwd-icon:before, +.elfinder-navbar-roor-dropbox .elfinder-cwd-icon:before { + content: '\e83f'; +} +.elfinder-cwd-view-list .elfinder-navbar-roor-googledrive td .elfinder-cwd-icon:before, +.elfinder-navbar-roor-googledrive .elfinder-cwd-icon:before { + content: '\e840'; +} +.elfinder-cwd-view-list .elfinder-navbar-roor-onedrive td .elfinder-cwd-icon:before, +.elfinder-navbar-roor-onedrive .elfinder-cwd-icon:before { + content: '\e841'; +} +.elfinder-cwd-view-list .elfinder-navbar-roor-box td .elfinder-cwd-icon:before, +.elfinder-navbar-roor-box .elfinder-cwd-icon:before { + content: '\e842'; +} +.elfinder-cwd-view-list .elfinder-navbar-root-trash td .elfinder-cwd-icon:before, +.elfinder-navbar-root-trash .elfinder-cwd-icon:before { + content: '\e80b'; +} +.elfinder-cwd-view-list .elfinder-navbar-root-zip td .elfinder-cwd-icon:before, +.elfinder-navbar-root-zip .elfinder-cwd-icon:before { + content: '\e85c'; +} +.elfinder-cwd-view-list .elfinder-navbar-root-network td .elfinder-cwd-icon:before, +.elfinder-navbar-root-network .elfinder-cwd-icon:before { + content: '\e823'; +} +.elfinder-dialog-icon { + font: normal normal normal 14px/1 material; + background: inherit; + color: #524949; + font-size: 37px; +} +.elfinder-dialog-icon:before { + content: '\e843'; +} +.elfinder-dialog-icon-mkdir:before { + content: '\e808'; +} +.elfinder-dialog-icon-mkfile:before { + content: '\e809'; +} +.elfinder-dialog-icon-copy:before { + content: '\e80d'; +} +.elfinder-dialog-icon-prepare:before, +.elfinder-dialog-icon-move:before { + content: '\e844'; +} +.elfinder-dialog-icon-upload:before, +.elfinder-dialog-icon-chunkmerge:before { + content: '\e815'; +} +.elfinder-dialog-icon-rm:before { + content: '\e80a'; +} +.elfinder-dialog-icon-open:before, +.elfinder-dialog-icon-readdir:before, +.elfinder-dialog-icon-file:before { + content: '\e807'; +} +.elfinder-dialog-icon-reload:before { + content: '\e806'; +} +.elfinder-dialog-icon-download:before { + content: '\e816'; +} +.elfinder-dialog-icon-save:before { + content: '\e845'; +} +.elfinder-dialog-icon-rename:before { + content: '\e812'; +} +.elfinder-dialog-icon-zipdl:before, +.elfinder-dialog-icon-archive:before { + content: '\e819'; +} +.elfinder-dialog-icon-extract:before { + content: '\e818'; +} +.elfinder-dialog-icon-search:before { + content: '\e81f'; +} +.elfinder-dialog-icon-loadimg:before { + content: '\e846'; +} +.elfinder-dialog-icon-url:before { + content: '\e81e'; +} +.elfinder-dialog-icon-resize:before { + content: '\e81d'; +} +.elfinder-dialog-icon-netmount:before { + content: '\e823'; +} +.elfinder-dialog-icon-netunmount:before { + content: '\e824'; +} +.elfinder-dialog-icon-chmod:before { + content: '\e826'; +} +.elfinder-dialog-icon-preupload:before, +.elfinder-dialog-icon-dim:before { + content: '\e847'; +} +.elfinder-contextmenu .elfinder-contextmenu-item span.elfinder-contextmenu-icon { + font-size: 16px; +} +.elfinder-contextmenu .elfinder-contextmenu-item .elfinder-contextsubmenu-item .ui-icon { + font-size: 15px; +} +.elfinder-contextmenu .elfinder-contextmenu-item .elfinder-button-icon-link:before { + content: '\e837'; +} +.elfinder .elfinder-contextmenu-extra-icon { + margin-top: -6px; +} +.elfinder .elfinder-contextmenu-extra-icon a { + padding: 5px; + margin: -16px; +} +.elfinder-button-icon-link:before { + content: '\e81e' !important; +} +.elfinder .elfinder-contextmenu-arrow { + font: normal normal normal 14px/1 material; + background-image: inherit; + font-size: 10px !important; + padding-top: 3px; +} +.elfinder .elfinder-contextmenu-arrow:before { + content: '\e857'; +} +.elfinder-contextmenu .ui-state-hover .elfinder-contextmenu-arrow { + background-image: inherit; +} +.elfinder-quicklook .ui-resizable-se { + background: inherit; +} +.elfinder-quicklook-navbar-icon { + background: transparent; + font: normal normal normal 14px/1 material; + font-size: 32px; + color: #fff; +} +.elfinder-quicklook-titlebar-icon { + margin-top: -8px; +} +.elfinder-quicklook-titlebar-icon .ui-icon { + border: 0; + opacity: .8; + font-size: 15px; + padding: 1px; +} +.elfinder-quicklook-titlebar .ui-icon-circle-close, +.elfinder-quicklook .ui-icon-gripsmall-diagonal-se { + color: #f1f1f1; +} +.elfinder-quicklook-navbar-icon-prev:before { + content: '\e848'; +} +.elfinder-quicklook-navbar-icon-next:before { + content: '\e849'; +} +.elfinder-quicklook-navbar-icon-fullscreen:before { + content: '\e84a'; +} +.elfinder-quicklook-navbar-icon-fullscreen-off:before { + content: '\e84b'; +} +.elfinder-quicklook-navbar-icon-close:before { + content: '\e84c'; +} +.ui-button-icon { + background-image: inherit; +} +.ui-icon-search:before { + content: '\e81f'; +} +.ui-icon-closethick:before, +.ui-icon-close:before { + content: '\e839'; +} +.ui-icon-circle-close:before { + content: '\e84c'; +} +.ui-icon-gear:before { + content: '\e82f'; +} +.ui-icon-gripsmall-diagonal-se:before { + content: '\e838'; +} +.ui-icon-locked:before { + content: '\e834'; +} +.ui-icon-unlocked:before { + content: '\e836'; +} +.ui-icon-arrowrefresh-1-n:before { + content: '\e821'; +} +.ui-icon-plusthick:before { + content: '\e83a'; +} +.ui-icon-arrowreturnthick-1-s:before { + content: '\e83b'; +} +.ui-icon-minusthick:before { + content: '\e83c'; +} +.ui-icon-pin-s:before { + content: '\e84d'; +} +.ui-icon-check:before { + content: '\e84e'; +} +.ui-icon-arrowthick-1-s:before { + content: '\e84f'; +} +.ui-icon-arrowthick-1-n:before { + content: '\e850'; +} +.ui-icon-triangle-1-s:before { + content: '\e851'; +} +.ui-icon-triangle-1-n:before { + content: '\e852'; +} +.ui-icon-grip-dotted-vertical:before { + content: '\e853'; +} +.elfinder-lock, +.elfinder-perms, +.elfinder-symlink { + background-image: inherit; + font: normal normal normal 18px/1 material; + color: #4d4d4d; +} +.elfinder-na .elfinder-perms:before { + content: '\e824'; +} +.elfinder-ro .elfinder-perms:before { + content: '\e835'; +} +.elfinder-wo .elfinder-perms:before { + content: '\e854'; +} +.elfinder-group .elfinder-perms:before { + content: '\e800'; +} +.elfinder-lock:before { + content: '\e834'; +} +.elfinder-symlink:before { + content: '\e837'; +} +.elfinder .elfinder-toast > div { + font: normal normal normal 14px/1 material; +} +.elfinder .elfinder-toast > div:before { + font-size: 45px; + position: absolute; + left: 5px; + top: 15px; +} +.elfinder .elfinder-toast > .toast-info, +.elfinder .elfinder-toast > .toast-error, +.elfinder .elfinder-toast > .toast-success, +.elfinder .elfinder-toast > .toast-warning { + background-image: inherit !important; +} +.elfinder .elfinder-toast > .toast-info:before { + content: '\e817'; + color: #3498db; +} +.elfinder .elfinder-toast > .toast-error:before { + content: '\e855'; + color: #f44336; +} +.elfinder .elfinder-toast > .toast-success:before { + content: '\e84e'; + color: #4caf50; +} +.elfinder .elfinder-toast > .toast-warning:before { + content: '\e856'; + color: #ff9800; +} +.elfinder-drag-helper-icon-status { + font: normal normal normal 14px/1 material; + background: inherit; +} +.elfinder-drag-helper-icon-status:before { + content: '\e824'; +} +.elfinder-drag-helper-move .elfinder-drag-helper-icon-status { + -webkit-transform: rotate(180deg); + -ms-transform: rotate(180deg); + -moz-transform: rotate(180deg); + -o-transform: rotate(180deg); + transform: rotate(180deg); +} +.elfinder-drag-helper-move .elfinder-drag-helper-icon-status:before { + content: '\e854'; +} +.elfinder-drag-helper-plus .elfinder-drag-helper-icon-status { + -webkit-transform: rotate(90deg); + -ms-transform: rotate(90deg); + -moz-transform: rotate(90deg); + -o-transform: rotate(90deg); + transform: rotate(90deg); +} +.elfinder-drag-helper-plus .elfinder-drag-helper-icon-status:before { + content: '\e84c'; +} +/** + * MIME Types + */ +.elfinder-cwd-view-list td .elfinder-cwd-icon { + background-image: url("../images/icons-small.png"); +} +.elfinder-cwd-icon { + background: url("../images/icons-big.png") 0 0 no-repeat; +} +.elfinder-cwd-icon:before { + font-size: 10px; + position: relative; + top: 27px; + left: inherit; + padding: 1px; + background-color: transparent; +} +.elfinder-info-title .elfinder-cwd-icon:before { + top: 32px; + display: block; + margin: 0 auto; +} +.elfinder-info-title .elfinder-cwd-icon.elfinder-cwd-bgurl:before { + background-color: #313131 !important; +} +.elfinder-cwd-view-icons .elfinder-cwd-icon.elfinder-cwd-bgurl:before { + left: inherit; + background-color: #313131; +} +.elfinder-quicklook .elfinder-cwd-icon:before { + top: 33px; + left: 50% !important; + position: relative; + display: block; + -webkit-transform: translateX(-50%); + -ms-transform: translateX(-50%); + -moz-transform: translateX(-50%); + -o-transform: translateX(-50%); + transform: translateX(-50%); +} +.elfinder-cwd-icon-zip:before, +.elfinder-cwd-icon-x-zip:before { + content: 'zip' !important; +} +.elfinder-cwd-icon-x-xz:before { + content: 'xz' !important; +} +.elfinder-cwd-icon-x-7z-compressed:before { + content: '7z' !important; +} +.elfinder-cwd-icon-x-gzip:before { + content: 'gzip' !important; +} +.elfinder-cwd-icon-x-tar:before { + content: 'tar' !important; +} +.elfinder-cwd-icon-x-bzip:before, +.elfinder-cwd-icon-x-bzip2:before { + content: 'bzip' !important; +} +.elfinder-cwd-icon-x-rar:before, +.elfinder-cwd-icon-x-rar-compressed:before { + content: 'rar' !important; +} +.elfinder-cwd-icon-directory { + background-position: 0 -50px; +} +.elfinder-cwd-icon-application { + background-position: 0 -150px; +} +.elfinder-cwd-icon-text { + background-position: 0 -200px; +} +.elfinder-cwd-icon-plain, +.elfinder-cwd-icon-x-empty { + background-position: 0 -250px; +} +.elfinder-cwd-icon-image { + background-position: 0 -300px; +} +.elfinder-cwd-icon-vnd-adobe-photoshop { + background-position: 0 -350px; +} +.elfinder-cwd-icon-vnd-adobe-photoshop:before { + content: none !important; +} +.elfinder-cwd-icon-postscript { + background-position: 0 -400px; +} +.elfinder-cwd-icon-audio { + background-position: 0 -450px; +} +.elfinder-cwd-icon-video, +.elfinder-cwd-icon-flash-video, +.elfinder-cwd-icon-dash-xml, +.elfinder-cwd-icon-vnd-apple-mpegurl, +.elfinder-cwd-icon-x-mpegurl { + background-position: 0 -500px; +} +.elfinder-cwd-icon-rtf, +.elfinder-cwd-icon-rtfd { + background-position: 0 -550px; +} +.elfinder-cwd-icon-pdf { + background-position: 0 -600px; +} +.elfinder-cwd-icon-x-msaccess { + background-position: 0 -650px; +} +.elfinder-cwd-icon-x-msaccess:before { + content: none !important; +} +.elfinder-cwd-icon-msword, +.elfinder-cwd-icon-vnd-ms-word, +.elfinder-cwd-icon-vnd-ms-word-document-macroEnabled-12, +.elfinder-cwd-icon-vnd-ms-word-template-macroEnabled-12 { + background-position: 0 -700px; +} +.elfinder-cwd-icon-msword:before, +.elfinder-cwd-icon-vnd-ms-word:before, +.elfinder-cwd-icon-vnd-ms-word-document-macroEnabled-12:before, +.elfinder-cwd-icon-vnd-ms-word-template-macroEnabled-12:before { + content: none !important; +} +.elfinder-cwd-icon-ms-excel, +.elfinder-cwd-icon-vnd-ms-excel, +.elfinder-cwd-icon-vnd-ms-excel-addin-macroEnabled-12, +.elfinder-cwd-icon-vnd-ms-excel-sheet-binary-macroEnabled-12, +.elfinder-cwd-icon-vnd-ms-excel-sheet-macroEnabled-12, +.elfinder-cwd-icon-vnd-ms-excel-template-macroEnabled-12 { + background-position: 0 -750px; +} +.elfinder-cwd-icon-ms-excel:before, +.elfinder-cwd-icon-vnd-ms-excel:before, +.elfinder-cwd-icon-vnd-ms-excel-addin-macroEnabled-12:before, +.elfinder-cwd-icon-vnd-ms-excel-sheet-binary-macroEnabled-12:before, +.elfinder-cwd-icon-vnd-ms-excel-sheet-macroEnabled-12:before, +.elfinder-cwd-icon-vnd-ms-excel-template-macroEnabled-12:before { + content: none !important; +} +.elfinder-cwd-icon-vnd-ms-powerpoint, +.elfinder-cwd-icon-vnd-ms-powerpoint-addin-macroEnabled-12, +.elfinder-cwd-icon-vnd-ms-powerpoint-presentation-macroEnabled-12, +.elfinder-cwd-icon-vnd-ms-powerpoint-slide-macroEnabled-12, +.elfinder-cwd-icon-vnd-ms-powerpoint-slideshow-macroEnabled-12, +.elfinder-cwd-icon-vnd-ms-powerpoint-template-macroEnabled-12 { + background-position: 0 -800px; +} +.elfinder-cwd-icon-vnd-ms-powerpoint:before, +.elfinder-cwd-icon-vnd-ms-powerpoint-addin-macroEnabled-12:before, +.elfinder-cwd-icon-vnd-ms-powerpoint-presentation-macroEnabled-12:before, +.elfinder-cwd-icon-vnd-ms-powerpoint-slide-macroEnabled-12:before, +.elfinder-cwd-icon-vnd-ms-powerpoint-slideshow-macroEnabled-12:before, +.elfinder-cwd-icon-vnd-ms-powerpoint-template-macroEnabled-12:before { + content: none !important; +} +.elfinder-cwd-icon-vnd-ms-office, +.elfinder-cwd-icon-vnd-oasis-opendocument-chart, +.elfinder-cwd-icon-vnd-oasis-opendocument-database, +.elfinder-cwd-icon-vnd-oasis-opendocument-formula, +.elfinder-cwd-icon-vnd-oasis-opendocument-graphics, +.elfinder-cwd-icon-vnd-oasis-opendocument-graphics-template, +.elfinder-cwd-icon-vnd-oasis-opendocument-image, +.elfinder-cwd-icon-vnd-oasis-opendocument-presentation, +.elfinder-cwd-icon-vnd-oasis-opendocument-presentation-template, +.elfinder-cwd-icon-vnd-oasis-opendocument-spreadsheet, +.elfinder-cwd-icon-vnd-oasis-opendocument-spreadsheet-template, +.elfinder-cwd-icon-vnd-oasis-opendocument-text, +.elfinder-cwd-icon-vnd-oasis-opendocument-text-master, +.elfinder-cwd-icon-vnd-oasis-opendocument-text-template, +.elfinder-cwd-icon-vnd-oasis-opendocument-text-web, +.elfinder-cwd-icon-vnd-openofficeorg-extension, +.elfinder-cwd-icon-vnd-openxmlformats-officedocument-presentationml-presentation, +.elfinder-cwd-icon-vnd-openxmlformats-officedocument-presentationml-slide, +.elfinder-cwd-icon-vnd-openxmlformats-officedocument-presentationml-slideshow, +.elfinder-cwd-icon-vnd-openxmlformats-officedocument-presentationml-template, +.elfinder-cwd-icon-vnd-openxmlformats-officedocument-spreadsheetml-sheet, +.elfinder-cwd-icon-vnd-openxmlformats-officedocument-spreadsheetml-template, +.elfinder-cwd-icon-vnd-openxmlformats-officedocument-wordprocessingml-document, +.elfinder-cwd-icon-vnd-openxmlformats-officedocument-wordprocessingml-template { + background-position: 0 -850px; +} +.elfinder-cwd-icon-html { + background-position: 0 -900px; +} +.elfinder-cwd-icon-css { + background-position: 0 -950px; +} +.elfinder-cwd-icon-javascript, +.elfinder-cwd-icon-x-javascript { + background-position: 0 -1000px; +} +.elfinder-cwd-icon-x-perl { + background-position: 0 -1050px; +} +.elfinder-cwd-icon-x-python:after, +.elfinder-cwd-icon-x-python { + background-position: 0 -1100px; +} +.elfinder-cwd-icon-x-ruby { + background-position: 0 -1150px; +} +.elfinder-cwd-icon-x-sh, +.elfinder-cwd-icon-x-shellscript { + background-position: 0 -1200px; +} +.elfinder-cwd-icon-x-c, +.elfinder-cwd-icon-x-csrc, +.elfinder-cwd-icon-x-chdr, +.elfinder-cwd-icon-x-c--, +.elfinder-cwd-icon-x-c--src, +.elfinder-cwd-icon-x-c--hdr { + background-position: 0 -1250px; +} +.elfinder-cwd-icon-x-jar, +.elfinder-cwd-icon-x-java, +.elfinder-cwd-icon-x-java-source { + background-position: 0 -1300px; +} +.elfinder-cwd-icon-x-jar:before, +.elfinder-cwd-icon-x-java:before, +.elfinder-cwd-icon-x-java-source:before { + content: none !important; +} +.elfinder-cwd-icon-x-php { + background-position: 0 -1350px; +} +.elfinder-cwd-icon-xml:after, +.elfinder-cwd-icon-xml { + background-position: 0 -1400px; +} +.elfinder-cwd-icon-zip, +.elfinder-cwd-icon-x-zip, +.elfinder-cwd-icon-x-xz, +.elfinder-cwd-icon-x-7z-compressed, +.elfinder-cwd-icon-x-gzip, +.elfinder-cwd-icon-x-tar, +.elfinder-cwd-icon-x-bzip, +.elfinder-cwd-icon-x-bzip2, +.elfinder-cwd-icon-x-rar, +.elfinder-cwd-icon-x-rar-compressed { + background-position: 0 -1450px; +} +.elfinder-cwd-icon-x-shockwave-flash { + background-position: 0 -1500px; +} +.elfinder-cwd-icon-group { + background-position: 0 -1550px; +} +.elfinder-cwd-icon-json { + background-position: 0 -1600px; +} +.elfinder-cwd-icon-json:before { + content: none !important; +} +.elfinder-cwd-icon-markdown, +.elfinder-cwd-icon-x-markdown { + background-position: 0 -1650px; +} +.elfinder-cwd-icon-markdown:before, +.elfinder-cwd-icon-x-markdown:before { + content: none !important; +} +.elfinder-cwd-icon-sql { + background-position: 0 -1700px; +} +.elfinder-cwd-icon-sql:before { + content: none !important; +} +.elfinder-cwd-icon-svg, +.elfinder-cwd-icon-svg-xml { + background-position: 0 -1750px; +} +.elfinder-cwd-icon-svg:before, +.elfinder-cwd-icon-svg-xml:before { + content: none !important; +} +/** + * Toolbar + */ +.elfinder-toolbar { + background: #3b4047; + -webkit-border-radius: 0; + border-radius: 0; + border: 0; + padding: 5px 0; +} +.elfinder-buttonset { + -webkit-border-radius: 0; + border-radius: 0; + border: 0; + margin: 0 5px; + height: 24px; +} +.elfinder .elfinder-button { + background: transparent; + -webkit-border-radius: 0; + border-radius: 0; + cursor: pointer; + color: #efefef; +} +.elfinder-toolbar-button-separator { + border: 0; +} +.elfinder-button-menu { + -webkit-border-radius: 2px; + border-radius: 2px; + -webkit-box-shadow: 0 1px 6px rgba(0, 0, 0, 0.3); + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.3); + border: none; + margin-top: 5px; +} +.elfinder-button-menu-item { + color: #666; + padding: 6px 19px; +} +.elfinder-button-menu-item.ui-state-hover { + color: #141414; + background-color: #f5f4f4; +} +.elfinder-button-menu-item-separated { + border-top: 1px solid #e5e5e5; +} +.elfinder-button-menu-item-separated.ui-state-hover { + border-top: 1px solid #e5e5e5; +} +.elfinder .elfinder-button-search { + margin: 0 10px; + min-height: inherit; + overflow: hidden; +} +.elfinder .elfinder-button-search input { + background: rgba(40, 42, 45, 0.79); + -webkit-border-radius: 2px; + border-radius: 2px; + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; + border: 0; + margin: 0; + padding: 0 23px; + height: 24px; + color: #fff; +} +.elfinder .elfinder-button-search .elfinder-button-menu { + margin-top: 4px; + border: none; + -webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); +} +/** + * Navbar + */ +.elfinder .elfinder-navbar { + background: #535e64; + -webkit-box-shadow: 0 1px 8px rgba(0, 0, 0, 0.6); + box-shadow: 0 1px 8px rgba(0, 0, 0, 0.6); + border: none; +} +.elfinder-navbar-dir { + color: #e6e6e6; + cursor: pointer; + -webkit-border-radius: 2px; + border-radius: 2px; + padding: 5px; + border: none; +} +.elfinder-navbar-dir.ui-state-hover, +.elfinder-navbar-dir.ui-state-active.ui-state-hover { + background: #3c4448; + color: #e6e6e6; + border: none; +} +.elfinder-navbar .ui-state-active, +.elfinder-disabled .elfinder-navbar .ui-state-active { + background: #41494e; + border: none; +} +/** + * Workzone + */ +.elfinder-workzone { + background: #cdcfd4; +} +.elfinder-cwd-file { + color: #555; +} +.elfinder-cwd-file.ui-state-hover, +.elfinder-cwd-file.ui-selected.ui-state-hover { + background: #4c5961; + color: #ddd; +} +.elfinder-cwd-file.ui-selected { + background: #455158; + /*color: #555;*/ + color: #ddd; + width: 120px !important; +} +.elfinder-cwd-filename input, +.elfinder-cwd-filename textarea { + padding: 2px; + -webkit-border-radius: 2px !important; + border-radius: 2px !important; + width: 100px !important; + background: #fff; + color: #222; +} +.elfinder-cwd-filename input:focus, +.elfinder-cwd-filename textarea:focus { + outline: none; + border: 1px solid #555; +} +.elfinder-cwd-view-icons .elfinder-cwd-file .ui-state-hover, +.elfinder-cwd-view-icons .elfinder-cwd-file .elfinder-cwd-filename.ui-state-hover, +.elfinder-disabled .elfinder-cwd-view-icons .elfinder-cwd-file .elfinder-cwd-filename.ui-state-hover, +.elfinder-disabled .elfinder-cwd table td.ui-state-hover, +.elfinder-cwd-view-icons .elfinder-cwd-file .ui-state-active { + background: transparent; + color: #ddd; +} +.elfinder-cwd table { + padding: 0; +} +.elfinder-cwd table tr:nth-child(odd) { + /*background-color: transparent;*/ +} +.elfinder-cwd table tr:nth-child(odd).ui-state-hover { + /*background-color: #4c5961;*/ +} +#elfinder-elfinder-cwd-thead td { + background: #353b42; + color: #ddd; +} +#elfinder-elfinder-cwd-thead td.ui-state-hover, +#elfinder-elfinder-cwd-thead td.ui-state-active { + background: #30363c; +} +#elfinder-elfinder-cwd-thead td.ui-state-active.ui-state-hover { + background: #2e333a; +} +.ui-selectable-helper { + border: 1px solid #3b4047; + background-color: rgba(104, 111, 121, 0.5); +} +.elfinder-cwd-wrapper.elfinder-cwd-wrapper-trash { + background-color: #e4e4e4; +} +.elfinder-cwd-wrapper.elfinder-cwd-wrapper-trash .elfinder-cwd-file { + color: #333; +} +.elfinder-cwd-wrapper.elfinder-cwd-wrapper-trash .elfinder-cwd-file.ui-state-hover, +.elfinder-cwd-wrapper.elfinder-cwd-wrapper-trash .elfinder-cwd-file.ui-selected.ui-state-hover { + background: #4c5961; + color: #ddd; +} +.elfinder-cwd-wrapper.elfinder-cwd-wrapper-trash .elfinder-cwd-file.ui-selected { + background: #455158; + color: #555; +} +/** + * Status Bar + */ +.elfinder .elfinder-statusbar { + background: #3b4047; + -webkit-border-radius: 0; + border-radius: 0; + border: 0; + color: #cfd2d4; +} +.elfinder-path, +.elfinder-stat-size { + margin: 0 15px; +} +/** + * Buttons + */ +.ui-button, +.ui-button:active, +.ui-button.ui-state-default { + display: inline-block; + font-weight: normal; + text-align: center; + vertical-align: middle; + cursor: pointer; + white-space: nowrap; + -webkit-border-radius: 3px; + border-radius: 3px; + /*text-transform: uppercase;*/ + -webkit-box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.4); + box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.4); + -webkit-transition: all 0.4s; + -o-transition: all 0.4s; + -moz-transition: all 0.4s; + transition: all 0.4s; + background: #fff; + color: #222; + border: none; +} +.ui-button .ui-icon, +.ui-button:active .ui-icon, +.ui-button.ui-state-default .ui-icon { + color: #222; +} +.ui-button:hover, +a.ui-button:active, +.ui-button:active, +.ui-button:focus, +.ui-button.ui-state-hover, +.ui-button.ui-state-active { + background: #3498db; + color: #fff; + border: none; +} +.ui-button:hover .ui-icon, +a.ui-button:active .ui-icon, +.ui-button:active .ui-icon, +.ui-button:focus .ui-icon, +.ui-button.ui-state-hover .ui-icon, +.ui-button.ui-state-active .ui-icon { + color: #fff; +} +.ui-button.ui-state-active:hover { + background: #217dbb; + color: #fff; + border: none; +} +.ui-button:focus { + outline: none !important; +} +.ui-controlgroup-horizontal .ui-button { + -webkit-border-radius: 0; + border-radius: 0; + border: 0; +} +/** + * Context Menu + */ +.elfinder .elfinder-contextmenu, +.elfinder .elfinder-contextmenu-sub { + -webkit-border-radius: 2px; + border-radius: 2px; + -webkit-box-shadow: 0 1px 6px rgba(0, 0, 0, 0.3); + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.3); + border: none; +} +.elfinder .elfinder-contextmenu-separator, +.elfinder .elfinder-contextmenu-sub-separator { + border-top: 1px solid #e5e5e5; +} +.elfinder .elfinder-contextmenu-item { + color: #666; + padding: 5px 30px; +} +.elfinder .elfinder-contextmenu-item.ui-state-hover { + background-color: #f5f4f4; + color: #141414; +} +.elfinder .elfinder-contextmenu-item.ui-state-active { + background-color: #2196f3; + color: #fff; +} +/** + * Dialogs + */ +.elfinder .elfinder-dialog { + -webkit-border-radius: 0; + border-radius: 0; + border: 0; + -webkit-box-shadow: 0 1px 30px rgba(0, 0, 0, 0.6); + box-shadow: 0 1px 30px rgba(0, 0, 0, 0.6); +} +.elfinder .elfinder-dialog .ui-dialog-content[id*="edit-elfinder-elfinder-"] { + padding: 0; +} +.elfinder .elfinder-dialog .ui-tabs { + -webkit-border-radius: 0; + border-radius: 0; + border: 0; +} +.elfinder .elfinder-dialog .ui-tabs-nav { + -webkit-border-radius: 0; + border-radius: 0; + border: 0; + background: transparent; + border-bottom: 1px solid #ddd; +} +.elfinder .elfinder-dialog .ui-tabs-nav li { + border: 0; + font-weight: normal; + background: transparent; + margin: 0; + padding: 3px 0; +} +.elfinder .elfinder-dialog .ui-tabs-nav li.ui-tabs-active { + padding-bottom: 7px; +} +.elfinder .elfinder-dialog .ui-tabs-nav .ui-tabs-selected a, +.elfinder .elfinder-dialog .ui-tabs-nav .ui-state-active a, +.elfinder .elfinder-dialog .ui-tabs-nav li:hover a { + -webkit-box-shadow: inset 0 -2px 0 #3498db; + box-shadow: inset 0 -2px 0 #3498db; + color: #3498db; +} +.elfinder .elfinder-dialog .ui-tabs .elfinder-tabstop.ui-state-hover { + background: transparent; + -webkit-box-shadow: inset 0 -2px 0 #3498db; + box-shadow: inset 0 -2px 0 #3498db; + color: #3498db; +} +.elfinder .elfinder-dialog label.ui-state-hover { + background: transparent; +} +.std42-dialog .ui-dialog-titlebar { + background: #353b44; + -webkit-border-radius: 0; + border-radius: 0; + border: 0; +} +.std42-dialog .ui-dialog-titlebar .elfinder-titlebar-button .ui-icon { + border-color: inherit; + -webkit-transition: 0.2s ease-out; + -o-transition: 0.2s ease-out; + -moz-transition: 0.2s ease-out; + transition: 0.2s ease-out; + opacity: 0.8; + color: #fff; + width: auto; + height: auto; + font-size: 12px; + padding: 3px; +} +.elfinder-mobile .std42-dialog .ui-dialog-titlebar .ui-dialog-titlebar-close .ui-icon, +.std42-dialog .ui-dialog-titlebar .ui-dialog-titlebar-close:hover .ui-icon { + background-color: #f44336; +} +.elfinder-mobile .std42-dialog .ui-dialog-titlebar .elfinder-titlebar-full .ui-icon, +.std42-dialog .ui-dialog-titlebar .elfinder-titlebar-full:hover .ui-icon { + background-color: #4caf50; +} +.elfinder-mobile .std42-dialog .ui-dialog-titlebar .elfinder-titlebar-minimize .ui-icon, +.std42-dialog .ui-dialog-titlebar .elfinder-titlebar-minimize:hover .ui-icon { + background-color: #ff9800; +} +.elfinder-dialog-title { + color: #f1f1f1; + +} + +.ui-dialog-buttonpane { + background: #fff; +} + +.std42-dialog .ui-dialog-content { + background: #fff; +} +.ui-widget-content { + font-family: "Open Sans", sans-serif; + color: #546e7a; +} +.std42-dialog .ui-dialog-buttonpane button { + margin: 2px; + padding: .4em .5em; +} +.std42-dialog .ui-dialog-buttonpane button span.ui-icon { + padding: 0; +} +.elfinder-upload-dialog-wrapper .elfinder-upload-dirselect { + width: inherit; + height: inherit; + padding: .4em; + margin-left: 5px; + color: #222; +} +.elfinder-upload-dialog-wrapper .elfinder-upload-dirselect.ui-state-hover { + background: #888; + color: #fff; + outline: none; + -webkit-border-radius: 2px; + border-radius: 2px; +} +.elfinder-upload-dialog-wrapper .ui-button { + padding: .4em 3px; + margin: 0 2px; +} +.elfinder-upload-dialog-wrapper .ui-button { + margin-left: 19px; + margin-right: -15px; +} +.elfinder-upload-dropbox { + border: 2px dashed #bbb; +} +.elfinder-upload-dropbox:focus { + outline: none; +} +.elfinder-upload-dropbox.ui-state-hover { + background: #f1f1f1; + border: 2px dashed #bbb; +} +.elfinder-help *, +.elfinder-help a { + color: #546e7a; +} diff --git a/static/plugins/elfinder/elfinder.full.js b/static/plugins/elfinder/elfinder.full.js index 53b61918d..1b1aac849 100755 --- a/static/plugins/elfinder/elfinder.full.js +++ b/static/plugins/elfinder/elfinder.full.js @@ -13149,7 +13149,7 @@ if (typeof elFinder === 'function' && elFinder.prototype.i18) { 'cmdrm' : 'Delete', 'cmdtrash' : 'Into trash', //from v2.1.24 added 29.4.2017 'cmdrestore' : 'Restore', //from v2.1.24 added 3.5.2017 - 'cmdsearch' : 'Find files', + 'cmdsearch' : 'Find assets', 'cmdup' : 'Go to parent folder', 'cmdupload' : 'Upload files', 'cmdview' : 'View', diff --git a/static/plugins/elfinder/fonts/OpenSans-Bold.ttf b/static/plugins/elfinder/fonts/OpenSans-Bold.ttf new file mode 100644 index 000000000..98c74e0a4 Binary files /dev/null and b/static/plugins/elfinder/fonts/OpenSans-Bold.ttf differ diff --git a/static/plugins/elfinder/fonts/OpenSans-BoldItalic.ttf b/static/plugins/elfinder/fonts/OpenSans-BoldItalic.ttf new file mode 100644 index 000000000..855892833 Binary files /dev/null and b/static/plugins/elfinder/fonts/OpenSans-BoldItalic.ttf differ diff --git a/static/plugins/elfinder/fonts/OpenSans-Italic.ttf b/static/plugins/elfinder/fonts/OpenSans-Italic.ttf new file mode 100644 index 000000000..29ff69386 Binary files /dev/null and b/static/plugins/elfinder/fonts/OpenSans-Italic.ttf differ diff --git a/static/plugins/elfinder/fonts/OpenSans-Light.ttf b/static/plugins/elfinder/fonts/OpenSans-Light.ttf new file mode 100644 index 000000000..ea175cc30 Binary files /dev/null and b/static/plugins/elfinder/fonts/OpenSans-Light.ttf differ diff --git a/static/plugins/elfinder/fonts/OpenSans-Regular.ttf b/static/plugins/elfinder/fonts/OpenSans-Regular.ttf new file mode 100644 index 000000000..67803bb64 Binary files /dev/null and b/static/plugins/elfinder/fonts/OpenSans-Regular.ttf differ diff --git a/static/plugins/elfinder/i18n/elfinder.zh_CN.js b/static/plugins/elfinder/i18n/elfinder.zh_CN.js index bb6f6bc9e..f8fced351 100755 --- a/static/plugins/elfinder/i18n/elfinder.zh_CN.js +++ b/static/plugins/elfinder/i18n/elfinder.zh_CN.js @@ -146,7 +146,7 @@ 'cmdrm' : '删除', 'cmdtrash' : '至回收站', //from v2.1.24 added 29.4.2017 'cmdrestore' : '恢复', //from v2.1.24 added 3.5.2017 - 'cmdsearch' : '查找文件', + 'cmdsearch' : '查找资产', 'cmdup' : '转到上一级文件夹', 'cmdupload' : '上传文件', 'cmdview' : '查看', @@ -211,7 +211,7 @@ 'ntfsave' : '保存文件', 'ntfarchive' : '创建压缩包', 'ntfextract' : '从压缩包提取文件', - 'ntfsearch' : '搜索文件', + 'ntfsearch' : '搜索资产', 'ntfresize' : '正在更改尺寸', 'ntfsmth' : '正在忙 >_<', 'ntfloadimg' : '正在加载图片', @@ -307,6 +307,7 @@ 'confirmNonUTF8' : '无法检测到此文件的字符编码.需要暂时转换此文件为UTF-8编码以进行编辑.
请选择此文件的字符编码.', // from v2.1.19 added 28.11.2016 'confirmNotSave' : '文件已被编辑.
如果不保存直接关闭,将丢失编辑内容.', // from v2.1 added 15.7.2015 'confirmTrash' : '确定要将该项移动到回收站么?', //from v2.1.24 added 29.4.2017 + 'confirmMove' : '确定要将该项移动到 "$1"?', //from v2.1.50 added 27.7.2019 'apllyAll' : '全部应用', 'name' : '名称', 'size' : '大小', diff --git a/static/plugins/elfinder/sounds/rm.wav b/static/plugins/elfinder/sounds/rm.wav new file mode 100644 index 000000000..0e1a21a7b Binary files /dev/null and b/static/plugins/elfinder/sounds/rm.wav differ diff --git a/templates/elfinder/file_manager.html b/templates/elfinder/file_manager.html index 76fa29b28..827680ce5 100644 --- a/templates/elfinder/file_manager.html +++ b/templates/elfinder/file_manager.html @@ -2,15 +2,20 @@ + + + + - - - + + diff --git a/ui/package.json b/ui/package.json index c36f7222b..211bd8883 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,54 +1,68 @@ { - "name": "koko", - "version": "0.1.0", + "name": "koko-terminal", + "type": "module", + "version": "0.0.0", "private": true, "scripts": { - "serve": "vue-cli-service serve", - "build": "vue-cli-service build", - "lint": "vue-cli-service lint", - "fix": "vue-cli-service lint --fix" + "lint": "eslint", + "preview": "vite preview", + "lint:fix": "eslint --fix", + "check": "vue-tsc --noEmit", + "dev": "vue-tsc -b && vite", + "serve": "vue-tsc -b && vite", + "build": "vue-tsc -b && vite build", + "prettier": "prettier --write \"src/**/*.{js,ts,json,tsx,css,less,scss,vue,html,md}\"" }, "dependencies": { - "core-js": "^3.6.5", - "element-ui": "^2.15.5", + "@vueuse/core": "^10.11.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-webgl": "^0.18.0", + "@xterm/xterm": "^5.5.0", + "alova": "^3.2.10", + "clipboard-polyfill": "^4.1.0", + "dayjs": "^1.11.13", + "lucide-vue-next": "^0.525.0", + "mitt": "^3.0.1", + "naive-ui": "^2.42.0", "nora-zmodemjs": "^1.1.1", - "v-contextmenu": "^2.9.0", - "vue": "^2.6.11", - "vue-cookies": "^1.7.4", - "vue-i18n": "^8.25.0", - "vue-router": "^3.5.2", - "vue-runtime-helpers": "^1.1.2", - "vuejs-logger": "^1.5.5", - "xterm": "^4.13.0", - "xterm-addon-fit": "^0.5.0", - "xterm-theme": "^1.1.0" + "pinia": "^3.0.2", + "pretty-bytes": "^7.0.0", + "sortablejs": "^1.15.3", + "uuid": "^10.0.0", + "vue": "^3.5.16", + "vue-draggable-plus": "^0.5.2", + "vue-i18n": "^11.1.5", + "vue-router": "^4.4.0", + "vue3-cookies": "^1.0.6", + "xterm-theme": "^1.1.0", + "zmodem-ts": "^1.0.5" }, "devDependencies": { - "@vue/cli-plugin-babel": "~4.5.0", - "@vue/cli-plugin-eslint": "~4.5.0", - "@vue/cli-service": "~4.5.0", - "babel-eslint": "^10.1.0", - "eslint": "^6.7.2", - "eslint-plugin-vue": "^6.2.2", - "vue-template-compiler": "^2.6.11" - }, - "eslintConfig": { - "root": true, - "env": { - "node": true - }, - "extends": [ - "plugin:vue/essential", - "eslint:recommended" - ], - "parserOptions": { - "parser": "babel-eslint" - }, - "rules": {} - }, - "browserslist": [ - "> 1%", - "last 2 versions", - "not dead" - ] + "@antfu/eslint-config": "^4.16.1", + "@eslint/js": "^9.23.0", + "@tailwindcss/vite": "^4.0.17", + "@types/node": "^20.14.11", + "@types/sortablejs": "^1.15.0", + "@types/uuid": "^10.0.0", + "@vicons/carbon": "^0.12.0", + "@vicons/tabler": "^0.12.0", + "@vitejs/plugin-vue": "^5.0.5", + "@vitejs/plugin-vue-jsx": "^4.1.2", + "eslint": "^9.28.0", + "eslint-plugin-spellcheck": "^0.0.20", + "eslint-plugin-vue": "^10.0.0", + "globals": "^16.0.0", + "prettier": "^3.3.3", + "sass": "^1.77.8", + "tailwindcss": "^4.0.17", + "typescript": "^5.2.2", + "typescript-eslint": "^8.29.0", + "unplugin-vue-components": "^0.27.3", + "vite": "^6.3.5", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-webpackchunkname": "^1.0.3", + "vue-eslint-parser": "^10.1.3", + "vue-tsc": "^2.0.24" + } } diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico deleted file mode 100644 index 0bd425270..000000000 Binary files a/ui/public/favicon.ico and /dev/null differ diff --git a/ui/public/index.html b/ui/public/index.html deleted file mode 100644 index 3e5a13962..000000000 --- a/ui/public/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - <%= htmlWebpackPlugin.options.title %> - - - -
- - - diff --git a/ui/src/App.vue b/ui/src/App.vue index c13f440fc..f65dac3fc 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -1,15 +1,94 @@ - + - \ No newline at end of file + diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts new file mode 100644 index 000000000..3523a3a2e --- /dev/null +++ b/ui/src/api/index.ts @@ -0,0 +1,6 @@ +import { createAlova } from 'alova'; +import fetchAdapter from 'alova/fetch'; + +export const alovaInstance = createAlova({ + requestAdapter: fetchAdapter(), +}); diff --git a/ui/src/components/CardContainer/index.vue b/ui/src/components/CardContainer/index.vue new file mode 100644 index 000000000..dc28357dd --- /dev/null +++ b/ui/src/components/CardContainer/index.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/ui/src/components/Drawer/components/FileManagement/index.vue b/ui/src/components/Drawer/components/FileManagement/index.vue new file mode 100644 index 000000000..9e99b78d2 --- /dev/null +++ b/ui/src/components/Drawer/components/FileManagement/index.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/ui/src/components/Drawer/components/FileManagement/widget/index.vue b/ui/src/components/Drawer/components/FileManagement/widget/index.vue new file mode 100644 index 000000000..09bf85a99 --- /dev/null +++ b/ui/src/components/Drawer/components/FileManagement/widget/index.vue @@ -0,0 +1,810 @@ + + + + + diff --git a/ui/src/components/Drawer/components/General/index.vue b/ui/src/components/Drawer/components/General/index.vue new file mode 100644 index 000000000..9c34f8325 --- /dev/null +++ b/ui/src/components/Drawer/components/General/index.vue @@ -0,0 +1,149 @@ + + + diff --git a/ui/src/components/Drawer/components/SessionShare/index.vue b/ui/src/components/Drawer/components/SessionShare/index.vue new file mode 100644 index 000000000..ad17bcd7a --- /dev/null +++ b/ui/src/components/Drawer/components/SessionShare/index.vue @@ -0,0 +1,53 @@ + + + diff --git a/ui/src/components/Drawer/components/SessionShare/widget/CreateLink.vue b/ui/src/components/Drawer/components/SessionShare/widget/CreateLink.vue new file mode 100644 index 000000000..7fd13a7a8 --- /dev/null +++ b/ui/src/components/Drawer/components/SessionShare/widget/CreateLink.vue @@ -0,0 +1,349 @@ + + + diff --git a/ui/src/components/Drawer/components/SessionShare/widget/UserItem.vue b/ui/src/components/Drawer/components/SessionShare/widget/UserItem.vue new file mode 100644 index 000000000..9a956f246 --- /dev/null +++ b/ui/src/components/Drawer/components/SessionShare/widget/UserItem.vue @@ -0,0 +1,110 @@ + + + diff --git a/ui/src/components/Drawer/components/Setting/index.vue b/ui/src/components/Drawer/components/Setting/index.vue new file mode 100644 index 000000000..527bbc198 --- /dev/null +++ b/ui/src/components/Drawer/components/Setting/index.vue @@ -0,0 +1,327 @@ + + + + + diff --git a/ui/src/components/Drawer/index.vue b/ui/src/components/Drawer/index.vue new file mode 100644 index 000000000..2cb22ab44 --- /dev/null +++ b/ui/src/components/Drawer/index.vue @@ -0,0 +1,246 @@ + + + diff --git a/ui/src/components/Kubernetes/ContentHeader/index.vue b/ui/src/components/Kubernetes/ContentHeader/index.vue new file mode 100644 index 000000000..d4fcdaec5 --- /dev/null +++ b/ui/src/components/Kubernetes/ContentHeader/index.vue @@ -0,0 +1,40 @@ + + + diff --git a/ui/src/components/Kubernetes/MainContent/index.scss b/ui/src/components/Kubernetes/MainContent/index.scss new file mode 100644 index 000000000..485132edb --- /dev/null +++ b/ui/src/components/Kubernetes/MainContent/index.scss @@ -0,0 +1,66 @@ +.header-tab { + width: 100% !important; + background-color: var(--tab-bg-color) !important; + + :deep(.n-tabs-nav) { + height: 45px; + background-color: var(--tab-bg-color) !important; + + .n-tabs-tab-wrapper { + height: 45px; + } + } + + :deep(.n-tabs-tab-pad) { + display: none; + } + + :deep(.n-tabs-tab) { + background-color: var(--tab-inactive-bg-color) !important; + border: none !important; + + .n-tabs-tab__label { + color: var(--tab-inactive-text-color) !important; + } + + &.n-tabs-tab--active { + background-color: var(--tab-active-bg-color) !important; + + .n-tabs-tab__label { + color: var(--tab-active-text-color) !important; + } + } + } + + .n-tab-pane { + padding-top: 0 !important; + } + + :deep(.icon-item) { + svg { + color: var(--icon-color); + fill: var(--icon-color); + } + + &:hover { + background-color: var(--icon-hover-bg-color); + } + } +} + +.k8s-terminal { + overflow: hidden; + + :deep(.xterm-viewport) { + overflow: hidden; + } + + :deep(.xterm-screen) { + width: 100% !important; + height: calc(100vh - 65px) !important; + } + + :deep(.xterm) { + padding: 10px 0 10px 10px; + } +} diff --git a/ui/src/components/Kubernetes/MainContent/index.vue b/ui/src/components/Kubernetes/MainContent/index.vue new file mode 100644 index 000000000..0cf818d55 --- /dev/null +++ b/ui/src/components/Kubernetes/MainContent/index.vue @@ -0,0 +1,577 @@ + + + + + diff --git a/ui/src/components/Kubernetes/Sidebar/index.vue b/ui/src/components/Kubernetes/Sidebar/index.vue new file mode 100644 index 000000000..9ebbbd446 --- /dev/null +++ b/ui/src/components/Kubernetes/Sidebar/index.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/ui/src/components/Kubernetes/Tree/index.scss b/ui/src/components/Kubernetes/Tree/index.scss new file mode 100644 index 000000000..d0da72adf --- /dev/null +++ b/ui/src/components/Kubernetes/Tree/index.scss @@ -0,0 +1,75 @@ +.tree-wrapper { + height: 100%; + overflow: hidden; + background-color: var(--tree-bg-color) !important; + + :deep(.n-descriptions) { + background-color: var(--tree-bg-color) !important; + } + + :deep(.n-descriptions-header) { + height: 45px; + margin-bottom: unset; + font-size: 11px; + font-weight: 400; + line-height: 40px; + color: var(--tree-header-text-color) !important; + background-color: var(--tree-bg-color) !important; + border-left: 2px solid var(--tree-bg-color); + } + + :deep(.n-descriptions-table-wrapper) { + height: calc(100vh - 45px - 10px); + margin-top: 10px; + } + + .collapse-item { + margin: 0; + height: 100%; + + :deep(.n-collapse-item__header) { + padding-top: 0; + + .n-collapse-item__header-main { + height: 22px; + margin-left: 5px; + font-size: 13px; + color: var(--tree-header-text-color); + } + } + + :deep(.n-collapse-item__content-wrapper) { + margin-top: 5px; + margin-left: 15px; + + .n-collapse-item__content-inner { + padding-right: 15px; + padding-top: 0; + + .tree-item .n-tree-node-wrapper { + //padding: 0 0 5px 0; + padding: 0; + line-height: 17px; + font-size: 13px; + + &:hover { + background-color: var(--tree-hover-color) !important; + } + + .n-tree-node-content { + padding-left: 5px; + color: var(--tree-node-text-color) !important; + + .n-tree-node-content__text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 13px; + color: var(--tree-node-text-color) !important; + } + } + } + } + } + } +} diff --git a/ui/src/components/Kubernetes/Tree/index.vue b/ui/src/components/Kubernetes/Tree/index.vue new file mode 100644 index 000000000..fa1d1390f --- /dev/null +++ b/ui/src/components/Kubernetes/Tree/index.vue @@ -0,0 +1,420 @@ + + + + + diff --git a/ui/src/components/SearchInput/index.vue b/ui/src/components/SearchInput/index.vue new file mode 100644 index 000000000..795d1eb45 --- /dev/null +++ b/ui/src/components/SearchInput/index.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/ui/src/components/ShareConfig.vue b/ui/src/components/ShareConfig.vue deleted file mode 100644 index 064d2e10a..000000000 --- a/ui/src/components/ShareConfig.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - - - \ No newline at end of file diff --git a/ui/src/components/Terminal.vue b/ui/src/components/Terminal.vue deleted file mode 100644 index 79f988b18..000000000 --- a/ui/src/components/Terminal.vue +++ /dev/null @@ -1,574 +0,0 @@ - - - - - \ No newline at end of file diff --git a/ui/src/components/Terminal/index.vue b/ui/src/components/Terminal/index.vue new file mode 100644 index 000000000..138d57f42 --- /dev/null +++ b/ui/src/components/Terminal/index.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/ui/src/components/TerminalProvider/index.vue b/ui/src/components/TerminalProvider/index.vue new file mode 100644 index 000000000..f8390bd4f --- /dev/null +++ b/ui/src/components/TerminalProvider/index.vue @@ -0,0 +1,22 @@ + + + diff --git a/ui/src/components/ThemeConfig.vue b/ui/src/components/ThemeConfig.vue deleted file mode 100644 index 991fd77d9..000000000 --- a/ui/src/components/ThemeConfig.vue +++ /dev/null @@ -1,207 +0,0 @@ - - - - - \ No newline at end of file diff --git a/ui/src/components/VirtualKeyboard/index.vue b/ui/src/components/VirtualKeyboard/index.vue new file mode 100644 index 000000000..a50f3845f --- /dev/null +++ b/ui/src/components/VirtualKeyboard/index.vue @@ -0,0 +1,229 @@ + + + diff --git a/ui/src/components/ZmodemUpload/index.vue b/ui/src/components/ZmodemUpload/index.vue new file mode 100644 index 000000000..8a2307897 --- /dev/null +++ b/ui/src/components/ZmodemUpload/index.vue @@ -0,0 +1,29 @@ + + + diff --git a/ui/src/context/terminalContext.ts b/ui/src/context/terminalContext.ts new file mode 100644 index 000000000..7b4c9409b --- /dev/null +++ b/ui/src/context/terminalContext.ts @@ -0,0 +1,240 @@ +import type { InjectionKey } from 'vue'; + +import mitt from 'mitt'; +import { inject, nextTick } from 'vue'; + +import type { LunaEventType } from '@/utils/lunaBus'; +import type { LunaMessage, TerminalSessionInfo } from '@/types/modules/postmessage.type'; + +import mittBus from '@/utils/mittBus'; +import { formatMessage } from '@/utils'; +import { lunaCommunicator } from '@/utils/lunaBus'; +import { useTreeStore } from '@/store/modules/tree.ts'; +import { terminalTheme } from '@/hooks/useTerminalSocket'; +import { getXTerminalLineContent } from '@/hooks/helper/index'; +import { useTerminalStore } from '@/store/modules/terminal.ts'; +import { useConnectionStore } from '@/store/modules/useConnection'; +import { FORMATTER_MESSAGE_TYPE, LUNA_MESSAGE_TYPE } from '@/types/modules/message.type'; + +type TerminalEvents = Record & { + 'luna-event': { event: string; data: any }; + 'terminal-session': TerminalSessionInfo; + 'terminal-connect': { id: string }; +}; + +interface TerminalContext { + lunaCommunicator: typeof lunaCommunicator; + eventBus: ReturnType>; + + cleanup: () => void; + initialize: () => void; + initializeLunaListeners: () => void; + sendMittEvent: (event: string, data: any) => void; + onMittEvent: (event: string, callback: (data: any) => void) => () => void; + sendLunaEvent: (event: string, data: any) => void; +} + +// 创建注入键 +export const terminalContextKey: InjectionKey = Symbol('terminal-context'); + +// 创建 Context 实例 +export const createTerminalContext = (): TerminalContext => { + const eventBus = mitt(); + const connectionStore = useConnectionStore(); + + const sendLunaEvent = (event: string, data: any) => { + eventBus.emit('luna-event', { event, data }); + }; + + const initializeLunaListeners = () => { + eventBus.on('luna-event', ({ event, data }) => { + switch (event) { + case LUNA_MESSAGE_TYPE.CLOSE: + case LUNA_MESSAGE_TYPE.TERMINAL_ERROR: + lunaCommunicator.sendLuna(LUNA_MESSAGE_TYPE.CLOSE, data); + break; + case LUNA_MESSAGE_TYPE.SHARE_CODE_RESPONSE: + lunaCommunicator.sendLuna(LUNA_MESSAGE_TYPE.SHARE_CODE_RESPONSE, data); + break; + default: + lunaCommunicator.sendLuna(event as LunaEventType, data); + } + }); + + mittBus.on('remove-share-user', user => { + const socket = connectionStore.socket; + const terminalId = connectionStore.terminalId; + + if (!socket || !terminalId) { + console.error('WebSocket connection may be closed, please refresh the page'); + return; + } + + socket.send( + formatMessage( + terminalId, + FORMATTER_MESSAGE_TYPE.TERMINAL_SHARE_USER_REMOVE, + JSON.stringify({ + session: user.sessionId, + user_meta: user.userMeta, + }) + ) + ); + }); + + mittBus.on('write-command', ({ type }) => { + const terminal = connectionStore.terminal; + + if (terminal) { + terminal.paste(type); + } + }); + + const handLunaCommand = (msg: LunaMessage) => { + const terminalStore = useTerminalStore(); + const currentTab = terminalStore.currentTab; + + // 只有在 k8s 连接或切换的时候 currentTab 才会有值 + if (currentTab) { + const treeStore = useTreeStore(); + const currentNode = treeStore.getTerminalByK8sId(currentTab); + + if (!currentNode || !currentNode.terminal) { + console.warn('No active K8s terminal instance found'); + return; + } + + try { + currentNode.socket.send( + JSON.stringify({ + id: currentNode.id, + k8s_id: currentNode.k8s_id, + type: 'TERMINAL_K8S_DATA', + data: msg.data, + }) + ); + } catch (error) { + console.error('Failed to paste command to K8s terminal:', error); + } + + return; + } + + const socket = connectionStore.socket; + const terminalId = connectionStore.terminalId; + + if (!socket || !terminalId) { + console.error('WebSocket connection may be closed, please refresh the page'); + return; + } + + socket.send(formatMessage(terminalId, FORMATTER_MESSAGE_TYPE.TERMINAL_DATA, msg.data)); + }; + + const handInputActive = (_data: string) => { + const msg = { + id: '', + origin: '', + data: '', + } as LunaMessage; + handLunaCommand(msg); + }; + + const handLunaFocus = (_msg: LunaMessage) => { + const terminal = connectionStore.terminal; + + if (terminal) { + terminal.focus(); + } + }; + + const handLunaThemeChange = (_msg: LunaMessage) => { + const terminal = connectionStore.terminal; + if (!terminal) return; + + const themeName = _msg.theme || 'Default'; + const theme = terminalTheme(themeName); + + nextTick(() => { + terminal.options.theme = theme; + }); + }; + + const handleDrawerOpen = (_msg: LunaMessage) => { + connectionStore.updateConnectionState({ + drawerOpenState: true, + }); + }; + + const handTerminalContent = (_msg: LunaMessage) => { + const terminal = connectionStore.terminal; + const sessionId = connectionStore.sessionId; + const terminalId = connectionStore.terminalId; + + if (!terminal || !sessionId || !terminalId) { + console.error('Terminal instance is not initialized'); + return; + } + + const content = getXTerminalLineContent(10, terminal); + + const data = { + content, + sessionId, + terminalId, + }; + + lunaCommunicator.sendLuna(LUNA_MESSAGE_TYPE.TERMINAL_CONTENT_RESPONSE, data); + }; + + lunaCommunicator.onLuna(LUNA_MESSAGE_TYPE.OPEN, handleDrawerOpen); + lunaCommunicator.onLuna(LUNA_MESSAGE_TYPE.CMD, handLunaCommand); + lunaCommunicator.onLuna(LUNA_MESSAGE_TYPE.FOCUS, handLunaFocus); + lunaCommunicator.onLuna(LUNA_MESSAGE_TYPE.TERMINAL_THEME_CHANGE, handLunaThemeChange); + lunaCommunicator.onLuna(LUNA_MESSAGE_TYPE.TERMINAL_CONTENT, handTerminalContent); + lunaCommunicator.onLuna(LUNA_MESSAGE_TYPE.INPUT_ACTIVE, handInputActive); + }; + + const sendMittEvent = (event: string, data: any) => { + mittBus.emit(event as any, data); + }; + + const onMittEvent = (event: string, callback: (data: any) => void) => { + mittBus.on(event as any, callback); + + return () => mittBus.off(event as any, callback); + }; + + const initialize = () => { + initializeLunaListeners(); + }; + + const cleanup = () => { + eventBus.all.clear(); + mittBus.all.clear(); + lunaCommunicator.destroy(); + }; + + return { + eventBus, + lunaCommunicator, + + cleanup, + initialize, + sendLunaEvent, + sendMittEvent, + onMittEvent, + initializeLunaListeners, + }; +}; + +// 获取 Context +export const useTerminalContext = () => { + const context = inject(terminalContextKey); + + if (!context) { + throw new Error('useTerminalContext must be used within TerminalProvider'); + } + + return context; +}; diff --git a/ui/src/directive/sidebarDraggable.ts b/ui/src/directive/sidebarDraggable.ts new file mode 100644 index 000000000..9da66c1e6 --- /dev/null +++ b/ui/src/directive/sidebarDraggable.ts @@ -0,0 +1,42 @@ +import type { DirectiveBinding } from 'vue'; + +export const draggable = { + beforeMount(el: HTMLElement, binding: DirectiveBinding) { + let startX = 0; + let startWidth = 0; + + const mouseMoveHandler = (event: MouseEvent) => { + const newWidth = startWidth + (event.clientX - startX); + + // 确保宽度在合理范围内 + if (newWidth >= 300 && newWidth <= 600) { + el.style.width = `${newWidth}px`; + + binding.value.width = newWidth; + } + }; + + const mouseUpHandler = () => { + document.removeEventListener('mousemove', mouseMoveHandler); + document.removeEventListener('mouseup', mouseUpHandler); + + if (binding.value.onDragEnd && typeof binding.value.onDragEnd === 'function') { + binding.value.onDragEnd(el, binding.value.width); + } + }; + + const mouseDownHandler = (event: MouseEvent) => { + const rect = el.getBoundingClientRect(); + // 只有在右侧边缘10px范围内拖动才触发 + if (event.clientX >= rect.right - 10 && event.clientX <= rect.right) { + startX = event.clientX; + startWidth = el.offsetWidth; + + document.addEventListener('mousemove', mouseMoveHandler); + document.addEventListener('mouseup', mouseUpHandler); + } + }; + + el.addEventListener('mousedown', mouseDownHandler); + }, +}; diff --git a/ui/src/fonts/OpenSans-Bold.ttf b/ui/src/fonts/OpenSans-Bold.ttf new file mode 100644 index 000000000..b7fadfa4a Binary files /dev/null and b/ui/src/fonts/OpenSans-Bold.ttf differ diff --git a/ui/src/fonts/OpenSans-BoldItalic.ttf b/ui/src/fonts/OpenSans-BoldItalic.ttf new file mode 100644 index 000000000..136a4b4c0 Binary files /dev/null and b/ui/src/fonts/OpenSans-BoldItalic.ttf differ diff --git a/ui/src/fonts/OpenSans-Italic.ttf b/ui/src/fonts/OpenSans-Italic.ttf new file mode 100644 index 000000000..e99cb92d4 Binary files /dev/null and b/ui/src/fonts/OpenSans-Italic.ttf differ diff --git a/ui/src/fonts/OpenSans-Light.ttf b/ui/src/fonts/OpenSans-Light.ttf new file mode 100644 index 000000000..a0ba20432 Binary files /dev/null and b/ui/src/fonts/OpenSans-Light.ttf differ diff --git a/ui/src/fonts/OpenSans-Regular.ttf b/ui/src/fonts/OpenSans-Regular.ttf new file mode 100644 index 000000000..8529c432c Binary files /dev/null and b/ui/src/fonts/OpenSans-Regular.ttf differ diff --git a/ui/src/global.d.ts b/ui/src/global.d.ts new file mode 100644 index 000000000..765a2482a --- /dev/null +++ b/ui/src/global.d.ts @@ -0,0 +1,59 @@ +interface Window { + Reconnect: () => void; + + SendTerminalData: (data: any) => void; +} + +declare module 'xterm-theme' { + const themes: { [key: string]: any }; + export default themes; +} + +declare module 'nora-zmodemjs/src/zmodem_browser' { + export class Sentry { + constructor(config: SentryConfig); + get_confirmed_session: () => ZmodemSession | null; + consume: (data: Uint8Array) => void; + } + + export class Browser { + static send_files: ( + session: ZmodemSession, + files: File[], + opts?: { + on_offer_response?: (obj: any, xfer: Transfer) => void; + on_file_complete?: (obj: any) => void; + } + ) => Promise; + + static save_to_disk: (buffer: Uint8Array[], filename: string) => void; + } + + export interface Detection { + confirm: () => ZmodemSession; + } + + export interface Transfer { + get_details: () => { name: string; size: number }; + get_offset: () => number; + accept: () => Promise; + skip: () => void; + on: ((event: 'input', handler: (payload: Uint8Array) => void) => void) & + ((event: 'send_progress', handler: (percent: number) => void) => void); + } + + export interface ZmodemSession { + type: 'send' | 'receive'; + on: (event: 'session_end' | 'offer', handler: (arg: any) => void) => void; + start: () => void; + abort: () => void; + close: () => void; + } + + export interface SentryConfig { + to_terminal?: (octets: string) => void; + sender?: (octets: Uint8Array) => void; + on_retract?: () => void; + on_detect?: (detection: Detection) => void; + } +} diff --git a/ui/src/hooks/helper/index.ts b/ui/src/hooks/helper/index.ts new file mode 100644 index 000000000..d45fbc2d0 --- /dev/null +++ b/ui/src/hooks/helper/index.ts @@ -0,0 +1,169 @@ +import type { Terminal } from '@xterm/xterm'; + +// 引入 API +import { useRoute } from 'vue-router'; +import { createDiscreteApi } from 'naive-ui'; +import { readText } from 'clipboard-polyfill'; + +import type { ILunaConfig } from '@/types/modules/config.type'; + +import { formatMessage } from '@/utils'; +import { BASE_WS_URL } from '@/utils/config'; + +const { message } = createDiscreteApi(['message']); + +/** + * 右键复制文本 + * + * @param e + * @param config + * @param socket + * @param terminalId + * @param termSelectionText + */ +export async function handleContextMenu( + e: MouseEvent, + config: ILunaConfig, + socket: WebSocket, + terminalId: string, + termSelectionText: string +) { + if (e.ctrlKey || config.quickPaste !== '1') return; + + let text: string = ''; + + try { + text = await readText(); + } catch { + if (termSelectionText !== '') text = termSelectionText; + } + e.preventDefault(); + + socket.send(formatMessage(terminalId, 'TERMINAL_DATA', text)); +} + +/** + * 生成 Socket url + */ +export function generateWsURL() { + const route = useRoute(); + + const routeName = route.name; + const urlParams = new URLSearchParams(window.location.search.slice(1)); + + let connectURL; + + switch (routeName) { + case 'Token': { + const params = route.params; + const requireParams = new URLSearchParams(); + + requireParams.append('type', 'token'); + requireParams.append('target_id', params.id ? params.id.toString() : ''); + + connectURL = `${BASE_WS_URL}/koko/ws/token/?${requireParams.toString()}`; + break; + } + case 'TokenParams': { + connectURL = urlParams ? `${BASE_WS_URL}/koko/ws/token/?${urlParams.toString()}` : ''; + break; + } + case 'kubernetes': { + connectURL = `${BASE_WS_URL}/koko/ws/terminal/?token=${route.query.token}&type=k8s`; + break; + } + case 'Share': { + const id = route.params.id as string; + const requireParams = new URLSearchParams(); + + requireParams.append('type', 'share'); + requireParams.append('target_id', id); + + connectURL = `${BASE_WS_URL}/koko/ws/terminal/?${requireParams.toString()}`; + break; + } + case 'Monitor': { + const id = route.params.id as string; + const requireParams = new URLSearchParams(); + + requireParams.append('type', 'monitor'); + requireParams.append('target_id', id); + + connectURL = `${BASE_WS_URL}/koko/ws/terminal/?${requireParams.toString()}`; + break; + } + default: { + connectURL = urlParams ? `${BASE_WS_URL}/koko/ws/terminal/?${urlParams.toString()}` : ''; + } + } + + if (!connectURL) { + message.error('Unable to generate WebSocket URL, missing parameters.'); + } + + return connectURL; +} + +/** + * @description 将 Base64 转化为字节数组 + */ +export function base64ToUint8Array(base64: string): Uint8Array { + // 转为原始的二进制字符串(binaryString)。 + const binaryString = atob(base64); + const len = binaryString.length; + + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} + +/** + * @description 更新网页图标。 + * + * @param {any} setting - 包含 LOGO_URLS 配置的设置对象。 + */ +export function updateIcon(setting: any) { + const faviconURL = setting.INTERFACE.favicon; + + let link = document.querySelector("link[rel*='icon']") as HTMLLinkElement; + + if (!link) { + link = document.createElement('link') as HTMLLinkElement; + link.type = 'image/x-icon'; + link.rel = 'shortcut icon'; + document.getElementsByTagName('head')[0].appendChild(link); + } + + if (faviconURL) { + link.href = faviconURL; + } +} + +export const getXTerminalLineContent = (index: number, terminal: Terminal) => { + const buffer = terminal.buffer.active; + + if (!buffer) return ''; + + const result: string[] = []; + const bufferLineCount = buffer.length; + + let startLine = bufferLineCount; + + while (true) { + if (result.length > index || startLine <= 0) { + console.warn(`Line ${startLine} is empty or result.length > ${result.length}`); + break; + } + const line = buffer.getLine(startLine); + const stripLine = line?.translateToString(true); + startLine--; + if (!stripLine) { + console.warn(`Line ${startLine} is empty or undefined`); + continue; + } + result.unshift(stripLine); + } + return result.join('\n'); +}; diff --git a/ui/src/hooks/useColor.ts b/ui/src/hooks/useColor.ts new file mode 100644 index 000000000..705c5850a --- /dev/null +++ b/ui/src/hooks/useColor.ts @@ -0,0 +1,205 @@ +import { ref } from 'vue'; + +interface HSL { + h: number; + s: number; + l: number; +} + +const mainThemeColorMap = new Map( + Object.entries({ + default: '#483D3D', + deepBlue: '#1A212C', + darkGary: '#303237', + }) +); + +const currentMainColoc = ref('#303237'); + +export const useColor = () => { + const setCurrentMainColor = (color: string) => { + const themeColor = mainThemeColorMap.get(color); + + if (themeColor) { + currentMainColoc.value = themeColor; + } else { + currentMainColoc.value = '#483D3D'; + } + }; + + /** + * 将十六进制颜色转换为HSL颜色 + * @param hex 十六进制颜色 + * @returns HSL颜色 + */ + const hexToHSL = (hex: string): HSL => { + let hexValue = hex.replace(/^#/, ''); + + if (hexValue.length === 3) { + hexValue = hexValue + .split('') + .map(char => char + char) + .join(''); + } + + // 解析RGB值 + const r = Number.parseInt(hexValue.substring(0, 2), 16) / 255; + const g = Number.parseInt(hexValue.substring(2, 4), 16) / 255; + const b = Number.parseInt(hexValue.substring(4, 6), 16) / 255; + + // 计算HSL值 + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + + h /= 6; + } + + // 转换为标准HSL格式 + return { + h: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100), + }; + }; + + /** + * 将HSL颜色转换为十六进制颜色 + * @param h 色相 + * @param s 饱和度 + * @param l 亮度 + * @returns 十六进制颜色 + */ + const hslToHex = (h: number, s: number, l: number) => { + h /= 360; + s /= 100; + l /= 100; + + let r, g, b; + + if (s === 0) { + // 如果饱和度为0,则为灰色 + r = g = b = l; + } else { + const hue2rgb = (p: number, q: number, t: number): number => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + + // 转换为十六进制 + const toHex = (x: number): string => { + const hex = Math.round(x * 255).toString(16); + return hex.length === 1 ? `0${hex}` : hex; + }; + + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + }; + + /** + * 将颜色转换为rgba格式 + * @param alphaValue 透明度值 + * @param color 颜色 + * @returns rgba格式颜色 + */ + const alpha = (alphaValue: number, color?: string) => { + // 如果没有提供颜色,使用当前主题颜色 + const actualColor = color || currentMainColoc.value; + // 确保透明度值在0-1之间 + const alpha = Math.max(0, Math.min(1, alphaValue)); + + // 移除#号并处理缩写形式 + let hex = actualColor.replace(/^#/, ''); + + if (hex.length === 3) { + hex = hex + .split('') + .map(char => char + char) + .join(''); + } + + // 解析RGB值 + const r = Number.parseInt(hex.substring(0, 2), 16); + const g = Number.parseInt(hex.substring(2, 4), 16); + const b = Number.parseInt(hex.substring(4, 6), 16); + + // 返回rgba格式 + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + }; + + /** + * 将颜色变亮 + * @param amount + * @param color + * @param alphaValue + * @returns + */ + const lighten = (amount: number, color?: string, alphaValue?: number) => { + const actualColor = color || currentMainColoc.value; + const hsl = hexToHSL(actualColor); + const hexColor = hslToHex(hsl.h, hsl.s, Math.min(100, hsl.l + amount)); + + if (alphaValue !== undefined) { + return alpha(alphaValue, hexColor); + } + + return hexColor; + }; + + /** + * 将颜色变暗 + * @param amount + * @param color + * @param alphaValue + * @returns + */ + const darken = (amount: number, color?: string, alphaValue?: number) => { + const actualColor = color || currentMainColoc.value; + const hsl = hexToHSL(actualColor); + const hexColor = hslToHex(hsl.h, hsl.s, Math.max(0, hsl.l - amount)); + + // 如果提供了透明度参数,应用透明度 + if (alphaValue !== undefined) { + return alpha(alphaValue, hexColor); + } + + return hexColor; + }; + + return { + darken, + lighten, + alpha, + setCurrentMainColor, + currentMainColor: currentMainColoc, + }; +}; diff --git a/ui/src/hooks/useFileManage.ts b/ui/src/hooks/useFileManage.ts new file mode 100644 index 000000000..e39a3b794 --- /dev/null +++ b/ui/src/hooks/useFileManage.ts @@ -0,0 +1,769 @@ +import type { Ref } from 'vue'; +import type { ConfigProviderProps, UploadFileInfo } from 'naive-ui'; +import type { MessageApiInjection } from 'naive-ui/es/message/src/MessageProvider'; + +import { v4 as uuid } from 'uuid'; +import { computed, ref, watch } from 'vue'; +import { useWebSocket } from '@vueuse/core'; +import { createDiscreteApi, darkTheme } from 'naive-ui'; + +import type { + FileManage, + FileManageConnectData, + FileManageSftpFileItem, + FileSendData, +} from '@/types/modules/file.type'; + +import mittBus from '@/utils/mittBus'; +import { BASE_WS_URL } from '@/utils/config'; +import { lunaCommunicator } from '@/utils/lunaBus'; +import { LUNA_MESSAGE_TYPE } from '@/types/modules/message.type'; +import { useFileManageStore } from '@/store/modules/fileManage.ts'; + +export enum MessageType { + CONNECT = 'CONNECT', + CLOSE = 'CLOSE', + ERROR = 'ERROR', + PING = 'PING', + PONG = 'PONG', + CLOSED = 'closed', + SFTP_DATA = 'SFTP_DATA', + SFTP_BINARY = 'SFTP_BINARY', +} +export enum ManageTypes { + CREATE = 'CREATE', + CHANGE = 'CHANGE', + REFRESH = 'REFRESH', + RENAME = 'RENAME', + REMOVE = 'REMOVE', +} + +const configProviderPropsRef = computed(() => ({ + theme: darkTheme, +})); +const { message: globalTipsMessage }: { message: MessageApiInjection } = createDiscreteApi(['message'], { + configProviderProps: configProviderPropsRef, +}); + +// TODO 都是 hook 内部状态 +let initialPath = ''; +let fileSize = ''; +const uploadFileId = ref(''); +const uploadInterrupt = ref(false); +const uploadInterruptType = ref<'permission' | 'manual' | null>(null); +let downLoadMessage = null; + +/** + * @description 将 buffer 转为 base64 + * @param buffer + */ +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const uint8Array = new Uint8Array(buffer); + const CHUNK_SIZE = 0x8000; + + let result = ''; + + for (let i = 0; i < uint8Array.length; i += CHUNK_SIZE) { + const chunk = uint8Array.subarray(i, i + CHUNK_SIZE); + result += String.fromCharCode.apply(null, chunk as unknown as number[]); + } + + return btoa(result); +} + +/** + * @description 刷新文件列表 + * @param socket + * @param path + */ +export function refresh(socket: WebSocket, path: string) { + const sendData = { + path, + }; + + const sendBody = { + id: uuid(), + cmd: 'list', + type: 'SFTP_DATA', + data: JSON.stringify(sendData), + }; + + socket.send(JSON.stringify(sendBody)); +} + +/** + * @description 处理 type 为 connect 的方法 + * @param messageData + * @param id + * @param socket + */ +function handleSocketConnectEvent(messageData: FileManageConnectData, id: string, socket: WebSocket) { + const sendData = { + path: '', + }; + + const sendBody = { + id, + type: 'SFTP_DATA', + cmd: 'list', + data: JSON.stringify(sendData), + }; + + if (messageData) { + socket.send(JSON.stringify(sendBody)); + } +} + +/** + * @description 设置文件信息 table + * @param messageData + */ +function handleSocketSftpData(messageData: FileManageSftpFileItem[]) { + const fileManageStore = useFileManageStore(); + + // 初始化时保存初始路径 + if (initialPath === '') { + initialPath = fileManageStore.currentPath; + } + + // 如果当前路径是根目录或者是初始路径,则不添加 .. 文件夹 + if (fileManageStore.currentPath === '/' || fileManageStore.currentPath === initialPath) { + messageData = [...messageData]; + } else { + messageData = [ + { + name: '..', + size: '', + perm: '', + mod_time: '', + type: '', + is_dir: true, + }, + ...messageData, + ]; + } + + fileManageStore.setFileList(messageData); +} + +/** + * @description 心跳检测机制 + * @param socket WebSocket实例 + */ +function heartBeat(socket: WebSocket) { + let pingInterval: number | null = null; + + const sendPing = () => { + if (socket.CLOSED === socket.readyState || socket.CLOSING === socket.readyState) { + clearInterval(pingInterval!); + return; + } + + const pingMessage = { + id: uuid(), + type: MessageType.PING, + data: 'ping', + }; + + socket.send(JSON.stringify(pingMessage)); + }; + + sendPing(); + + pingInterval = window.setInterval(sendPing, 200000); + + return () => { + if (pingInterval) { + clearInterval(pingInterval); + } + }; +} + +/** + * @description 处理 message + * @param socket + */ +function initSocketEvent(socket: WebSocket, t: any) { + const fileManageStore = useFileManageStore(); + + let receivedBuffers: any = []; + let clearHeartbeat: (() => void) | null = null; + + socket.binaryType = 'arraybuffer'; + + socket.onopen = () => { + clearHeartbeat = heartBeat(socket); + }; + socket.onerror = () => { + clearHeartbeat?.(); + }; + socket.onclose = () => { + clearHeartbeat?.(); + }; + + socket.onmessage = (event: MessageEvent) => { + const message: FileManage = JSON.parse(event.data); + + fileManageStore.setMessageId(message.id); + fileManageStore.setCurrentPath(message.current_path); + + switch (message.type) { + case MessageType.CONNECT: { + handleSocketConnectEvent(JSON.parse(message.data), message.id, socket); + break; + } + + case MessageType.SFTP_DATA: { + if (message.cmd === 'mkdir' && message.data === 'ok') { + globalTipsMessage.success(t('OperationSuccessful')); + + mittBus.emit('reload-table'); + } + + if (message.cmd === 'rm' && message.data === 'ok') { + globalTipsMessage.success(t('OperationSuccessful')); + + mittBus.emit('reload-table'); + } + + if (message.cmd === 'rm' && message.err === 'permission denied') { + globalTipsMessage.error(t('PermissionDenied')); + + mittBus.emit('reload-table'); + } + + if (message.cmd === 'rename' && message.data === 'ok') { + globalTipsMessage.success(t('OperationSuccessful')); + + mittBus.emit('reload-table'); + } + + if (message.cmd === 'upload' && message.data === 'ok') { + fileManageStore.setReceived(true); + globalTipsMessage.success(t('UploadSuccess')); + + mittBus.emit('reload-table'); + } + + if (message.cmd === 'upload' && message.data === '' && message.err === 'Permission denied') { + globalTipsMessage.error(t('PermissionDenied')); + uploadInterrupt.value = true; + uploadInterruptType.value = 'permission'; + } + + if (message.cmd === 'upload' && message.data !== 'ok') { + fileManageStore.setReceived(true); + } + + if (message.cmd === 'download' && message.data) { + const blob: Blob = new Blob(receivedBuffers, { + type: 'application/octet-stream', + }); + + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + + a.style.display = 'none'; + a.href = url; + a.download = message.data; + + document.body.appendChild(a); + a.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + receivedBuffers = []; + downLoadMessage!.destroy(); + } + + if (message.cmd === 'download' && message.err === 'Permission denied') { + downLoadMessage!.destroy(); + globalTipsMessage.error(t('PermissionDenied')); + + mittBus.emit('reload-table'); + } + + if (message.cmd === 'list') { + handleSocketSftpData(JSON.parse(message.data)); + } + break; + } + + case MessageType.SFTP_BINARY: { + const binaryString = atob(message.raw); + const len = binaryString.length; + const bytes = new Uint8Array(len); + + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + receivedBuffers.push(bytes); + + let receivedBytes = 0; + + for (const buffer of receivedBuffers) { + receivedBytes += buffer.length; + } + + const percent = (receivedBytes / Number(fileSize)) * 100; + downLoadMessage!.content = `${t('DownloadProgress')}: ${percent.toFixed(2)}%`; + + break; + } + + case MessageType.ERROR: { + fileManageStore.setFileList([]); + globalTipsMessage.error(message.err ? message.err : t('FileListError')); + break; + } + + case MessageType.PING: { + socket.send( + JSON.stringify({ + id: uuid(), + type: MessageType.PONG, + data: 'pong', + }) + ); + break; + } + + case MessageType.PONG: { + break; + } + + case MessageType.CLOSE: { + globalTipsMessage.error(t('FileManagementExpired')); + + uploadInterrupt.value = true; + uploadInterruptType.value = null; + + // 文件列表置空 + fileManageStore.setFileList([]); + // 文件路径置空 + fileManageStore.setCurrentPath(''); + + socket.close(); + lunaCommunicator.sendLuna(LUNA_MESSAGE_TYPE.FILE_MANAGE_EXPIRED, ''); + + mittBus.emit('file-manager-expired'); + break; + } + + default: { + break; + } + } + }; +} + +/** + * @description 文件管理中的 Socket 连接 + * @param url + */ +function fileSocketConnection(url: string, t: any) { + const { ws } = useWebSocket(url, { + protocols: ['JMS-KOKO'], + autoReconnect: { + retries: 5, + delay: 3000, + }, + }); + + if (!ws.value) { + globalTipsMessage.error(t('FileListError')); + } + + initSocketEvent(ws!.value, t); + + return ws.value; +} + +/** + * @description 路径跳转的处理 + * @param socket + * @param path + */ +function handleChangePath(socket: WebSocket, path: string) { + const sendBody = { + id: uuid(), + type: 'SFTP_DATA', + cmd: 'list', + data: JSON.stringify({ path }), + }; + + socket.send(JSON.stringify(sendBody)); +} + +/** + * @description 创建文件夹 + * @param socket + * @param path + */ +function handleFileCreate(socket: WebSocket, path: string) { + const sendBody = { + id: uuid(), + type: 'SFTP_DATA', + cmd: 'mkdir', + data: JSON.stringify({ path }), + }; + + socket.send(JSON.stringify(sendBody)); +} + +/** + * @description 重命名 + * @param socket + * @param path + * @param newName + */ +function handleFileRename(socket: WebSocket, path: string, newName: string) { + const sendBody = { + id: uuid(), + type: 'SFTP_DATA', + cmd: 'rename', + data: JSON.stringify({ path, new_name: newName }), + }; + + socket.send(JSON.stringify(sendBody)); +} + +/** + * @description 移除文件 + * @param socket + * @param path + */ +function handleFileRemove(socket: WebSocket, path: string) { + const sendBody = { + id: uuid(), + type: 'SFTP_DATA', + cmd: 'rm', + data: JSON.stringify({ path }), + }; + + socket.send(JSON.stringify(sendBody)); +} + +/** + * @description 下载文件 + * @param socket + * @param path + * @param is_dir + */ +function handleFileDownload(socket: WebSocket, path: string, is_dir: boolean, t: any) { + downLoadMessage = globalTipsMessage.loading(`${t('DownloadProgress')}: 0.00%`, { duration: 1000000000 }); + + const sendData = { + path, + is_dir, + }; + + const sendBody = { + id: uuid(), + type: 'SFTP_DATA', + cmd: 'download', + data: JSON.stringify(sendData), + }; + + socket.send(JSON.stringify(sendBody)); +} + +/** + * @description 预处理 chunks + */ +async function generateUploadChunks( + sliceChunk: Blob, + socket: WebSocket, + fileInfo: UploadFileInfo, + CHUNK_SIZE: number, + sentChunks: Ref, + isSingleChunk: boolean = false, + onError: (() => void) | null = null +) { + const fileManageStore = useFileManageStore(); + const sendData: FileSendData = { + offSet: 0, + size: fileInfo.file?.size, + path: `${fileManageStore.currentPath}/${fileInfo.name}`, + }; + + if (isSingleChunk) { + sendData.chunk = false; + } else { + sendData.merge = isSingleChunk; + sendData.chunk = !isSingleChunk; + } + + const sendBody = { + cmd: 'upload', + type: 'SFTP_DATA', + id: uploadFileId.value, + data: '', + raw: '', + }; + + try { + const arrayBuffer: ArrayBuffer = await sliceChunk.arrayBuffer(); + const base64String: string = arrayBufferToBase64(arrayBuffer); + + sendData.offSet = sentChunks.value * CHUNK_SIZE; + sendBody.raw = base64String; + sendBody.data = JSON.stringify(sendData); + + socket.send(JSON.stringify(sendBody)); + + sentChunks.value++; + + return new Promise(resolve => { + const interval = setInterval(() => { + if (uploadInterrupt.value) { + clearInterval(interval); + if (onError) { + onError(); + } + resolve(false); + return; + } + + if (fileManageStore.isReceived) { + clearInterval(interval); + resolve(true); + } + }, 100); + }); + } catch (error) { + if (onError) { + onError(); + } + + console.error(error); + return false; + } +} + +/** + * @description 中断上传,停止继续发送切片信息 + */ +function interraptUpload() { + uploadInterrupt.value = true; + uploadInterruptType.value = 'manual'; +} + +/** + * @description 上传文件 + */ +async function handleFileUpload( + socket: WebSocket, + uploadFileList: Ref>, + _onProgress: any, + onFinish: () => void, + onError: () => void, + t: any, + externalLoadingMessage?: any +) { + const maxSliceCount = 100; + const maxChunkSize = 1024 * 1024 * 10; + const fileManageStore = useFileManageStore(); + + // prettier-ignore + const loadingMessage = externalLoadingMessage || globalTipsMessage.loading(`${t('UploadProgress')}: 0%`, { duration: 1000000000 }); + + // 确保开始新的上传任务时重置中断状态 + uploadInterrupt.value = false; + uploadInterruptType.value = null; + + let fileInfo = uploadFileList.value[uploadFileList.value.length - 1]; + + // 检查是否已存在同名文件 + const existingFiles = new Set(fileManageStore.fileList?.map(file => file.name) || []); + + for (let i = uploadFileList.value.length - 1; i >= 0; i--) { + const file = uploadFileList.value[i]; + if (!existingFiles.has(file.name)) { + fileInfo = file; + break; + } + } + + const sliceChunks = []; + let CHUNK_SIZE = 1024 * 1024 * 5; + const sentChunks = ref(0); + + const unwatch = watch( + () => sentChunks.value, + newValue => { + const percent = (newValue / sliceChunks.length) * 100; + + _onProgress({ percent }); + + loadingMessage.content = `${t('UploadProgress')}: ${Math.floor(percent)}%`; + + if (percent >= 100) { + onFinish(); + loadingMessage.destroy(); + unwatch(); + } + } + ); + + if (fileInfo && fileInfo.file) { + let sliceCount = Math.ceil(fileInfo.file?.size / CHUNK_SIZE); + + // 如果切片数量大于最大切片数量,那么调大切片大小 + if (sliceCount > maxSliceCount) { + sliceCount = maxSliceCount; + CHUNK_SIZE = Math.ceil(fileInfo.file?.size / maxSliceCount); + } + + // 如果切片大小大于最大切片大小,那么依然调整切片数量 + if (CHUNK_SIZE > maxChunkSize) { + CHUNK_SIZE = maxChunkSize; + sliceCount = Math.ceil(fileInfo.file?.size / CHUNK_SIZE); + } + + for (let i = 0; i < sliceCount; i++) { + sliceChunks.push(fileInfo.file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE)); + } + + try { + uploadFileId.value = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(); + + // 判断是否只有一个切片 + const isSingleChunk = sliceChunks.length === 1; + + for (const sliceChunk of sliceChunks) { + fileManageStore.setReceived(false); + + if (uploadInterrupt.value) { + // 只有在用户主动取消时才显示取消提示 + if (uploadInterruptType.value === 'manual') { + globalTipsMessage.error(t('CancelFileUpload')); + } + onError(); + loadingMessage.destroy(); + uploadInterrupt.value = false; + uploadInterruptType.value = null; + return; + } + + const result = await generateUploadChunks( + sliceChunk, + socket, + fileInfo, + CHUNK_SIZE, + sentChunks, + isSingleChunk, + onError + ); + + if (!result) { + loadingMessage.destroy(); + return; + } + } + + // 如果不是单切片,才需要发送merge请求 + if (sliceChunks.length > 1) { + // 结束 chunk 发送 merge: true + socket.send( + JSON.stringify({ + cmd: 'upload', + type: 'SFTP_DATA', + id: fileManageStore.messageId, + raw: '', + data: JSON.stringify({ + offSet: 0, + merge: true, + size: 0, + path: `${fileManageStore.currentPath}/${fileInfo.name}`, + }), + }) + ); + } + uploadFileId.value = ''; + } catch (e) { + loadingMessage.destroy(); + console.error(e); + onError(); + } + } +} + +/** + * @description 用于处理文件管理相关逻辑 + */ +export function useFileManage(token: string, t: any) { + const fileConnectionUrl: string = `${BASE_WS_URL}/koko/ws/sftp/?token=${token}`; + + function init() { + const socket = fileSocketConnection(fileConnectionUrl, t); + + mittBus.on( + 'file-upload', + ({ + uploadFileList, + onFinish, + onError, + onProgress, + loadingMessage, + }: { + uploadFileList: Ref>; + onFinish: () => void; + onError: () => void; + onProgress: (e: { percent: number }) => void; + loadingMessage?: any; + }) => { + handleFileUpload(socket, uploadFileList, onProgress, onFinish, onError, t, loadingMessage); + } + ); + + mittBus.on('download-file', ({ path, is_dir, size }: { path: string; is_dir: boolean; size: string }) => { + fileSize = size; + handleFileDownload(socket, path, is_dir, t); + }); + + mittBus.on('file-manage', ({ path, type, new_name }: { path: string; type: ManageTypes; new_name?: string }) => { + switch (type) { + case ManageTypes.CREATE: { + handleFileCreate(socket, path); + break; + } + case ManageTypes.CHANGE: { + handleChangePath(socket, path); + break; + } + case ManageTypes.REFRESH: { + refresh(socket, path); + break; + } + case ManageTypes.RENAME: { + handleFileRename(socket, path, new_name!); + break; + } + case ManageTypes.REMOVE: { + handleFileRemove(socket, path); + break; + } + } + }); + + mittBus.on('stop-upload', (data: { fileInfo: UploadFileInfo }) => { + interraptUpload(); + // 发送上传停止成功事件 + mittBus.emit('upload-stopped', { fileInfo: data.fileInfo }); + }); + + return socket; + } + + return init(); +} + +export function unloadListeners() { + mittBus.off('download-file'); + mittBus.off('file-upload'); + mittBus.off('file-manage'); + mittBus.off('stop-upload'); + mittBus.off('upload-stopped'); +} diff --git a/ui/src/hooks/useKubernetes.ts b/ui/src/hooks/useKubernetes.ts new file mode 100644 index 000000000..a2a9f3331 --- /dev/null +++ b/ui/src/hooks/useKubernetes.ts @@ -0,0 +1,873 @@ +import type { Ref } from 'vue'; +import type { ISearchOptions } from '@xterm/addon-search'; +import type { TreeOption } from 'naive-ui'; + +import { h, ref } from 'vue'; +import { v4 as uuid } from 'uuid'; +import { storeToRefs } from 'pinia'; +import xtermTheme from 'xterm-theme'; +import { Terminal } from '@xterm/xterm'; +import { useWebSocket } from '@vueuse/core'; +import { FitAddon } from '@xterm/addon-fit'; +import { BrandDocker } from '@vicons/tabler'; +import { Box, Folder } from 'lucide-vue-next'; +import { readText } from 'clipboard-polyfill'; +import { WebglAddon } from '@xterm/addon-webgl'; +import { SearchAddon } from '@xterm/addon-search'; +import { createDiscreteApi, darkTheme, NIcon } from 'naive-ui'; + +import type { customTreeOption, ILunaConfig } from '@/types/modules/config.type'; + +import mittBus from '@/utils/mittBus'; +import { updateIcon } from '@/hooks/helper'; +import { MaxTimeout } from '@/utils/config'; +import { useZmodem } from '@/hooks/useZmodem'; +import { lunaCommunicator } from '@/utils/lunaBus'; +import { useTreeStore } from '@/store/modules/tree.ts'; +import { formatMessage, preprocessInput } from '@/utils'; +import { useParamsStore } from '@/store/modules/params.ts'; +import { useTerminalStore } from '@/store/modules/terminal.ts'; +import { LUNA_MESSAGE_TYPE } from '@/types/modules/message.type'; +import { useKubernetesStore } from '@/store/modules/kubernetes.ts'; + +import { base64ToUint8Array, generateWsURL } from './helper'; + +const { message, notification } = createDiscreteApi(['message', 'notification'], { + configProviderProps: { + theme: darkTheme, + }, +}); + +const lunaId = ref(''); +const counter = ref(0); +const guaranteeInterval = ref(null); + +function getLabelString(label: unknown): string { + if (typeof label === 'string') return label; + if (label == null) return ''; + return String(label); +} + +function isAsciiUppercase(char: string): boolean { + return char >= 'A' && char <= 'Z'; +} + +function isAsciiLowercase(char: string): boolean { + return char >= 'a' && char <= 'z'; +} + +function azAzGroup(label: string): number { + const firstChar = label.trim().charAt(0); + if (isAsciiUppercase(firstChar)) return 0; + if (isAsciiLowercase(firstChar)) return 1; + return 2; +} + +function compareLabelAZaz(aLabel: string, bLabel: string): number { + const aGroup = azAzGroup(aLabel); + const bGroup = azAzGroup(bLabel); + if (aGroup !== bGroup) return aGroup - bGroup; + + const aLower = aLabel.toLowerCase(); + const bLower = bLabel.toLowerCase(); + + const byLower = aLower.localeCompare(bLower, 'en', { numeric: true }); + if (byLower !== 0) return byLower; + + return aLabel.localeCompare(bLabel, 'en', { numeric: true }); +} + +function sortTreeNodesAZaz(nodes: TreeOption[]): void { + nodes.sort((a, b) => compareLabelAZaz(getLabelString(a.label), getLabelString(b.label))); + + for (const node of nodes) { + if (Array.isArray(node.children) && node.children.length > 0) { + sortTreeNodesAZaz(node.children); + } + } +} + +function handleConnected(socket: WebSocket, pingInterval: Ref) { + const kubernetesStore = useKubernetesStore(); + + if (pingInterval.value) clearInterval(pingInterval.value); + + pingInterval.value = setInterval(() => { + if (socket.CLOSED === socket.readyState || socket.CLOSING === socket.readyState) { + return clearInterval(pingInterval.value!); + } + + const currentDate: Date = new Date(); + + if (kubernetesStore.lastReceiveTime.getTime() - currentDate.getTime() > MaxTimeout) { + console.error('More than 30 seconds do not receive data'); + } + + const pingTimeout = currentDate.getTime() - kubernetesStore.lastSendTime.getTime() - MaxTimeout; + + if (pingTimeout < 0) { + return; + } + + socket.send(formatMessage(kubernetesStore.globalTerminalId, 'PING', '')); + }, 25 * 1000); +} + +function guaranteeLunaConnection(ws: WebSocket) { + guaranteeInterval.value = setInterval(() => { + lunaId.value = lunaCommunicator.getLunaId(); + if (lunaId.value) { + clearInterval(guaranteeInterval.value!); + return; + } + counter.value++; + if (counter.value >= 5) { + // eslint-disable-next-line no-alert + alert('Failed to connect to Luna'); + ws.close(); + clearInterval(guaranteeInterval.value!); + window.close(); + } + }, 1000); +} + +/** + * @description 初始化同步节点树 + */ +export function initTreeNodes(ws: WebSocket, id: string, info: any) { + const unique = uuid(); + const treeStore = useTreeStore(); + const sendData: string = JSON.stringify({ + type: 'TERMINAL_K8S_TREE', + }); + + const rootNode: customTreeOption = { + id, + key: unique, + k8s_id: unique, + isLeaf: false, + isParent: true, + socket: ws, + label: info.asset.name, + prefix: () => h(Folder), + }; + + treeStore.setRoot(rootNode); + + ws.send(sendData); +} + +/** + * 处理 socket Error + * + * @param {string} type + */ +export function handleInterrupt(type: string, t: any) { + switch (type) { + case 'error': { + // terminal.write('Connection Websocket Error'); + message.error(t('WebSocketError')); + break; + } + case 'disconnected': { + // terminal.write('Connection Websocket Closed'); + message.error(t('WebSocketClosed')); + break; + } + } +} + +/** + * @description 设置通用属性 + * + * @param nodes + * @param label + * @param isLeaf + */ +export function setCommonAttributes(nodes: any, label: string, isLeaf: boolean) { + const unique = uuid(); + + Object.assign(nodes, { + label, + key: unique, + k8s_id: unique, + isLeaf, + }); +} + +/** + * 处理最后的 container 节点 + * + * @param containers + * @param podName + * @param namespace + * @param socket + */ +export function handleContainer(containers: any, podName: string, namespace: string, socket: WebSocket) { + const kubernetesStore = useKubernetesStore(); + + containers.forEach((container: any) => { + Object.assign(container, { + socket, + namespace, + key: uuid(), + pod: podName, + container: container.name, + id: kubernetesStore.globalTerminalId, + prefix: () => h(NIcon, { size: 16 }, { default: () => h(BrandDocker) }), + }); + + setCommonAttributes(container, container.name, true); + }); +} + +/** + * 处理 Pod + * + * @param pods + * @param namespace + * @param socket + */ +export function handlePods(pods: any, namespace: string, socket: WebSocket) { + pods.forEach((pod: any) => { + if (pod.containers && pod.containers?.length > 0) { + pod.key = uuid(); + pod.label = pod.name; + pod.isLeaf = false; + pod.namespace = namespace; + pod.children = pod.containers; + pod.prefix = () => h(Box, { size: 16 }); + + // 处理最后的 container + handleContainer(pod.children, pod.name, namespace, socket); + + delete pod.containers; + } else { + pod.children = []; + } + }); +} + +/** + * 二次处理节点 + * + * @param message + * @param ws + */ +export function handleTreeNodes(message: any, ws: WebSocket) { + const treeStore = useTreeStore(); + + if (message.err) { + treeStore.setTreeNodes({} as customTreeOption); + + return notification.error({ + content: message.err, + duration: 5000, + }); + } + + const originNode = JSON.parse(message.data); + + if (Object.keys(originNode).length === 0) { + return treeStore.setLoaded(false); + } + + Object.keys(originNode).forEach(node => { + // 得到每个 namespace + const item = originNode[node]; + + item.label = node; + item.socket = ws; + item.key = uuid(); + item.prefix = () => h(Folder, { size: 15 }); + + if (item.pods && item.pods.length > 0) { + // 处理 pods + item.children = item.pods; + + handlePods(item.pods, item.name, ws); + + // 删除多余项 + delete item.pods; + } else { + delete item.pods; + item.children = []; + } + + treeStore.setTreeNodes(item); + }); + + sortTreeNodesAZaz(treeStore.treeNodes); + treeStore.setLoaded(true); +} + +/** + * @description 处理 Tree 相关的 Socket 消息 + * + * @param ws + * @param event + */ +export function handleTreeMessage(ws: WebSocket, event: MessageEvent) { + const treeStore = useTreeStore(); + const kubernetesStore = useKubernetesStore(); + const paramsStore = useParamsStore(); + + kubernetesStore.setLastReceiveTime(new Date()); + + if (!event.data) return; + + const message = JSON.parse(event.data); + const type = message.type; + + switch (type) { + case 'CLOSE': + case 'ERROR': { + ws.close(); + mittBus.emit('connect-error'); + break; + } + case 'CONNECT': { + const info = JSON.parse(message.data); + + //* 设置通用配置以及全局唯一 id + paramsStore.setSetting(info.setting); + kubernetesStore.setGlobalSetting(info.setting); + kubernetesStore.setGlobalTerminalId(message.id); + + treeStore.setConnectInfo(info); + + updateIcon(info.setting); + initTreeNodes(ws, message.id, info); + + break; + } + case 'TERMINAL_K8S_TREE': { + handleTreeNodes(message, ws); + break; + } + } +} + +export function handleTerminalMessage(ws: WebSocket, event: MessageEvent, createSentry: any, t: any) { + const treeStore = useTreeStore(); + const paramsStore = useParamsStore(); + const terminalStore = useTerminalStore(); + const kubernetesStore = useKubernetesStore(); + + const { setting } = storeToRefs(paramsStore); + + const info = JSON.parse(event.data); + + // 根据返回信息的 k8s id 找到与之对应的 terminal 实例 + const operatedNode = treeStore.getTerminalByK8sId(info.k8s_id); + const currentTerminal = operatedNode?.terminal; + + if (currentTerminal) { + const sentry = createSentry(currentTerminal, ws, kubernetesStore.lastSendTime); + + switch (info.type) { + case 'TERMINAL_K8S_BINARY': { + sentry.consume(base64ToUint8Array(info.raw)); + break; + } + case 'TERMINAL_SESSION': { + const sessionInfo = JSON.parse(info.data); + const sessionDetail = sessionInfo.session; + + const share = sessionInfo.permission.actions.includes('share'); + + const backspaceValue = sessionInfo.backspaceAsCtrlH ? '1' : '0'; + const ctrlCValue = sessionInfo.ctrlCAsCtrlZ ? '1' : '0'; + + // 存储到节点的配置映射中 + if (operatedNode.sessionIdMap) { + operatedNode.sessionIdMap.set(info.k8s_id, sessionDetail.id); + } else { + operatedNode.sessionIdMap = new Map(); + operatedNode.sessionIdMap.set(info.k8s_id, sessionDetail.id); + } + + if (operatedNode.ctrlCAsCtrlZMap) { + operatedNode.ctrlCAsCtrlZMap.set(info.k8s_id, ctrlCValue); + } else { + operatedNode.ctrlCAsCtrlZMap = new Map(); + operatedNode.ctrlCAsCtrlZMap.set(info.k8s_id, ctrlCValue); + } + + if (operatedNode.backspaceAsCtrlHMap) { + operatedNode.backspaceAsCtrlHMap.set(info.k8s_id, backspaceValue); + } else { + operatedNode.backspaceAsCtrlHMap = new Map(); + operatedNode.backspaceAsCtrlHMap.set(info.k8s_id, backspaceValue); + } + + operatedNode.themeName = sessionInfo.themeName; + + // 如果当前激活的 tab 就是这个节点,立即更新 terminalStore 的配置 + if (terminalStore.currentTab === info.k8s_id) { + terminalStore.setTerminalConfig('backspaceAsCtrlH', backspaceValue); + terminalStore.setTerminalConfig('ctrlCAsCtrlZ', ctrlCValue); + } + + if (setting.value.SECURITY_SESSION_SHARE && share) { + operatedNode.enableShare = true; + } + + treeStore.setK8sIdMap(info.k8s_id, { ...operatedNode }); + + currentTerminal.options.theme = xtermTheme[sessionInfo.themeName]; + + break; + } + case 'TERMINAL_ACTION': { + break; + } + case 'TERMINAL_ERROR': { + const hasCurrentK8sId = treeStore.removeK8sIdMap(info.k8s_id); + + if (hasCurrentK8sId) { + currentTerminal?.write(info.err); + } + + break; + } + case 'K8S_CLOSE': { + treeStore.removeK8sIdMap(info.k8s_id); + + currentTerminal?.attachCustomKeyEventHandler(() => { + return false; + }); + + operatedNode.enableShare = false; + + if (operatedNode.onlineUsersMap && Object.prototype.hasOwnProperty.call(operatedNode.onlineUsersMap, info.id)) { + delete operatedNode.onlineUsersMap[info.id]; + } + + treeStore.setK8sIdMap(info.k8s_id, { ...operatedNode }); + + break; + } + case 'TERMINAL_SHARE_JOIN': { + const data = JSON.parse(info.data); + const k8s_id: string = info.k8s_id; + + if (operatedNode.onlineUsersMap && operatedNode.onlineUsersMap[k8s_id]) { + operatedNode.onlineUsersMap[k8s_id].push({ + k8s_id: info.k8s_id, + ...data, + }); + treeStore.setK8sIdMap(k8s_id, { ...operatedNode }); + } else { + operatedNode.onlineUsersMap = {}; + operatedNode.onlineUsersMap[k8s_id] = [{ k8s_id, ...data }]; + + treeStore.setK8sIdMap(k8s_id, { ...operatedNode }); + } + + if (data.primary) { + break; + } + + message.info(`${data.user} ${t('JoinShare')}`); + + break; + } + case 'TERMINAL_SHARE_LEAVE': { + const data = JSON.parse(info.data); + const k8s_id: string = info.k8s_id; + + if (Object.prototype.hasOwnProperty.call(operatedNode.onlineUsersMap, k8s_id)) { + const items = operatedNode?.onlineUsersMap[k8s_id]; + const index = items.findIndex((item: any) => item?.terminal_id === data?.terminal_id); + + if (index !== -1) { + items.splice(index, 1); + + if (items.length === 0) { + delete operatedNode.onlineUsersMap[k8s_id]; + } + } + } + + treeStore.setK8sIdMap(k8s_id, { ...operatedNode }); + + message.info(`${data.user} ${t('LeaveShare')}`); + + break; + } + case 'CLOSE': { + operatedNode.enableShare = false; + + treeStore.setK8sIdMap(info.k8s_id, { ...operatedNode }); + break; + } + } + } + + // 由于 TERMINAL_GET_SHARE_USER 不会返回 k8s id 所以只能根据当前页保存的 k8s id 去获取 node 信息 + if (info.type === 'TERMINAL_GET_SHARE_USER') { + const innerOperatedNode = treeStore.getTerminalByK8sId(terminalStore.currentTab); + innerOperatedNode.userOptions = JSON.parse(info.data); + + treeStore.setK8sIdMap(terminalStore.currentTab, { ...innerOperatedNode }); + } + + if (info.type === 'TERMINAL_SHARE') { + const data = JSON.parse(info.data); + + const currentTab = terminalStore.currentTab; + const currentNode = treeStore.getTerminalByK8sId(currentTab); + + if (currentNode) { + // 为当前节点添加分享状态映射 + if (!currentNode.shareIdMap) { + currentNode.shareIdMap = new Map(); + } + if (!currentNode.shareCodeMap) { + currentNode.shareCodeMap = new Map(); + } + + currentNode.shareIdMap.set(currentTab, data.share_id); + currentNode.shareCodeMap.set(currentTab, data.code); + treeStore.setK8sIdMap(currentTab, { ...currentNode }); + } + } +} + +/** + * @description 创建 k8s 连接 + */ +export function createConnect(t: any) { + const pingInterval: Ref = ref(null); + const connectURL: string = generateWsURL(); + + const { createSentry } = useZmodem(); + + if (connectURL) { + const { ws } = useWebSocket(connectURL, { + protocols: ['JMS-KOKO'], + onConnected: (ws: WebSocket) => { + guaranteeLunaConnection(ws); + handleConnected(ws, pingInterval); + }, + onMessage: (ws: WebSocket, event: MessageEvent) => { + handleTreeMessage(ws, event); + handleTerminalMessage(ws, event, createSentry, t); + }, + onError: () => handleInterrupt('error', t), + onDisconnected: () => handleInterrupt('disconnected', t), + }); + + return ws.value; + } +} + +/** + * @description 初始化终端事件 + * + * @param el + * @param terminal + * @param lunaConfig + * @param socket + * @param nodeInfo + */ +export function initTerminalEvent( + el: HTMLElement, + terminal: Terminal, + lunaConfig: ILunaConfig, + socket: WebSocket, + nodeInfo: any +) { + const fitAddon: FitAddon = new FitAddon(); + const webglAddon: WebglAddon = new WebglAddon(); + const searchAddon: SearchAddon = new SearchAddon(); + + const terminalStore = useTerminalStore(); + + terminal.loadAddon(fitAddon); + terminal.loadAddon(webglAddon); + terminal.loadAddon(searchAddon); + + terminal.open(el); + terminal.focus(); + fitAddon.fit(); + + terminal.onResize(({ cols, rows }) => { + fitAddon.fit(); + + const resizeData = JSON.stringify({ cols, rows }); + const sendData = { + id: nodeInfo.id, + k8s_id: nodeInfo.k8s_id, + type: 'TERMINAL_K8S_RESIZE', + namespace: nodeInfo.namespace || '', + pod: nodeInfo.pod || '', + container: nodeInfo.container || '', + resizeData, + }; + + socket.send(JSON.stringify(sendData)); + }); + + terminal.onData((data: string) => { + const kubernetesStore = useKubernetesStore(); + const terminalStore = useTerminalStore(); + const treeStore = useTreeStore(); + + const currentK8sId = terminalStore.currentTab; + const currentNode = treeStore.getTerminalByK8sId(currentK8sId); + + if (!currentNode) { + return; + } + + kubernetesStore.setLastSendTime(new Date()); + + // 使用当前 terminalStore 中的配置,这样每个 tab 都有自己的配置 + const currentConfig = { + ...lunaConfig, + ctrlCAsCtrlZ: terminalStore.ctrlCAsCtrlZ, + backspaceAsCtrlH: terminalStore.backspaceAsCtrlH, + }; + + const inputMessage = preprocessInput(data, currentConfig); + + const messageBody = { + data: inputMessage, + id: currentNode.id, + pod: currentNode.pod || '', + k8s_id: currentK8sId, + namespace: currentNode.namespace || '', + container: currentNode.container || '', + type: 'TERMINAL_K8S_DATA', + }; + + socket.send(JSON.stringify(messageBody)); + lunaCommunicator.sendLuna(LUNA_MESSAGE_TYPE.INPUT_ACTIVE, ''); + }); + + terminal.onSelectionChange(() => { + terminalStore.setTerminalConfig('termSelectionText', terminal.getSelection().trim()); + }); + + terminal.attachCustomKeyEventHandler(e => { + if (e.altKey && e.shiftKey && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) { + switch (e.key) { + case 'ArrowRight': + mittBus.emit('alt-shift-right'); + + break; + case 'ArrowLeft': + mittBus.emit('alt-shift-left'); + break; + } + return false; + } + + if (e.ctrlKey && e.key === 'c' && terminal.hasSelection()) { + return false; + } + + return !(e.ctrlKey && e.key === 'v'); + }); + + return { + fitAddon, + searchAddon, + }; +} + +/** + * @description 初始节点相关事件 + */ +export function initElEvent( + el: HTMLElement, + terminal: Terminal, + fitAddon: FitAddon, + socket: WebSocket, + lunaConfig: ILunaConfig, + nodeInfo: any +) { + el.addEventListener( + 'mouseenter', + () => { + fitAddon.fit(); + terminal?.focus(); + }, + false + ); + + el.addEventListener( + 'contextmenu', + async e => { + if (e.ctrlKey || lunaConfig.quickPaste !== '1') return; + + let text: string = ''; + + const terminalStore = useTerminalStore(); + + try { + text = await readText(); + } catch { + if (terminalStore.termSelectionText !== '') text = terminalStore.termSelectionText; + } finally { + socket.send( + JSON.stringify({ + id: nodeInfo.id, + k8s_id: nodeInfo.k8s_id, + type: 'TERMINAL_K8S_DATA', + data: text, + }) + ); + } + + e.preventDefault(); + }, + false + ); + + el.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.ctrlKey || e.metaKey) { + if (e.key === 'f') { + mittBus.emit('open-search'); + e.preventDefault(); + } + } + }); +} + +/** + * @description 初始化全局 window 事件 + * + * @param fitAddon + */ +export function initCustomWindowEvent(fitAddon: FitAddon) { + window.addEventListener( + 'resize', + () => { + fitAddon.fit(); + }, + false + ); + + window.addEventListener('keydown', (event: KeyboardEvent) => { + const isAltShift = event.altKey && event.shiftKey; + + if (isAltShift && event.key === 'ArrowLeft') { + mittBus.emit('alt-shift-left'); + } else if (isAltShift && event.key === 'ArrowRight') { + mittBus.emit('alt-shift-right'); + } + }); +} + +/** + * @description 发送 k8s 事件 + * + * @param socket + * @param type + * @param data + */ +export function sendK8sMessage(socket: WebSocket, type: string, data: any) { + const treeStore = useTreeStore(); + const terminalStore = useTerminalStore(); + + const currentNode = treeStore.getTerminalByK8sId(terminalStore.currentTab); + + socket.send( + JSON.stringify({ + id: currentNode.id, + k8s_id: currentNode.k8s_id, + type, + data: JSON.stringify(data), + }) + ); +} + +export function initMittBusEvents(searchAddon: SearchAddon, socket: WebSocket) { + mittBus.on('terminal-search', ({ keyword, type = '' }) => { + const searchOption: ISearchOptions = { + caseSensitive: false, + // @ts-expect-error - xterm search addon types are incomplete for decorations + decorations: { + matchBackground: '#FFFF54', + activeMatchBackground: '#F19B4A', + }, + }; + + if (type === 'next') { + searchAddon.findNext(keyword, searchOption); + } else { + searchAddon.findPrevious(keyword, searchOption); + } + }); + mittBus.on('create-share-url', ({ type, sessionId, shareLinkRequest }) => { + const origin = window.location.origin; + + sendK8sMessage(socket, type, { + origin, + session: sessionId, + users: shareLinkRequest.users, + expired_time: shareLinkRequest.expiredTime, + action_permission: shareLinkRequest.actionPerm, + }); + }); + mittBus.on('remove-share-user', ({ sessionId, userMeta, type }) => { + sendK8sMessage(socket, type, { + session: sessionId, + user_meta: userMeta, + }); + }); + mittBus.on('share-user', ({ type, query }) => { + sendK8sMessage(socket, type, { query }); + }); + mittBus.on('sync-theme', ({ type, data }) => { + sendK8sMessage(socket, type, data); + }); +} + +/** + * @description 创建 K8s 终端 + */ +export function createTerminal(el: HTMLElement, socket: WebSocket, lunaConfig: ILunaConfig, nodeInfo: any) { + const { fontSize, lineHeight, fontFamily } = lunaConfig; + + const options = { + allowProposedApi: true, + fontSize, + lineHeight, + fontFamily, + rightClickSelectsWord: true, + theme: { + background: '#1E1E1E', + }, + scrollback: 5000, + }; + + const terminal: Terminal = new Terminal(options); + + const { fitAddon, searchAddon } = initTerminalEvent(el, terminal, lunaConfig, socket, nodeInfo); + + initElEvent(el, terminal, fitAddon, socket, lunaConfig, nodeInfo); + initCustomWindowEvent(fitAddon); + initMittBusEvents(searchAddon, socket); + + return { + terminal, + searchAddon, + }; +} + +export function useKubernetes(t: any) { + let socket: WebSocket | undefined; + + const ws = createConnect(t); + + if (ws) { + socket = ws; + socket!.binaryType = 'arraybuffer'; + + return socket; + } +} diff --git a/ui/src/hooks/useSessionAdapter.ts b/ui/src/hooks/useSessionAdapter.ts new file mode 100644 index 000000000..032b89542 --- /dev/null +++ b/ui/src/hooks/useSessionAdapter.ts @@ -0,0 +1,256 @@ +import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { storeToRefs } from 'pinia'; +import { useMessage } from 'naive-ui'; +import { writeText } from 'clipboard-polyfill'; + +import type { OnlineUser, ShareUserOptions } from '@/types/modules/user.type'; + +import mittBus from '@/utils/mittBus'; +import { formatMessage } from '@/utils'; +import { BASE_URL } from '@/utils/config'; +import { useTreeStore } from '@/store/modules/tree'; +import { useParamsStore } from '@/store/modules/params'; +import { useTerminalStore } from '@/store/modules/terminal'; +import { useConnectionStore } from '@/store/modules/useConnection'; +import { FORMATTER_MESSAGE_TYPE } from '@/types/modules/message.type'; + +/** + * 会话数据适配器 - 统一普通连接和K8s连接的数据接口 + */ +export function useSessionAdapter() { + const { t } = useI18n(); + const message = useMessage(); + + const treeStore = useTreeStore(); + const paramsStore = useParamsStore(); + const terminalStore = useTerminalStore(); + const connectionStore = useConnectionStore(); + + const isK8sEnvironment = computed(() => { + return window.location.pathname.includes('/k8s'); + }); + + // 获取当前活跃的tab ID(K8s环境下使用) + const currentActiveTab = computed(() => { + return terminalStore.currentTab; + }); + + // 获取当前K8s节点信息 + const getCurrentK8sNode = () => { + if (!isK8sEnvironment.value || !currentActiveTab.value) return null; + return treeStore.getTerminalByK8sId(currentActiveTab.value); + }; + + // 统一的在线用户数据 + const onlineUsers = computed(() => { + if (isK8sEnvironment.value) { + const currentNode = getCurrentK8sNode(); + if (!currentNode?.onlineUsersMap || !currentActiveTab.value) return []; + + return currentNode.onlineUsersMap[currentActiveTab.value] || []; + } else { + return connectionStore.onlineUsers || []; + } + }); + + const shareInfo = computed(() => { + if (isK8sEnvironment.value) { + const currentNode = getCurrentK8sNode(); + const currentTabId = currentActiveTab.value; + + // 从节点的分享映射中获取当前 tab 的分享信息 + const shareId = currentNode?.shareIdMap?.get(currentTabId) || ''; + const shareCode = currentNode?.shareCodeMap?.get(currentTabId) || ''; + + return { + shareId, + shareCode, + sessionId: currentNode?.sessionIdMap?.get(currentTabId) || '', + enableShare: currentNode?.enableShare || false, + shareURL: shareId ? `${BASE_URL}/luna/share/${shareId}/?code=${shareCode}` : '', + }; + } else { + const shareId = connectionStore.shareId || ''; + return { + shareId, + shareCode: connectionStore.shareCode || '', + sessionId: connectionStore.sessionId || '', + enableShare: connectionStore.enableShare || false, + shareURL: shareId ? `${BASE_URL}/luna/share/${shareId}/?code=${connectionStore.shareCode}` : '', + }; + } + }); + + const userOptions = computed(() => { + if (isK8sEnvironment.value) { + const currentNode = getCurrentK8sNode(); + return currentNode?.userOptions || []; + } else { + return connectionStore.userOptions || []; + } + }); + + const connectionInfo = computed(() => { + if (isK8sEnvironment.value) { + const currentNode = getCurrentK8sNode(); + return { + socket: currentNode?.socket, + terminalId: currentNode?.id, + }; + } else { + return { + socket: connectionStore.socket, + terminalId: connectionStore.terminalId, + }; + } + }); + + const createShareLink = (shareLinkRequest: { + expiredTime: number; + actionPerm: string; + users: ShareUserOptions[]; + }) => { + if (isK8sEnvironment.value) { + const currentNode = getCurrentK8sNode(); + const sessionId = currentNode?.sessionIdMap?.get(currentActiveTab.value); + + if (!sessionId) { + message.error(t('FailedCreateConnection')); + return; + } + + mittBus.emit('create-share-url', { + type: 'TERMINAL_SHARE', + sessionId, + shareLinkRequest, + }); + } else { + const { socket, terminalId } = storeToRefs(connectionStore); + const sessionId = connectionStore.sessionId; + + if (!socket?.value || !terminalId?.value || !sessionId) { + message.error(t('FailedCreateConnection')); + return; + } + + socket.value.send( + formatMessage( + terminalId.value, + FORMATTER_MESSAGE_TYPE.TERMINAL_SHARE, + JSON.stringify({ + origin: window.location.origin, + session: sessionId, + users: shareLinkRequest.users, + expired_time: shareLinkRequest.expiredTime, + action_permission: shareLinkRequest.actionPerm, + }) + ) + ); + } + }; + + const searchUsers = (query: string) => { + if (isK8sEnvironment.value) { + mittBus.emit('share-user', { + type: 'TERMINAL_GET_SHARE_USER', + query, + }); + } else { + const { socket, terminalId } = storeToRefs(connectionStore); + + if (!socket?.value || !terminalId?.value) { + return; + } + + socket.value.send( + formatMessage(terminalId.value, FORMATTER_MESSAGE_TYPE.TERMINAL_GET_SHARE_USER, JSON.stringify({ query })) + ); + } + }; + + const removeShareUser = (user: OnlineUser) => { + if (isK8sEnvironment.value) { + const currentNode = getCurrentK8sNode(); + const sessionId = currentNode?.sessionIdMap?.get(currentActiveTab.value); + + if (!sessionId) return; + + mittBus.emit('remove-share-user', { + sessionId, + userMeta: user, + type: 'TERMINAL_SHARE_USER_REMOVE', + }); + } else { + if (!connectionStore.sessionId) return; + + mittBus.emit('remove-share-user', { + sessionId: connectionStore.sessionId, + userMeta: user, + type: 'remove', + }); + } + }; + + const copyShareURL = () => { + const currentShareId = shareInfo.value.shareId; + const currentShareCode = shareInfo.value.shareCode; + const currentEnableShare = shareInfo.value.enableShare; + + if (!currentShareId || !currentEnableShare) return; + + const url = `${BASE_URL}/luna/share/${currentShareId}`; + const linkTitle = t('LinkAddr'); + const codeTitle = t('VerifyCode'); + const text = `${linkTitle}: ${url}\n${codeTitle}: ${currentShareCode}`; + + writeText(text) + .then(() => { + message.success(t('CopyShareURLSuccess')); + }) + .catch(e => { + message.error(`Copy Error for ${e}`); + }); + }; + + const resetShareState = () => { + if (isK8sEnvironment.value) { + const currentNode = getCurrentK8sNode(); + const currentTabId = currentActiveTab.value; + + if (currentNode && currentTabId) { + if (currentNode.shareIdMap && currentNode.shareIdMap.has(currentTabId)) { + currentNode.shareIdMap.delete(currentTabId); + } + if (currentNode.shareCodeMap && currentNode.shareCodeMap.has(currentTabId)) { + currentNode.shareCodeMap.delete(currentTabId); + } + + // 更新节点以触发响应式 + treeStore.setK8sIdMap(currentTabId, { ...currentNode }); + } + + paramsStore.setShareId(''); + paramsStore.setShareCode(''); + } else { + connectionStore.updateConnectionState({ + shareId: '', + shareCode: '', + }); + } + }; + + return { + shareInfo, + onlineUsers, + userOptions, + connectionInfo, + isK8sEnvironment, + + searchUsers, + copyShareURL, + createShareLink, + removeShareUser, + resetShareState, + }; +} diff --git a/ui/src/hooks/useTerminalEvents.ts b/ui/src/hooks/useTerminalEvents.ts new file mode 100644 index 000000000..7f96185b7 --- /dev/null +++ b/ui/src/hooks/useTerminalEvents.ts @@ -0,0 +1,145 @@ +import { onUnmounted } from 'vue'; + +import type { LunaEventType } from '@/utils/lunaBus'; +import type { TerminalSessionInfo } from '@/types/modules/postmessage.type'; + +import { useTerminalContext } from '@/context/terminalContext'; + +/** + * 替代原来的 sendLunaEvent 和 eventBus,同时提供 Luna 通信功能 + */ +export const useTerminalEvents = () => { + const context = useTerminalContext(); + + /** + * 发送 Luna 事件 + * @param {string} event - 事件名称 + * @param {any} data - 事件数据 + */ + const sendLunaEvent = (event: string, data: any) => { + context.sendLunaEvent(event, data); + }; + + /** + * 监听终端会话事件 + * @param {Function} callback - 事件处理函数 + */ + const onTerminalSession = (callback: (info: TerminalSessionInfo) => void) => { + context.eventBus.on('terminal-session', callback); + + onUnmounted(() => { + context.eventBus.off('terminal-session', callback); + }); + + return () => context.eventBus.off('terminal-session', callback); + }; + + /** + * 监听终端连接事件 + * @param {Function} callback - 事件处理函数 + */ + const onTerminalConnect = (callback: (data: { id: string }) => void) => { + context.eventBus.on('terminal-connect', callback); + + onUnmounted(() => { + context.eventBus.off('terminal-connect', callback); + }); + + return () => context.eventBus.off('terminal-connect', callback); + }; + + /** + * 监听 Luna 事件 - 用于组件间通信 + * @param {Function} callback - 事件处理函数 + */ + const onLunaEvent = (callback: (data: { event: string; data: any }) => void) => { + context.eventBus.on('luna-event', callback); + + onUnmounted(() => { + context.eventBus.off('luna-event', callback); + }); + + return () => context.eventBus.off('luna-event', callback); + }; + + /** + * 触发终端会话事件 + * @param {TerminalSessionInfo} info - 终端会话信息 + */ + const emitTerminalSession = (info: TerminalSessionInfo) => { + context.eventBus.emit('terminal-session', info); + }; + + const sendMittEvent = (event: string, data?: any) => { + context.sendMittEvent(event, data || {}); + }; + + const onMittEvent = (event: string, callback: (data: any) => void) => { + const unsubscribe = context.onMittEvent(event, callback); + + onUnmounted(() => { + unsubscribe(); + }); + + return unsubscribe; + }; + + /** + * 触发终端连接事件 + * @param {string} id - 终端 ID + */ + const emitTerminalConnect = (id: string) => { + context.eventBus.emit('terminal-connect', { id }); + }; + + /** + * 发送消息到 Luna(父窗口) + * @param name - 事件名称 + * @param data - 事件数据 + */ + const sendToLuna = (name: K, data: any) => { + context.lunaCommunicator.sendLuna(name, data); + }; + + /** + * 监听来自 Luna 的消息 + * @param type - 事件类型 + * @param handler - 事件处理函数 + * @returns + */ + const onLunaMessage = (type: K, handler: (data: any) => void) => { + context.lunaCommunicator.onLuna(type, handler); + + onUnmounted(() => { + context.lunaCommunicator.offLuna(type, handler); + }); + + return () => context.lunaCommunicator.offLuna(type, handler); + }; + + /** + * 监听一次性 Luna 消息 + * @param type - 事件类型 + * @param handler - 事件处理函数 + */ + const onLunaMessageOnce = (type: K, handler: (data: any) => void) => { + context.lunaCommunicator.once(type, handler); + }; + + return { + sendLunaEvent, + emitTerminalSession, + emitTerminalConnect, + onTerminalSession, + onTerminalConnect, + onLunaEvent, + sendMittEvent, + onMittEvent, + + sendToLuna, + onLunaMessage, + onLunaMessageOnce, + + lunaCommunicator: context.lunaCommunicator, + }; +}; diff --git a/ui/src/hooks/useTerminalSocket.ts b/ui/src/hooks/useTerminalSocket.ts new file mode 100644 index 000000000..bc588895d --- /dev/null +++ b/ui/src/hooks/useTerminalSocket.ts @@ -0,0 +1,639 @@ +import type { ConfigProviderProps } from 'naive-ui'; +import type { Sentry } from 'nora-zmodemjs/src/zmodem_browser'; + +import { useI18n } from 'vue-i18n'; +import xtermTheme from 'xterm-theme'; +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebglAddon } from '@xterm/addon-webgl'; +import { SearchAddon } from '@xterm/addon-search'; +import { createDiscreteApi, darkTheme } from 'naive-ui'; +import { readText, writeText } from 'clipboard-polyfill'; +import { useDebounceFn, useWebSocket, useWindowSize } from '@vueuse/core'; +import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; + +import type { SettingConfig } from '@/types/modules/config.type'; +import type { OnlineUser, ShareUserOptions } from '@/types/modules/user.type'; + +import { lunaCommunicator } from '@/utils/lunaBus'; +import { getDefaultTerminalConfig } from '@/utils/guard'; +import { defaultTheme, MaxTimeout } from '@/utils/config'; +import { useConnectionStore } from '@/store/modules/useConnection'; +import { useTerminalSettingsStore } from '@/store/modules/terminalSettings'; +import { formatMessage, preprocessInput, writeBufferToTerminal } from '@/utils'; +import { + FORMATTER_MESSAGE_TYPE, + LUNA_MESSAGE_TYPE, + MESSAGE_TYPE, + ZMODEM_ACTION_TYPE, +} from '@/types/modules/message.type'; + +import { useZmodem } from './useZmodem'; +import { generateWsURL, updateIcon } from './helper'; +import { useTerminalEvents } from './useTerminalEvents'; +import { getXTerminalLineContent } from './helper/index'; + +/** + * @description 判断 WebSocket 是否关闭 + * @param {WebSocket} socket + * @returns {boolean} + */ +const isSocketClosing = (socket: WebSocket) => { + return socket.readyState === WebSocket.CLOSING || socket.readyState === WebSocket.CLOSED; +}; + +/** + * @description 获取终端主题 + * @param {string} themeName + */ +export const terminalTheme = (themeName: string) => { + if (!xtermTheme[themeName]) { + return defaultTheme; + } + return xtermTheme[themeName]; +}; + +export const useTerminalSocket = () => { + let sentry: Sentry | null = null; + + const { t } = useI18n(); + const { createSentry } = useZmodem(); + const { width, height } = useWindowSize(); + + const { sendLunaEvent, emitTerminalConnect, emitTerminalSession, sendMittEvent } = useTerminalEvents(); + + const containerRef = shallowRef(); + + const shareId = ref(''); + const shareCode = ref(''); + const sessionId = ref(''); + const terminalId = ref(''); + const selectionText = ref(''); + const zmodemTransferStatus = ref(true); + + const lastSendTime = ref(new Date()); + const lastReceiveTime = ref(new Date()); + + const onlineUsers = ref([]); + const userOptions = ref([]); + + const terminalRef = ref(null); + const socketRef = ref(null); + const featureSetting = ref>({}); + const pingInterval = ref | null>(null); + const warningInterval = ref | null>(null); + + const connectionStore = useConnectionStore(); + const defaultTerminalCfg = getDefaultTerminalConfig(); + const terminalSettingsStore = useTerminalSettingsStore(); + + const fitAddon = new FitAddon(); + const webglAddon = new WebglAddon(); + const searchAddon = new SearchAddon(); + + // 处理 webgl context 超过浏览器最大上下文时的处理 + // https://github.com/xtermjs/xterm.js/tree/master/addons/addon-webgl + webglAddon.onContextLoss(() => { + webglAddon.dispose(); + }); + + const configProviderPropsRef = computed(() => ({ + theme: darkTheme, + })); + + const autoTerminalFit = watch([width, height], ([_newWidth, _newHeight]: [number, number]) => { + if (!terminalRef.value || !fitAddon) return; + + nextTick(() => { + fitAddon.fit(); + }); + }); + + const { message } = createDiscreteApi(['message'], { + configProviderProps: configProviderPropsRef, + }); + + const debouncedResize = useDebounceFn(({ cols, rows }) => { + if (!fitAddon || !socketRef.value) return; + + fitAddon.fit(); + + const resizeData = JSON.stringify({ cols, rows }); + socketRef.value.send(formatMessage(terminalId.value, FORMATTER_MESSAGE_TYPE.TERMINAL_RESIZE, resizeData)); + }, 200); + const debouncedSendLunaKey = useDebounceFn((key: string) => { + switch (key) { + case 'ArrowRight': + lunaCommunicator.sendLuna(LUNA_MESSAGE_TYPE.KEYEVENT, 'alt+shift+right'); + break; + case 'ArrowLeft': + lunaCommunicator.sendLuna(LUNA_MESSAGE_TYPE.KEYEVENT, 'alt+shift+left'); + break; + } + }, 500); + + /** + * @description 分发 Socket 消息 + */ + + let lastMessage: string; + + function showInfoOnce(content: string) { + if (lastMessage === content) return; + message.info(content); + lastMessage = content; + } + + const dispatch = (socketData: string) => { + if (!socketData || !socketRef.value || !terminalRef.value) return; + + const parsedMessageData = JSON.parse(socketData); + + switch (parsedMessageData.type) { + case MESSAGE_TYPE.CLOSE: { + connectionStore.updateConnectionState({ + enableShare: false, + onlineUsers: [], + }); + + socketRef.value.close(); + sendLunaEvent(LUNA_MESSAGE_TYPE.CLOSE, ''); + break; + } + case MESSAGE_TYPE.ERROR: { + terminalRef.value!.write(parsedMessageData.err); + sendLunaEvent(LUNA_MESSAGE_TYPE.TERMINAL_ERROR, ''); + break; + } + case MESSAGE_TYPE.PING: { + break; + } + case MESSAGE_TYPE.CONNECT: { + terminalId.value = parsedMessageData.id; + emitTerminalConnect(terminalId.value); + + connectionStore.setConnectionState({ + socket: socketRef.value!, + terminal: terminalRef.value!, + terminalId: parsedMessageData.id, + }); + + const terminalData = { + cols: terminalRef.value!.cols, + rows: terminalRef.value!.rows, + code: connectionStore.shareCode, + }; + + const info = JSON.parse(parsedMessageData.data); + + featureSetting.value = info.setting; + + if (info.asset?.name) { + connectionStore.setConnectionState({ + assetName: info.asset.name, + }); + } + + updateIcon(info.setting); + + socketRef.value!.send( + formatMessage(terminalId.value, FORMATTER_MESSAGE_TYPE.TERMINAL_INIT, JSON.stringify(terminalData)) + ); + + break; + } + case MESSAGE_TYPE.TERMINAL_ERROR: { + terminalRef.value!.write(parsedMessageData.err); + break; + } + case MESSAGE_TYPE.MESSAGE_NOTIFY: { + const eventName = JSON.parse(parsedMessageData.data).event_name; + + if (eventName === 'sync_user_preference') { + message.success(t('ThemeSyncSuccessful')); + } + + break; + } + case MESSAGE_TYPE.TERMINAL_SHARE: { + const data = JSON.parse(parsedMessageData.data); + + shareId.value = data.share_id; + shareCode.value = data.code; + + connectionStore.updateConnectionState({ + shareId: data.share_id, + shareCode: data.code, + }); + + break; + } + case MESSAGE_TYPE.TERMINAL_ACTION: { + const actionType = parsedMessageData.data; + + switch (actionType) { + case ZMODEM_ACTION_TYPE.ZMODEM_START: { + zmodemTransferStatus.value = true; + break; + } + case ZMODEM_ACTION_TYPE.ZMODEM_END: { + terminalRef.value!.write('\r\n'); + break; + } + default: { + zmodemTransferStatus.value = false; + } + } + + break; + } + case MESSAGE_TYPE.TERMINAL_SESSION: { + const sessionInfo = JSON.parse(parsedMessageData.data); + const sessionDetail = sessionInfo.session; + + emitTerminalSession(sessionInfo); + + const share = sessionInfo?.permission?.actions?.includes('share'); + + if (sessionInfo.backspaceAsCtrlH) { + const value = sessionInfo.backspaceAsCtrlH ? '1' : '0'; + + terminalSettingsStore.setDefaultTerminalConfig('backspaceAsCtrlH', value); + } + + if (sessionInfo.ctrlCAsCtrlZ) { + const value = sessionInfo.ctrlCAsCtrlZ ? '1' : '0'; + + terminalSettingsStore.setDefaultTerminalConfig('ctrlCAsCtrlZ', value); + } + + if (sessionInfo.themeName) { + const theme = terminalTheme(sessionInfo.themeName); + + nextTick(() => { + terminalRef.value!.options.theme = theme; + }); + } + + if (featureSetting.value.SECURITY_SESSION_SHARE && share) { + connectionStore.updateConnectionState({ + enableShare: true, + }); + } + + sessionId.value = sessionDetail.id; + connectionStore.updateConnectionState({ + sessionId: sessionDetail.id, + }); + terminalSettingsStore.setDefaultTerminalConfig('theme', sessionInfo.themeName); + + break; + } + case MESSAGE_TYPE.TERMINAL_SHARE_JOIN: { + const data = JSON.parse(parsedMessageData.data); + + // data 中如果 primary 为 true 则表示是当前用户 + onlineUsers.value.push(data); + + connectionStore.updateConnectionState({ + onlineUsers: onlineUsers.value, + }); + sendLunaEvent(LUNA_MESSAGE_TYPE.SHARE_USER_ADD, JSON.stringify({ ...data, sessionId: sessionId.value })); + + if (!data.primary) { + message.info(`${data.user} ${t('JoinShare')}`); + } + + break; + } + case MESSAGE_TYPE.TERMINAL_PERM_VALID: { + clearInterval(warningInterval.value!); + message.info(`${t('PermissionValid')}`); + break; + } + case MESSAGE_TYPE.TERMINAL_SHARE_LEAVE: { + const data: OnlineUser = JSON.parse(parsedMessageData.data); + + sendLunaEvent(LUNA_MESSAGE_TYPE.SHARE_USER_LEAVE, parsedMessageData.data); + + const index = onlineUsers.value.findIndex(item => item.user_id === data.user_id && !item.primary); + + if (index !== -1) { + onlineUsers.value.splice(index, 1); + + connectionStore.updateConnectionState({ + onlineUsers: onlineUsers.value, + }); + + message.info(`${data.user} ${t('LeaveShare')}`); + } + break; + } + case MESSAGE_TYPE.TERMINAL_PERM_EXPIRED: { + const data = JSON.parse(parsedMessageData.data); + const warningMsg = `${t('PermissionExpired')}: ${data.detail}`; + + message.warning(warningMsg); + + if (warningInterval.value) { + clearInterval(warningInterval.value); + } + warningInterval.value = setInterval(() => { + message.warning(warningMsg); + }, 1000 * 60); + break; + } + case MESSAGE_TYPE.TERMINAL_SESSION_PAUSE: { + const data = JSON.parse(parsedMessageData.data); + const content = `${data.user} ${t('PauseSession')}`; + showInfoOnce(content); + break; + } + case MESSAGE_TYPE.TERMINAL_SESSION_RESUME: { + const data = JSON.parse(parsedMessageData.data); + const content = `${data.user} ${t('ResumeSession')}`; + showInfoOnce(content); + break; + } + case MESSAGE_TYPE.TERMINAL_GET_SHARE_USER: { + userOptions.value = JSON.parse(parsedMessageData.data); + + connectionStore.updateConnectionState({ + userOptions: userOptions.value, + }); + + break; + } + case MESSAGE_TYPE.TERMINAL_SHARE_USER_REMOVE: { + message.info(t('RemoveShareUser')); + socketRef.value!.close(); + break; + } + } + }; + + /** + * @description 终端 zmodem 处理二进制消息 + * @param {MessageEvent} socketMessage + */ + const handleBinaryMessage = (socketMessage: MessageEvent) => { + if (!terminalRef.value || !sentry) { + return; + } + + if (zmodemTransferStatus.value) { + try { + sentry.consume(socketMessage.data); + } catch (_e) { + if (sentry.get_confirmed_session()) { + sentry.get_confirmed_session()?.abort(); + message.error('File transfer error, file transfer interrupted'); + } + } + } else { + writeBufferToTerminal(true, false, terminalRef.value, socketMessage.data); + } + }; + + /** + * @description 监听 socket 事件 + */ + const listenSocketEvent = () => { + if (!socketRef.value) { + return; + } + + sentry = createSentry(terminalRef.value!, socketRef.value!, lastSendTime); + + socketRef.value.onopen = () => { + if (pingInterval.value) clearInterval(pingInterval.value); + + pingInterval.value = setInterval(() => { + if (isSocketClosing(socketRef.value!)) { + return clearInterval(pingInterval.value!); + } + + const currentDate = new Date(); + + if (lastReceiveTime.value.getTime() - currentDate.getTime() > MaxTimeout) { + console.error('More than 30 seconds do not receive data'); + } + + const pingTimeout = currentDate.getTime() - lastSendTime.value.getTime() - MaxTimeout; + + if (pingTimeout < 0) { + return; + } + + socketRef.value!.send(formatMessage('', FORMATTER_MESSAGE_TYPE.PING, '')); + }, 25 * 1000); + }; + socketRef.value.onclose = () => { + if (!terminalRef.value) return; + + terminalRef.value.write(`\r\n`); + terminalRef.value.write(`\x1B[31m${t('WebSocketClosed')}\x1B[0m`); + }; + const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + socketRef.value.onmessage = async (message: MessageEvent) => { + await sleep(1); // time sleep 0.001, avoid long write and block websocket send + lastReceiveTime.value = new Date(); + if (typeof message.data === 'object') { + handleBinaryMessage(message); + } else { + dispatch(message.data); + } + }; + }; + + /** + * @description 监听挂载节点事件 + */ + const listenElEvent = () => { + if (!terminalRef.value) { + return; + } + + containerRef.value!.addEventListener('click', () => { + sendLunaEvent(LUNA_MESSAGE_TYPE.CLICK, ''); + }); + containerRef.value!.addEventListener('mouseenter', () => { + fitAddon.fit(); + terminalRef.value!.focus(); + }); + containerRef.value!.addEventListener('contextmenu', async (e: MouseEvent) => { + // 只有在开启右键快速复制时才允许粘贴 + // TODO 对于 terminal 的 contextmenu 后续需要进行封装 + if (e.ctrlKey || terminalSettingsStore.quickPaste !== '1') return; + + e.preventDefault(); + + let text: string = ''; + + try { + text = await readText(); + } catch (_e) { + if (selectionText.value) { + text = selectionText.value; + } + } + + if (!text) { + return; + } + + if (isSocketClosing(socketRef.value!)) { + return message.error(t('WebSocket connection is closed, please refresh the page')); + } + + socketRef.value!.send(formatMessage(terminalId.value, FORMATTER_MESSAGE_TYPE.TERMINAL_DATA, text)); + }); + containerRef.value!.addEventListener('mouseleave', () => { + terminalRef.value?.blur(); + + sendLunaEvent(LUNA_MESSAGE_TYPE.TERMINAL_CONTENT_RESPONSE, { + content: getXTerminalLineContent(10, terminalRef.value!), + sessionId: sessionId.value, + terminalId: terminalId.value, + }); + }); + + // 监听 ctrl + f 或 command + f 快捷键 + containerRef.value!.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.ctrlKey || e.metaKey) { + if (e.key === 'f') { + sendMittEvent('open-search'); + e.preventDefault(); + } + } + }); + }; + + /** + * @description 监听 terminalRef 事件 + */ + const listenTerminalRefEvent = () => { + if (!terminalRef.value || !socketRef.value) { + return; + } + + terminalRef.value.onData((data: string) => { + lastSendTime.value = new Date(); + + if (isSocketClosing(socketRef.value!)) { + return; + } + + const processedData = preprocessInput(data, terminalSettingsStore.getConfig); + socketRef.value!.send(formatMessage('', FORMATTER_MESSAGE_TYPE.TERMINAL_DATA, processedData)); + lunaCommunicator.sendLuna(LUNA_MESSAGE_TYPE.INPUT_ACTIVE, ''); + }); + terminalRef.value.onResize(({ cols, rows }) => debouncedResize({ cols, rows })); + terminalRef.value.onSelectionChange(async () => { + selectionText.value = terminalRef.value!.getSelection() || ''; + + if (!selectionText.value) { + return; + } + + await writeText(selectionText.value); + }); + terminalRef.value.attachCustomKeyEventHandler((e: KeyboardEvent) => { + if (e.altKey && e.shiftKey && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) { + debouncedSendLunaKey(e.key); + return false; + } + + // 允许复制操作而不是发送中断信号 + if (e.ctrlKey && e.key === 'c' && terminalRef.value?.hasSelection()) { + return false; + } + + // 阻止默认的粘贴行为,粘贴数据通过 socket 写入 + return !(e.ctrlKey && e.key === 'v'); + }); + }; + + /** + * @description 创建终端 + */ + const createTerminal = () => { + const terminal: Terminal = new Terminal({ + // 基础配置 + fontSize: defaultTerminalCfg.fontSize, + fontFamily: defaultTerminalCfg.fontFamily, + lineHeight: defaultTerminalCfg.lineHeight, + + // 光标配置 + cursorBlink: true, + cursorStyle: 'block', + rightClickSelectsWord: true, + + // 滚动配置 + scrollback: 5000, + scrollOnUserInput: true, + + // 主题配置 + theme: terminalTheme(defaultTerminalCfg.themeName), + + // 性能优化 + allowProposedApi: true, + customGlyphs: true, + }); + + terminal.loadAddon(fitAddon); + terminal.loadAddon(webglAddon); + terminal.loadAddon(searchAddon); + + terminalRef.value = terminal; + }; + + /** + * @description 创建 WebSocket 连接 + */ + const createWebSocket = () => { + const url = generateWsURL(); + + const { ws } = useWebSocket(url, { + protocols: ['JMS-KOKO'], + autoReconnect: { + retries: 5, + delay: 3000, + }, + }); + + if (!ws.value) { + return message.error(t('FailedCreateConnection')); + } + + ws.value.binaryType = 'arraybuffer'; + + socketRef.value = ws.value; + }; + + onMounted(() => { + if (!containerRef.value) return; + + createTerminal(); + createWebSocket(); + + nextTick(() => { + listenSocketEvent(); + listenTerminalRefEvent(); + listenElEvent(); + + terminalRef.value?.open(containerRef.value!); + + fitAddon.fit(); + }); + }); + + onUnmounted(() => { + autoTerminalFit(); + }); + + return { + searchAddon, + containerRef, + }; +}; diff --git a/ui/src/hooks/useZmodem.ts b/ui/src/hooks/useZmodem.ts new file mode 100644 index 000000000..72395c2a4 --- /dev/null +++ b/ui/src/hooks/useZmodem.ts @@ -0,0 +1,312 @@ +import type { Ref } from 'vue'; +import type { Terminal } from '@xterm/xterm'; +import type { ConfigProviderProps, UploadFileInfo } from 'naive-ui'; +import type { Detection, Transfer, ZmodemSession } from 'nora-zmodemjs/src/zmodem_browser'; + +import { useI18n } from 'vue-i18n'; +import { computed, h, ref } from 'vue'; +import prettyBytes from 'pretty-bytes'; +import { createDiscreteApi, darkTheme } from 'naive-ui'; +import ZmodemBrowser from 'nora-zmodemjs/src/zmodem_browser'; + +import { MAX_TRANSFER_SIZE } from '@/utils/config'; +import ZmodemUpload from '@/components/ZmodemUpload/index.vue'; + +export const useZmodem = () => { + const { t } = useI18n(); + + const fileInfo = ref(null); + const sentryRef = ref(null); + const activeSession = ref(null); + + // 上传进度跟踪 + let lastPercent = -1; + let messageShown = false; + + const configProviderPropsRef = computed(() => ({ + theme: darkTheme, + })); + + const { message, modal } = createDiscreteApi(['message', 'modal'], { + configProviderProps: configProviderPropsRef, + }); + + /** + * 清理 session 状态 + */ + const cleanupSession = () => { + if (activeSession.value) { + try { + activeSession.value.close(); + } catch (e) { + console.warn('Error cleaning up session:', e); + } + activeSession.value = null; + } + // 重置进度跟踪变量 + lastPercent = -1; + messageShown = false; + }; + + /** + * 终端进度条 + * @param {Transfer} transfer + * @param {Terminal} terminal + */ + const terminalProgress = (transfer: Transfer, terminal: Terminal) => { + const detail = transfer.get_details(); + const offset = transfer.get_offset(); + + const name = detail.name; + const total = detail.size; + + let percent; + + if (total === 0 || total === offset) { + percent = 100; + } else { + percent = Math.round((offset / total) * 100); + } + + const msg = `${t('Download')} ${name}: ${prettyBytes(total)} ${percent}% `; + + terminal.write(`\r${msg}`); + }; + + /** + * 上传文件 + * @param {ZmodemSession} session + * @param {Terminal} terminal + */ + const handleUpload = (session: ZmodemSession, terminal: Terminal) => { + if (!fileInfo.value || !session) { + return; + } + + // 重置进度跟踪变量 + lastPercent = -1; + messageShown = false; + + const { size } = fileInfo.value as File; + + if (size >= MAX_TRANSFER_SIZE) { + const msg = `${t('ExceedTransferSize')}: ${prettyBytes(MAX_TRANSFER_SIZE)}`; + message.error(msg); + cleanupSession(); + return; + } + + ZmodemBrowser.Browser.send_files(session, [fileInfo.value], { + on_offer_response: (_obj: any, transfer: Transfer) => { + if (transfer) { + const detail = transfer.get_details(); + const name = detail.name; + const total = detail.size; + + transfer.on('send_progress', (percent: number) => { + percent = Math.round(percent); + + if (percent !== lastPercent) { + let progressBar = ''; + const progressLength = Math.floor(percent / 2); + + for (let i = 0; i < progressLength; i++) { + progressBar += '='; + } + for (let i = progressLength; i < 50; i++) { + progressBar += ' '; + } + + const msg = `${t('Upload')} ${name}: ${prettyBytes(total)} ${percent}% [${progressBar}]`; + + if (percent === 100 && !messageShown) { + message.info(t('UploadEnd'), { duration: 5000 }); + messageShown = true; + } + + terminal.write(`\r${msg}`); + + lastPercent = percent; + } + }); + } + }, + on_file_complete: (obj: any) => { + message.success(`${t('EndFileTransfer')}: ${t('UploadSuccess')} ${obj.name}`, { + duration: 2000, + }); + }, + }) + .then(() => { + cleanupSession(); + }) + .catch((err: Error) => { + message.error(err.message); + cleanupSession(); + activeSession.value?.abort(); + }); + }; + + /** + * 获取上传文件信息 + * @param {UploadFileInfo} options.fileList + */ + const handleFileChange = (options: { fileList: UploadFileInfo[] }) => { + fileInfo.value = options.fileList[0].file as File; + }; + + /** + * 处理发送会话 (rz 命令) + * @param {ZmodemSession} session + * @param {Terminal} terminal + */ + const handleSendSession = (session: ZmodemSession, terminal: Terminal) => { + activeSession.value = session; + + session.on('session_end', () => { + terminal.write('\r\n'); + cleanupSession(); + }); + + // 打开上传对话框 + modal.create({ + preset: 'dialog', + title: t('UploadTitle'), + showIcon: false, + closable: false, + closeOnEsc: false, + maskClosable: false, + positiveText: t('Upload'), + negativeText: t('Cancel'), + negativeButtonProps: { + type: 'tertiary', + }, + positiveButtonProps: { + type: 'tertiary', + }, + onPositiveClick: async () => { + if (!fileInfo.value) { + message.error(t('MustSelectOneFile')); + return false; + } + + handleUpload(session, terminal); + return true; + }, + onNegativeClick: () => { + cleanupSession(); + + terminal.write('\r\n'); + return true; + }, + content: () => { + return h(ZmodemUpload, { + t, + onFileChange: handleFileChange, + }); + }, + }); + }; + + /** + * 处理接收会话 (sz 命令) + * @param {ZmodemSession} session + * @param {Terminal} terminal + */ + const handleReceiveSession = (session: ZmodemSession, terminal: Terminal) => { + activeSession.value = session; + + session.on('offer', (transfer: Transfer) => { + const buffer: Uint8Array[] = []; + const detail = transfer.get_details(); + + // 文件大小限制 + if (detail.size >= MAX_TRANSFER_SIZE) { + const msg = `${t('ExceedTransferSize')}: ${prettyBytes(MAX_TRANSFER_SIZE)}`; + message.info(msg); + transfer.skip(); + return; + } + + // 接收文件数据 + transfer.on('input', (payload: Uint8Array) => { + terminalProgress(transfer, terminal); + buffer.push(new Uint8Array(payload)); + }); + + // 保存文件 + transfer + .accept() + .then(() => { + ZmodemBrowser.Browser.save_to_disk(buffer, detail.name); + message.success(`${t('DownloadSuccess')}: ${detail.name}`); + terminal.write('\r\n'); + }) + .catch((e: Error) => { + message.error(`Error: ${e}`); + }); + }); + + session.on('session_end', () => { + terminal.write('\r\n'); + cleanupSession(); + }); + + session.start(); + }; + + // Sentry 在 Zmodem 中用于监控终端数据流、识别 ZMODEM 协议信号、启动文件传输会话 + const createSentry = (terminal: Terminal, socket: WebSocket, lastSendTime: Ref) => { + const sentry = new ZmodemBrowser.Sentry({ + to_terminal: (octets: string) => { + try { + // 只有在没有确认的 session 时,普通的终端数据才会被写入终端显示 + if (sentryRef.value && !sentryRef.value.get_confirmed_session()) { + terminal.write(octets); + } + } catch (_e) { + message.error(t('Failed to write to terminal')); + } + }, + sender: (octets: Uint8Array) => { + try { + lastSendTime.value = new Date(); + socket.send(new Uint8Array(octets)); + } catch (_e) { + console.warn('Failed to send octets via WebSocket'); + } + }, + on_retract: () => {}, + on_detect: (detection: Detection) => { + try { + // 直接确认检测到的 ZMODEM 会话 + const session = detection.confirm(); + + terminal.write('\r\n'); + + // 根据会话类型处理 + if (session.type === 'send') { + // rz 命令 - 上传 + handleSendSession(session, terminal); + } else { + // sz 命令 - 下载 + handleReceiveSession(session, terminal); + } + } catch (error) { + console.warn('Error in ZMODEM detection:', error); + cleanupSession(); + activeSession.value?.abort(); + } + }, + }); + + sentryRef.value = sentry; + + return sentry; + }; + + return { + createSentry, + cleanupSession, + }; +}; diff --git a/ui/src/i18n/date.js b/ui/src/i18n/date.js deleted file mode 100644 index f88891951..000000000 --- a/ui/src/i18n/date.js +++ /dev/null @@ -1,44 +0,0 @@ -export default { - 'en': { - short: { - year: 'numeric', month: 'short', day: 'numeric' - }, - medium: { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', second: '2-digit', - hourCycle: 'h23', hour12: false - }, - long: { - year: 'numeric', month: 'short', day: 'numeric', - hour: 'numeric', minute: 'numeric' - } - }, - 'cn': { - short: { - year: 'numeric', month: 'short', day: 'numeric' - }, - medium: { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', second: '2-digit', - hourCycle: 'h23', hour12: false - }, - long: { - year: 'numeric', month: 'short', day: 'numeric', - hour: 'numeric', minute: 'numeric', hour12: true - } - }, - 'ja': { - short: { - year: 'numeric', month: 'short', day: 'numeric' - }, - medium: { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', second: '2-digit', - hourCycle: 'h23', hour12: false - }, - long: { - year: 'numeric', month: 'short', day: 'numeric', - hour: 'numeric', minute: 'numeric', hour12: true - } - } -} diff --git a/ui/src/i18n/i18n.js b/ui/src/i18n/i18n.js deleted file mode 100644 index 4afeb31b8..000000000 --- a/ui/src/i18n/i18n.js +++ /dev/null @@ -1,19 +0,0 @@ -// i18n.js -import Vue from 'vue' -import locale from 'element-ui/lib/locale' -import VueI18n from 'vue-i18n' -import messages from './langs' -import date from './date' - -Vue.use(VueI18n) -const i18n = new VueI18n({ - locale: (localStorage.lang || 'zh-hans') === 'zh-hans' ? 'cn' : localStorage.lang, - fallbackLocale: 'en', - silentFallbackWarn: true, - silentTranslationWarn: true, - dateTimeFormats: date, - messages -}) -locale.i18n((key, value) => i18n.t(key, value)) // 重点: 为了实现element插件的多语言切换 - -export default i18n diff --git a/ui/src/i18n/langs/cn.json b/ui/src/i18n/langs/cn.json deleted file mode 100644 index 828a8f8b0..000000000 --- a/ui/src/i18n/langs/cn.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "Terminal": { - "UploadSuccess": "上传成功", - "MustSelectOneFile": "必须选择一个文件", - "MustOneFile": "只能选择一个文件", - "DownloadSuccess": "下载成功", - "Download": "下载", - "Upload": "上传", - "Cancel": "取消", - "UploadTitle": "上传文件", - "UploadTips": "将文件拖到此处,或点击上传", - "Share": "分享", - "CopyShareURLSuccess": "复制分享地址成功", - "ThemeConfig": "主题", - "OnlineUsers": "在线人员", - "User": "用户", - "VerifyCode": "验证码", - "LinkAddr": "链接地址", - "ExpiredTime": "有效期", - "SelectAction": "请选择", - "CreateSuccess": "创建成功", - "CreateLink": "创建分享链接", - "CopyLink": "复制链接及验证码", - "NoLink": "无地址", - "ConfirmBtn": "确定", - "Theme": "主题", - "SelectTheme": "请选择主题", - "ThemeColors": "主题颜色", - "ExceedTransferSize": "超过最大传输大小", - "WaitFileTransfer": "等待文件传输结束", - "EndFileTransfer": "文件传输结束" - }, - "Message": { - "InputVerifyCode": "请输入验证码" - } -} diff --git a/ui/src/i18n/langs/en.json b/ui/src/i18n/langs/en.json deleted file mode 100644 index a1c243e35..000000000 --- a/ui/src/i18n/langs/en.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "Terminal": { - "UploadSuccess": "Upload success", - "MustSelectOneFile": "Must select one file", - "MustOneFile": "Only support to select one file", - "DownloadSuccess": "Download success", - "Download": "Download", - "Upload": "Upload", - "Cancel": "Cancel", - "UploadTitle": "file upload", - "UploadTips": "Drag file here or click to upload", - "Share": "Share", - "CopyShareURLSuccess": "Copy Share URL Success", - "ThemeConfig": "Theme", - "OnlineUsers": "Online Users", - "User": "User", - "VerifyCode": "Verify Code", - "LinkAddr": "Link", - "ExpiredTime": "Expired", - "SelectAction": "Select", - "CreateSuccess": "Success", - "CreateLink": "Create Share Link", - "CopyLink": "Copy Link Address and Code", - "NoLink": "No Link", - "ConfirmBtn": "Confirm", - "Theme": "Theme", - "SelectTheme": "Select Theme", - "ThemeColors": "Theme Colors", - "ExceedTransferSize": "exceed max transfer size", - "WaitFileTransfer": "Wait file transfer to finish", - "EndFileTransfer": "File transfer end" - }, - "Message": { - "InputVerifyCode": "Input Verify Code" - } -} diff --git a/ui/src/i18n/langs/index.js b/ui/src/i18n/langs/index.js deleted file mode 100644 index 6f476487f..000000000 --- a/ui/src/i18n/langs/index.js +++ /dev/null @@ -1,21 +0,0 @@ -import zhLocale from 'element-ui/lib/locale/lang/zh-CN' -import enLocale from 'element-ui/lib/locale/lang/en' -import jaLocale from 'element-ui/lib/locale/lang/ja' -import zh from './cn.json' -import en from './en.json' -import ja from './ja.json' - -export default { - cn: { - ...zhLocale, - ...zh - }, - en: { - ...enLocale, - ...en - }, - ja: { - ...jaLocale, - ...ja - } -} diff --git a/ui/src/i18n/langs/ja.json b/ui/src/i18n/langs/ja.json deleted file mode 100644 index bfd8a9153..000000000 --- a/ui/src/i18n/langs/ja.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "Terminal": { - "UploadSuccess": "アップロード成功", - "MustSelectOneFile": "ファイルを選択する必要があります", - "MustOneFile": "ファイルを1つだけ選択できます", - "DownloadSuccess": "ダウンロードに成功しました", - "Download": "ダウンロード", - "Upload": "アップロード", - "Cancel": "キャンセル", - "UploadTitle": "ファイルのアップロード", - "UploadTips": "ファイルをここにドラッグするか、アップロードをクリックします", - "Share": "シェア", - "CopyShareURLSuccess": "レプリケーション共有住所成功", - "ThemeConfig": "テーマ", - "OnlineUsers": "オンラインスタッフ", - "User": "ユーザー", - "VerifyCode": "認証コード", - "LinkAddr": "リンク先", - "ExpiredTime": "有効期限", - "SelectAction": "選択してください", - "CreateSuccess": "作成に成功しました", - "CreateLink": "シェアリンクの作成", - "CopyLink": "リンクと認証コードのコピー", - "NoLink": "住所なし", - "ConfirmBtn": "確定", - "Theme": "テーマ", - "SelectTheme": "テーマを選択してください", - "ThemeColors": "テーマカラー", - "ExceedTransferSize": "最大転送サイズを超えています", - "WaitFileTransfer": "ファイル転送終了待ち", - "EndFileTransfer": "ファイル転送終了" - }, - "Message": { - "InputVerifyCode": "認証コードを入力してください" - } -} \ No newline at end of file diff --git a/ui/src/locales/date.ts b/ui/src/locales/date.ts new file mode 100644 index 000000000..57eb2a32a --- /dev/null +++ b/ui/src/locales/date.ts @@ -0,0 +1,76 @@ +export default { + en: { + short: { + year: 'numeric', + month: 'short', + day: 'numeric', + }, + medium: { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + hour12: false, + }, + long: { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }, + }, + cn: { + short: { + year: 'numeric', + month: 'short', + day: 'numeric', + }, + medium: { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + hour12: false, + }, + long: { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true, + }, + }, + ja: { + short: { + year: 'numeric', + month: 'short', + day: 'numeric', + }, + medium: { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + hour12: false, + }, + long: { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true, + }, + }, +}; diff --git a/ui/src/locales/index.ts b/ui/src/locales/index.ts new file mode 100644 index 000000000..23ea7d102 --- /dev/null +++ b/ui/src/locales/index.ts @@ -0,0 +1,19 @@ +import { createI18n } from 'vue-i18n'; + +import { LanguageCode } from '@/utils/config'; + +import date from './date'; +import { message } from './modules'; + +const i18n = createI18n({ + locale: LanguageCode, + fallbackLocale: 'en', + legacy: false, + allowComposition: true, + silentFallbackWarn: true, + silentTranslationWarn: true, + messages: message, + dateTimeFormats: date, +}); + +export default i18n; diff --git a/ui/src/locales/modules/en.json b/ui/src/locales/modules/en.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/ui/src/locales/modules/en.json @@ -0,0 +1 @@ +{} diff --git a/ui/src/locales/modules/index.ts b/ui/src/locales/modules/index.ts new file mode 100644 index 000000000..f4f97ad81 --- /dev/null +++ b/ui/src/locales/modules/index.ts @@ -0,0 +1,19 @@ +import en from './en.json'; +import ja from './ja.json'; +import zh from './zh.json'; +import zh_Hant from './zh_Hant.json'; + +export const message = { + zh: { + ...zh, + }, + zh_hant: { + ...zh_Hant, + }, + en: { + ...en, + }, + ja: { + ...ja, + }, +}; diff --git a/ui/src/locales/modules/ja.json b/ui/src/locales/modules/ja.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/ui/src/locales/modules/ja.json @@ -0,0 +1 @@ +{} diff --git a/ui/src/locales/modules/zh.json b/ui/src/locales/modules/zh.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/ui/src/locales/modules/zh.json @@ -0,0 +1 @@ +{} diff --git a/ui/src/locales/modules/zh_Hant.json b/ui/src/locales/modules/zh_Hant.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/ui/src/locales/modules/zh_Hant.json @@ -0,0 +1 @@ +{} diff --git a/ui/src/main.css b/ui/src/main.css new file mode 100644 index 000000000..24989c3e6 --- /dev/null +++ b/ui/src/main.css @@ -0,0 +1,48 @@ +@import 'tailwindcss'; + +@font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-Regular.ttf'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'Open Sans'; + src: url('./style/font/OpenSans-Bold.ttf'); + font-weight: bold; + font-style: normal; +} + +@font-face { + font-family: 'Open Sans'; + src: url('./style/font/OpenSans-Light.ttf'); + font-weight: 300; + font-style: normal; +} + +html, +body, +#app { + width: 100%; + height: 100%; + padding: 0; + margin: 0; + font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +#app { + position: relative; +} + +:-webkit-any(article, aside, nav, section) h1 { + font-size: 2em; +} + +.icon-hover { + @apply cursor-pointer hover:text-[#63e2b7] duration-300 transition-all ease-in-out; +} + +.text-xs-plus { + @apply text-[13px]; +} diff --git a/ui/src/main.js b/ui/src/main.js deleted file mode 100644 index 82829a5c6..000000000 --- a/ui/src/main.js +++ /dev/null @@ -1,24 +0,0 @@ -import Vue from 'vue' -import VueRouter from 'vue-router' -import VueLogger from 'vuejs-logger' -import App from './App.vue' -import router from './router' -import i18n from './i18n/i18n' -import loggerOptions from './plugins/logger' -import ElementUI from 'element-ui' -import 'element-ui/lib/theme-chalk/index.css'; -import 'element-ui/lib/theme-chalk/display.css'; -import contextmenu from "v-contextmenu"; -import "v-contextmenu/dist/index.css"; -import VueCookies from 'vue-cookies' -Vue.use(VueCookies); -Vue.config.productionTip = false -Vue.use(VueRouter) -Vue.use(VueLogger, loggerOptions) -Vue.use(ElementUI) -Vue.use(contextmenu); -new Vue({ - router, - i18n, - render: h => h(App), -}).$mount('#app') diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 000000000..ceba59312 --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,25 @@ +import { createApp } from 'vue'; +import VueCookies from 'vue3-cookies'; + +// 引入指令 +import { draggable } from '@/directive/sidebarDraggable.ts'; + +import App from './App.vue'; +import pinia from './store'; +import i18n from './locales'; +import router from './router'; +import './main.css'; + +// 引入 xterm 样式 +import '@xterm/xterm/css/xterm.css'; + +const app = createApp(App); + +app.use(i18n); +app.use(pinia); +app.use(router); +app.use(VueCookies); + +app.directive('draggable', draggable); + +app.mount('#app'); diff --git a/ui/src/overrides.ts b/ui/src/overrides.ts new file mode 100644 index 000000000..414dd802e --- /dev/null +++ b/ui/src/overrides.ts @@ -0,0 +1,337 @@ +import type { GlobalThemeOverrides } from 'naive-ui'; + +import { useColor } from './hooks/useColor'; + +const { darken, lighten, alpha, setCurrentMainColor } = useColor(); + +// 创建主题生成函数 +export const createThemeOverrides = ( + themeType: 'default' | 'deepBlue' | 'darkGary' = 'default' +): GlobalThemeOverrides => { + setCurrentMainColor(themeType); + + const primaryColor = lighten(0); + const primaryColorHover = lighten(10); + const primaryColorPressed = darken(10); + const backgroundColor = darken(5); + const cardBackgroundColor = darken(7); + const inputBackgroundColor = lighten(2); + const surfaceColor = lighten(8); + const borderColor = alpha(0.3); + const textColor = 'rgba(235, 235, 235, 1)'; + const textColorSecondary = alpha(0.8, '#FFFFFF'); + const hoverColor = alpha(0.12, '#FFFFFF'); + + return { + Tabs: { + tabPaddingVerticalSmallLine: '6px 12px 6px 0', + }, + Form: { + labelTextColor: textColor, + labelTextColorDisabled: textColorSecondary, + asteriskColor: primaryColor, + feedbackTextColor: textColor, + feedbackTextColorError: '#ff6b6b', + feedbackTextColorWarning: '#FFB020', + feedbackTextColorSuccess: lighten(8), + feedbackPadding: '4px 0 0 0', + }, + Tree: { + nodeColorActive: alpha(0.1), + }, + Input: { + color: inputBackgroundColor, + colorFocus: inputBackgroundColor, + colorDisabled: darken(10), + border: `1px solid ${borderColor}`, + borderHover: `1px solid ${primaryColor}`, + borderActive: `1px solid ${primaryColor}`, + borderFocus: `1px solid ${primaryColor}`, + borderDisabled: `1px solid ${alpha(0.1)}`, + textColor, + textColorDisabled: textColorSecondary, + placeholderColor: textColorSecondary, + placeholderColorDisabled: alpha(0.4), + caretColor: '#FFFFFF', + boxShadowFocus: `0 0 0 2px ${alpha(0.2)}`, + }, + List: { + // colorHover: backgroundColor, + // colorModal: backgroundColor, + // colorHoverModal: hoverColor, + // borderColor, + // peers: { + // ListItem: { + // colorHover: hoverColor, + // colorHoverModal: hoverColor, + // borderRadius: '6px', + // }, + // }, + }, + Select: { + peers: { + InternalSelection: { + borderFocus: `1px solid ${lighten(10)}`, + color: lighten(5), + colorActive: lighten(5), + colorDisabled: darken(10), + border: `1px solid ${borderColor}`, + borderHover: `1px solid ${primaryColor}`, + borderActive: `1px solid ${primaryColor}`, + boxShadowActive: lighten(10), + textColor, + textColorDisabled: textColorSecondary, + placeholderColor: 'rgba(255,255,255,0.5)', + placeholderColorDisabled: alpha(0.4), + boxShadowFocus: `0 0 0 2px ${alpha(0.2)}`, + }, + InternalSelectMenu: { + color: surfaceColor, + optionTextColor: textColor, + optionTextColorActive: textColor, + optionTextColorPressed: textColor, + optionCheckColor: '#18a058', + groupHeaderTextColor: textColorSecondary, + actionTextColor: textColorSecondary, + loadingColor: primaryColor, + }, + }, + }, + Modal: { + peers: { + Dialog: { + color: backgroundColor, + peers: { + Button: { + borderPressedPrimary: `1px solid ${primaryColorPressed}`, + borderFocusPrimary: `1px solid ${primaryColor}`, + borderHoverPrimary: `1px solid ${primaryColorHover}`, + borderPrimary: `1px solid ${primaryColor}`, + colorPrimary: primaryColor, + colorFocusPrimary: primaryColor, + colorHoverPrimary: primaryColorHover, + colorPressedPrimary: primaryColorPressed, + textColorPrimary: textColor, + textColorHoverPrimary: textColor, + textColorPressedPrimary: textColor, + textColorFocusPrimary: textColor, + textColorError: textColor, + textColorHoverError: textColor, + textColorPressedError: textColor, + textColorFocusError: textColor, + }, + }, + }, + }, + }, + Card: { + color: cardBackgroundColor, + colorModal: cardBackgroundColor, + }, + Button: { + borderPressedPrimary: `1px solid ${primaryColorPressed}`, + borderFocusPrimary: `1px solid ${primaryColor}`, + borderHoverPrimary: `1px solid ${primaryColorHover}`, + borderPrimary: `1px solid ${primaryColor}`, + colorPrimary: lighten(300), + colorFocusPrimary: primaryColor, + colorHoverPrimary: primaryColorHover, + colorPressedPrimary: primaryColorPressed, + textColorPrimary: textColor, + textColorHoverPrimary: textColor, + textColorPressedPrimary: textColor, + textColorFocusPrimary: textColor, + }, + Switch: { + railColor: alpha(0.3), + railColorActive: primaryColor, + buttonColor: backgroundColor, + buttonColorPressed: darken(5), + textColor, + textColorDisabled: textColorSecondary, + opacityDisabled: 0.4, + }, + Divider: { + color: lighten(4), + }, + Checkbox: { + color: backgroundColor, + colorChecked: primaryColor, + colorDisabled: darken(10), + colorDisabledChecked: alpha(0.3), + textColor, + textColorDisabled: textColorSecondary, + dotColorDisabled: alpha(0.6), + checkMarkColor: textColor, + checkMarkColorDisabled: textColorSecondary, + border: `1px solid ${borderColor}`, + borderFocus: `1px solid ${primaryColor}`, + borderDisabled: `1px solid ${alpha(0.1)}`, + borderChecked: `1px solid ${primaryColor}`, + boxShadowFocus: `0 0 0 2px ${alpha(0.2)}`, + }, + Radio: { + color: backgroundColor, + colorDisabled: darken(10), + colorChecked: primaryColor, + dotColorActive: textColor, + dotColorDisabled: textColorSecondary, + textColor, + textColorDisabled: textColorSecondary, + buttonBorderColor: borderColor, + buttonBorderColorActive: primaryColor, + buttonColorActive: primaryColor, + buttonTextColor: textColor, + buttonTextColorActive: textColor, + buttonTextColorHover: textColor, + buttonColorHover: lighten(10), + buttonColorPressed: darken(10), + boxShadowFocus: `0 0 0 2px ${alpha(0.2)}`, + }, + DataTable: { + thColor: cardBackgroundColor, + tdColor: cardBackgroundColor, + tdColorHover: hoverColor, + thColorModal: cardBackgroundColor, + tdColorModal: cardBackgroundColor, + tdColorHoverModal: hoverColor, + borderColorModal: borderColor, + borderColorHoverModal: borderColor, + }, + Ellipsis: { + textColor, + peers: { + Tooltip: { + color: surfaceColor, + textColor, + peers: { + Popover: { + color: surfaceColor, + textColor, + }, + }, + }, + }, + }, + Table: { + thColorModal: cardBackgroundColor, + tdColorModal: cardBackgroundColor, + }, + Tag: { + // borderPrimary: `1px solid ${primaryColor}`, + // textColorPrimary: textColor, + // colorSuccess: lighten(5), + // borderSuccess: `1px solid ${lighten(8)}`, + // textColorSuccess: textColor, + // closeColorSuccess: textColorSecondary, + // closeColorHoverSuccess: textColor, + // closeColorPressedSuccess: darken(5), + // colorWarning: alpha(0.1, '#FFB020'), + // borderWarning: `1px solid ${alpha(0.3, '#FFB020')}`, + // textColorWarning: '#FFB020', + // closeColorWarning: alpha(0.6, '#FFB020'), + // closeColorHoverWarning: '#FFB020', + // closeColorPressedWarning: alpha(0.8, '#FFB020'), + // color: cardBackgroundColor, + // textColor: textColorSecondary, + // border: `1px solid ${borderColor}`, + // closeColor: textColorSecondary, + // closeColorHover: textColor, + // closeColorPressed: darken(5), + // closeIconColor: alpha(0.8, '#FF0000'), + }, + Upload: { + peers: { + Progress: { + fillColor: alpha(0.1, '#18a058'), + fillColorInfo: alpha(0.7, '#18a058'), + }, + }, + }, + Layout: { + color: darken(40), + siderColor: darken(40), + headerColor: darken(40), + }, + Drawer: { + color: backgroundColor, + titleTextColor: textColor, + bodyPadding: '16px 24px', + }, + Dropdown: { + color: surfaceColor, + optionTextColor: textColor, + optionTextColorHover: textColor, + optionTextColorActive: textColor, + optionTextColorChildActive: primaryColor, + optionColorHover: hoverColor, + optionColorActive: alpha(0.15), + optionColorPressed: alpha(0.2), + groupHeaderTextColor: textColorSecondary, + dividerColor: alpha(0.3), + optionOpacityDisabled: 0.4, + optionCheckColor: primaryColor, + optionArrowColor: textColorSecondary, + borderColor, + }, + Popover: { + color: surfaceColor, + textColor, + borderColor, + borderRadius: '8px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + arrowColor: surfaceColor, + arrowColorInfo: surfaceColor, + arrowColorSuccess: lighten(5), + arrowColorWarning: alpha(0.1, '#FFB020'), + arrowColorError: alpha(0.1, '#ff6b6b'), + colorInfo: surfaceColor, + colorSuccess: lighten(5), + colorWarning: alpha(0.1, '#FFB020'), + colorError: alpha(0.1, '#ff6b6b'), + textColorInfo: textColor, + textColorSuccess: textColor, + textColorWarning: '#FFB020', + textColorError: '#ff6b6b', + borderColorInfo: borderColor, + borderColorSuccess: alpha(0.3, lighten(8)), + borderColorWarning: alpha(0.3, '#FFB020'), + borderColorError: alpha(0.3, '#ff6b6b'), + }, + Popconfirm: { + peers: { + Popover: { + color: surfaceColor, + textColor, + }, + Button: { + colorPrimary: primaryColor, + colorHoverPrimary: primaryColorHover, + colorPressedPrimary: primaryColorPressed, + textColorPrimary: textColor, + textColorHoverPrimary: textColor, + textColorPressedPrimary: textColor, + + color: cardBackgroundColor, + colorHover: hoverColor, + colorPressed: alpha(0.2), + textColor, + textColorHover: textColor, + textColorPressed: textColor, + }, + }, + }, + Descriptions: { + titleTextColor: textColor, + thColor: cardBackgroundColor, + thColorModal: cardBackgroundColor, + thColorPopover: cardBackgroundColor, + thTextColor: textColor, + tdTextColor: textColor, + tdColor: backgroundColor, + tdColorModal: backgroundColor, + tdColorPopover: backgroundColor, + borderColorModal: lighten(10), + }, + }; +}; diff --git a/ui/src/plugins/logger.js b/ui/src/plugins/logger.js deleted file mode 100644 index 6763cad38..000000000 --- a/ui/src/plugins/logger.js +++ /dev/null @@ -1,11 +0,0 @@ -const isProduction = process.env.NODE_ENV === 'production' - -export default { - isEnabled: true, - logLevel: isProduction ? 'error' : 'debug', - stringifyArguments: false, - showLogLevel: true, - showMethodName: true, - separator: '|', - showConsoleColors: true -} diff --git a/ui/src/router/index.js b/ui/src/router/index.js deleted file mode 100644 index f3b68640f..000000000 --- a/ui/src/router/index.js +++ /dev/null @@ -1,49 +0,0 @@ -import Vue from 'vue' -import Router from 'vue-router' - -Vue.use(Router) - -export const allRoleRoutes = [ - { - path: '/terminal/', - name: 'Terminal', - component: () => import('../views/Connection') - }, - { - path: '/token/', - name: 'TokenParams', - component: () => import('../views/Connection') - }, - { - path: '/token/:id/', - name: 'Token', - component: () => import('../views/Connection') - }, - { - path: '/share/:id/', - name: 'Share', - component: () => import('../views/ShareTerminal') - }, - { - path: '/monitor/:id/', - name: 'Monitor', - component: () => import('../views/Monitor') - } -] - -const createRouter = () => new Router({ - mode: 'history', // require service support - // scrollBehavior: () => ({y: 0}), - base: '/koko/', - routes: allRoleRoutes -}) - -const router = createRouter() - -// Detail see: https://github.com/vuejs/vue-router/issues/1234#issuecomment-357941465 -export function resetRouter() { - const newRouter = createRouter() - router.matcher = newRouter.matcher // reset router -} - -export default router diff --git a/ui/src/router/index.ts b/ui/src/router/index.ts new file mode 100644 index 000000000..9233787a0 --- /dev/null +++ b/ui/src/router/index.ts @@ -0,0 +1,50 @@ +import type { Router, RouteRecordRaw } from 'vue-router'; + +import { createRouter, createWebHistory } from 'vue-router'; + +import { guard } from '../utils/guard'; + +const allRoutes: RouteRecordRaw[] = [ + { + path: '/connect/', + name: 'Terminal', + component: () => import('@/views/connection/index.vue'), + }, + { + path: '/token/', + name: 'TokenParams', + component: () => import('@/views/connection/index.vue'), + }, + { + path: '/k8s/', + name: 'kubernetes', + component: () => import('@/views/kubernetes/index.vue'), + }, + { + path: '/token/:id/', + name: 'Token', + component: () => import('@/views/connection/index.vue'), + }, + { + path: '/share/:id/', + name: 'Share', + component: () => import('@/views/share/index.vue'), + }, + { + path: '/monitor/:id/', + name: 'Monitor', + component: () => import('@/views/monitor/index.vue'), + }, +]; + +const router: Router = createRouter({ + history: createWebHistory('/koko/'), + routes: allRoutes, + scrollBehavior: () => ({ left: 0, top: 0 }), +}); + +router.beforeEach((_to, _from, next) => { + guard(next); +}); + +export default router; diff --git a/ui/src/store/index.ts b/ui/src/store/index.ts new file mode 100644 index 000000000..497312c2a --- /dev/null +++ b/ui/src/store/index.ts @@ -0,0 +1,7 @@ +import type { Pinia } from 'pinia'; + +import { createPinia } from 'pinia'; + +const pinia: Pinia = createPinia(); + +export default pinia; diff --git a/ui/src/store/modules/fileManage.ts b/ui/src/store/modules/fileManage.ts new file mode 100644 index 000000000..d0e02b331 --- /dev/null +++ b/ui/src/store/modules/fileManage.ts @@ -0,0 +1,50 @@ +import type { UploadFileInfo } from 'naive-ui'; + +import { defineStore } from 'pinia'; + +import type { FileManageSftpFileItem } from '@/types/modules/file.type'; + +interface IFileManageStoreState { + fileList: FileManageSftpFileItem[] | null; + + messageId: string; + + currentPath: string; + + isReceived: boolean; + + uploadFileList: UploadFileInfo[]; +} + +export const useFileManageStore = defineStore('fileManage', { + state: (): IFileManageStoreState => ({ + fileList: null, + + messageId: '', + + currentPath: '', + + isReceived: false, + + uploadFileList: [], + }), + actions: { + setFileList(fileList: FileManageSftpFileItem[]) { + if (fileList) { + this.fileList = fileList; + } + }, + setMessageId(id: string): void { + this.messageId = id; + }, + setCurrentPath(currentPath: string): void { + this.currentPath = currentPath; + }, + setReceived(value: boolean) { + this.isReceived = value; + }, + setUploadFileList(fileList: UploadFileInfo[]) { + this.uploadFileList = fileList; + }, + }, +}); diff --git a/ui/src/store/modules/global.ts b/ui/src/store/modules/global.ts new file mode 100644 index 000000000..77537a64e --- /dev/null +++ b/ui/src/store/modules/global.ts @@ -0,0 +1,16 @@ +import { defineStore } from 'pinia'; + +import type { IGlobalState } from '@/types/modules/store.type'; + +export const useGlobalStore = defineStore('global', { + state: (): IGlobalState => ({ + initialized: false, + i18nLoaded: false, + }), + getters: {}, + actions: { + setI18nLoaded(payload: boolean) { + this.i18nLoaded = payload; + }, + }, +}); diff --git a/ui/src/store/modules/kubernetes.ts b/ui/src/store/modules/kubernetes.ts new file mode 100644 index 000000000..8378dce59 --- /dev/null +++ b/ui/src/store/modules/kubernetes.ts @@ -0,0 +1,39 @@ +import { defineStore } from 'pinia'; + +import type { SettingConfig } from '@/types/modules/config.type'; + +export interface IKubernetesState { + // 全局的唯一 TerminalId + globalTerminalId: string; + + globalSetting: SettingConfig; + + lastReceiveTime: any; + + lastSendTime: any; +} + +export const useKubernetesStore = defineStore('kubernetes', { + state: (): IKubernetesState => { + return { + globalTerminalId: '', + globalSetting: {}, + lastReceiveTime: new Date(), + lastSendTime: new Date(), + }; + }, + actions: { + setGlobalTerminalId(id: string) { + this.globalTerminalId = id; + }, + setGlobalSetting(setting: SettingConfig) { + this.globalSetting = setting; + }, + setLastReceiveTime(time: any) { + this.lastReceiveTime = time; + }, + setLastSendTime(time: any) { + this.lastSendTime = time; + }, + }, +}); diff --git a/ui/src/store/modules/params.ts b/ui/src/store/modules/params.ts new file mode 100644 index 000000000..31ccb716f --- /dev/null +++ b/ui/src/store/modules/params.ts @@ -0,0 +1,27 @@ +import { defineStore } from 'pinia'; + +import type { IParamsState } from '@/types/modules/store.type'; +import type { SettingConfig } from '@/types/modules/config.type'; + +export const useParamsStore = defineStore('params', { + state: (): IParamsState => ({ + shareId: '', + shareCode: '', + currentUser: null, + setting: {}, + }), + actions: { + setShareId(shareId: string) { + this.shareId = shareId; + }, + setShareCode(shareCode: string) { + this.shareCode = shareCode; + }, + setCurrentUser(curremtUser: any) { + this.currentUser = curremtUser; + }, + setSetting(setting: SettingConfig) { + this.setting = setting; + }, + }, +}); diff --git a/ui/src/store/modules/terminal.ts b/ui/src/store/modules/terminal.ts new file mode 100644 index 000000000..8bb46efdc --- /dev/null +++ b/ui/src/store/modules/terminal.ts @@ -0,0 +1,30 @@ +import { defineStore } from 'pinia'; + +import type { ITerminalConfig, ObjToKeyValArray } from '@/types/modules/store.type'; + +export const useTerminalStore = defineStore('terminal', { + state: (): ITerminalConfig => ({ + fontSize: 14, + themeName: '', + quickPaste: '0', + ctrlCAsCtrlZ: '', + backspaceAsCtrlH: '0', + lineHeight: 1, + fontFamily: 'monaco, Consolas, "Lucida Console", monospace', + + enableZmodem: true, + zmodemStatus: false, + + currentTab: '', + + termSelectionText: '', + }), + getters: { + getConfig: state => state, + }, + actions: { + setTerminalConfig(...args: ObjToKeyValArray) { + this.$patch({ [args[0]]: args[1] }); + }, + }, +}); diff --git a/ui/src/store/modules/terminalSettings.ts b/ui/src/store/modules/terminalSettings.ts new file mode 100644 index 000000000..36e67b6ed --- /dev/null +++ b/ui/src/store/modules/terminalSettings.ts @@ -0,0 +1,25 @@ +import { defineStore } from 'pinia'; + +import type { ObjToKeyValArray } from '@/types'; +import type { ITerminalSettings } from '@/types/modules/terminal.type'; + +export const useTerminalSettingsStore = defineStore('terminalSettings', { + state: (): Partial => ({ + fontSize: 14, + lineHeight: 1, + fontFamily: 'monaco, Consolas, "Lucida Console", monospace', + themeName: '', + quickPaste: '0', + ctrlCAsCtrlZ: '0', + backspaceAsCtrlH: '0', + theme: '', + }), + getters: { + getConfig: state => state, + }, + actions: { + setDefaultTerminalConfig(...args: ObjToKeyValArray) { + this.$patch({ [args[0]]: args[1] }); + }, + }, +}); diff --git a/ui/src/store/modules/tree.ts b/ui/src/store/modules/tree.ts new file mode 100644 index 000000000..d25d123c4 --- /dev/null +++ b/ui/src/store/modules/tree.ts @@ -0,0 +1,64 @@ +import type { TreeOption } from 'naive-ui'; + +import { defineStore } from 'pinia'; + +import type { ITreeState } from '@/types/modules/store.type'; +import type { customTreeOption } from '@/types/modules/config.type'; + +export const useTreeStore = defineStore('tree', { + state: (): ITreeState => ({ + connectInfo: null, + treeNodes: [], + currentNode: {}, + root: {}, + isLoaded: false, + terminalMap: new Map(), + }), + actions: { + setTreeNodes(nodes: customTreeOption) { + this.treeNodes.push(nodes); + }, + setChildren(nodes: customTreeOption[]) { + const updateChildren = (tree: TreeOption[]) => { + for (const node of tree) { + if (node.k8s_id === this.currentNode.k8s_id) { + node.children = nodes; + return true; + } else if (node.children && node.children.length > 0) { + const found = updateChildren(node.children); + if (found) return true; + } + } + return false; + }; + + if (this.treeNodes.length > 0) { + updateChildren(this.treeNodes); + } + }, + setConnectInfo(info: any) { + this.connectInfo = info; + }, + setCurrentNode(currentNode: customTreeOption) { + this.currentNode = currentNode; + }, + setRoot(node: customTreeOption) { + this.root = node; + }, + setReload() { + this.treeNodes = []; + }, + setLoaded(status: boolean) { + this.isLoaded = status; + }, + setK8sIdMap(k8s_id: string, data: any) { + this.terminalMap.set(k8s_id, data); + }, + getTerminalByK8sId(k8s_id: string): any { + return this.terminalMap.get(k8s_id); + }, + removeK8sIdMap(k8s_id: string): boolean { + return this.terminalMap.delete(k8s_id); + }, + }, +}); diff --git a/ui/src/store/modules/useConnection.ts b/ui/src/store/modules/useConnection.ts new file mode 100644 index 000000000..3e90d216f --- /dev/null +++ b/ui/src/store/modules/useConnection.ts @@ -0,0 +1,37 @@ +import { defineStore } from 'pinia'; + +import type { ConnectionState } from '@/types/modules/connection.type'; + +export const useConnectionStore = defineStore('connection', { + state: (): Partial => ({ + origin: '', + lunaId: '', + shareId: '', + shareCode: '', + sessionId: '', + assetName: '', + terminalId: '', + enableShare: false, + terminal: undefined, + socket: null, + userOptions: [], + onlineUsers: [], + drawerOpenState: false, + drawerTabIndex: 0, + }), + getters: { + isConnected: state => !!state.socket && state.socket.readyState === WebSocket.OPEN, + hasShare: state => !!state.shareId && !!state.shareCode, + }, + actions: { + setConnectionState(connectionState: Partial) { + Object.assign(this, connectionState); + }, + updateConnectionState(connectionState: Partial) { + Object.assign(this, connectionState); + }, + resetConnectionState() { + this.$reset(); + }, + }, +}); diff --git a/ui/src/styles/index.css b/ui/src/styles/index.css deleted file mode 100644 index f8550453d..000000000 --- a/ui/src/styles/index.css +++ /dev/null @@ -1,71 +0,0 @@ -html { - height: 100%; - width: 100%; - position: fixed; - box-sizing: border-box; -} - -body { - margin: 0; - background-color: #1f1b1b; - overflow: auto; - height: 100%; - width: 100%; -} - -body ::-webkit-scrollbar-track { - -webkit-box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.3); - background-color: #272323; -} - -body ::-webkit-scrollbar-thumb { - background-color: #494141; - border-radius: 6px; -} - -#app{ - height: 100%; - width: 100%; -} - -.el-container { - margin: 0; - width: 100%; - height: 100%; - padding: 0; -} - -.el-main { - margin: 0; - width: 100%; - height: 100%; - padding: 0; - overflow: hidden; -} - -.el-aside { - height: 100%; - overflow: hidden; -} - -.menu-list li { - cursor: pointer; -} - -.xterm .xterm-viewport { - overflow-y: auto!important; -} - -::-webkit-scrollbar { - width: 9px; -} -::-webkit-scrollbar-track { - border-radius:10px; -} -::-webkit-scrollbar-thumb { - border-radius: 8px; - box-shadow: 8px 10px 20px #494141 inset; -} -::-webkit-scrollbar-thumb:hover { - box-shadow: 8px 10px 20px #878787 inset; -} \ No newline at end of file diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts new file mode 100644 index 000000000..beb3f9092 --- /dev/null +++ b/ui/src/types/index.ts @@ -0,0 +1,17 @@ +import type { Component } from 'vue'; +import type { Composer } from 'vue-i18n'; + +export type TranslateFunction = Composer['t']; + +export type ObjToKeyValArray = { + [K in keyof T]: [K, T[K]]; +}[keyof T]; + +export interface ISettingProp { + label: string; + title: string; + icon: Component; + disabled: () => any; + click: (user: any) => any; + content?: any; +} diff --git a/ui/src/types/modules/config.type.ts b/ui/src/types/modules/config.type.ts new file mode 100644 index 000000000..08b83249a --- /dev/null +++ b/ui/src/types/modules/config.type.ts @@ -0,0 +1,60 @@ +import type { TreeOption } from 'naive-ui'; + +interface Interface { + favicon: string; + login_image: string; + login_title: string; + logo_index: string; + logo_logout: string; +} + +interface Announcement { + CONTENT: string; + ID: string; + LINK: string; + SUBJECT: string; +} + +export interface ILunaConfig { + fontSize?: number; + + quickPaste?: string; + + backspaceAsCtrlH?: string; + + ctrlCAsCtrlZ?: string; + + lineHeight?: number; + + fontFamily: string; +} + +export interface SettingConfig { + ANNOUNCEMENT?: Announcement; + ANNOUNCEMENT_ENABLED?: boolean; + INTERFACE?: Interface; + SECURITY_SESSION_SHARE?: boolean; +} + +export interface ITerminalProps { + // 主题名称 + themeName?: string; + + terminalType: string; + + socket?: WebSocket; + + indexKey?: string; +} + +export interface customTreeOption extends TreeOption { + id?: string; + + k8s_id?: string; + + namespace?: string; + + pod?: string; + + container?: string; +} diff --git a/ui/src/types/modules/connection.type.ts b/ui/src/types/modules/connection.type.ts new file mode 100644 index 000000000..c50cd7ad1 --- /dev/null +++ b/ui/src/types/modules/connection.type.ts @@ -0,0 +1,35 @@ +import type { Terminal } from '@xterm/xterm'; + +import type { OnlineUser, ShareUserOptions } from './user.type'; + +export interface ConnectionState { + origin: string; + + lunaId: string; + + shareId: string; + + shareCode: string; + + assetName: string; + + sessionId: string; + + terminalId: string; + + enableShare: boolean; + + terminal: Terminal; + + socket: WebSocket | null; + + userOptions: ShareUserOptions[]; + + onlineUsers: OnlineUser[]; + + drawerOpenState: boolean; + + drawerTabIndex: number; +} + +export type ContentType = 'setting' | 'file-manager' | ''; diff --git a/ui/src/types/modules/file.type.ts b/ui/src/types/modules/file.type.ts new file mode 100644 index 000000000..1d8717a90 --- /dev/null +++ b/ui/src/types/modules/file.type.ts @@ -0,0 +1,128 @@ +import type { FunctionalComponent } from 'vue'; + +interface User { + id: string; + name: string; + username: string; + email: string; + role: string; + is_valid: boolean; + is_active: boolean; + otp_level: number; +} + +interface Protocol { + id: number; + name: string; + port: number; + public: boolean; +} + +interface SpecInfo { + db_name: string; + pg_ssl_mode: string; + use_ssl: boolean; + allow_invalid_cert: boolean; + autofill: string; + username_selector: string; + password_selector: string; + submit_selector: string; +} + +interface SecretInfo { + ca_cert: string; + client_cert: string; + client_key: string; +} + +interface Platform { + id: number; + name: string; +} + +interface Asset { + id: string; + address: string; + name: string; + org_id: string; + protocols: Protocol[]; + spec_info: SpecInfo; + secret_info: SecretInfo; + platform: Platform; + domain: string | null; + comment: string; + org_name: string; + is_active: boolean; +} + +interface InterfaceSettings { + login_title: string; + logo_logout: string; + logo_index: string; + login_image: string; + favicon: string; +} + +interface SettingAnnouncement { + ID: string; + SUBJECT: string; + CONTENT: string; + LINK: string; + DATE_START: string; + DATE_END: string; +} + +interface Setting { + INTERFACE: InterfaceSettings; + SECURITY_WATERMARK_ENABLED: boolean; + SECURITY_SESSION_SHARE: boolean; + ANNOUNCEMENT_ENABLED: boolean; + ANNOUNCEMENT: SettingAnnouncement; +} + +export interface FileManage { + id: string; + type: string; + // data: FileManageConnectData | FileManageSftpFileItem; + data: string; + raw?: any; + err: string; + prompt: string; + interrupt: boolean; + k8s_id: string; + namespace: string; + pod: string; + container: string; + cmd: string; + current_path: string; +} + +export interface FileManageConnectData { + user: User; + setting: Setting; + asset: Asset; +} + +export interface FileManageSftpFileItem { + name: string; + size: string; + perm: string; + mod_time: string; + type: string; + is_dir: boolean; +} + +export interface LeftActionsMenu { + label: string; + icon: FunctionalComponent; + disabled: boolean; + click: () => void; +} + +export interface FileSendData { + offSet: number; + size: number | undefined; + path: string; + merge?: boolean; + chunk?: boolean; +} diff --git a/ui/src/types/modules/guard.type.ts b/ui/src/types/modules/guard.type.ts new file mode 100644 index 000000000..87878855c --- /dev/null +++ b/ui/src/types/modules/guard.type.ts @@ -0,0 +1,30 @@ +interface BasicConfig { + is_async_asset_tree: boolean; + connect_default_open_method: string; +} + +interface GraphicsConfig { + rdp_resolution: string; + keyboard_layout: string; + rdp_client_option: string[]; + rdp_color_quality: string; + rdp_smart_size: number; + applet_connection_method: string; + file_name_conflict_resolution: string; +} + +export interface CommandLineConfig { + character_terminal_font_size: number; + is_backspace_as_ctrl_h: boolean; + is_right_click_quickly_paste: boolean; + terminal_theme_name: string; +} + +export interface ILocalTerminalConfig { + commandExecution: boolean; + isSkipAllManualPassword: string; + sqlClient: string; + basic: BasicConfig; + graphics: GraphicsConfig; + command_line: CommandLineConfig; +} diff --git a/ui/src/types/modules/message.type.ts b/ui/src/types/modules/message.type.ts new file mode 100644 index 000000000..569e775b0 --- /dev/null +++ b/ui/src/types/modules/message.type.ts @@ -0,0 +1,94 @@ +export enum FORMATTER_MESSAGE_TYPE { + PING = 'PING', + TERMINAL_INIT = 'TERMINAL_INIT', + TERMINAL_DATA = 'TERMINAL_DATA', + TERMINAL_SHARE = 'TERMINAL_SHARE', + TERMINAL_RESIZE = 'TERMINAL_RESIZE', + TERMINAL_K8S_DATA = 'TERMINAL_K8S_DATA', + TERMINAL_K8S_RESIZE = 'TERMINAL_K8S_RESIZE', + TERMINAL_SHARE_USER_REMOVE = 'TERMINAL_SHARE_USER_REMOVE', + TERMINAL_GET_SHARE_USER = 'TERMINAL_GET_SHARE_USER', +} + +export enum ZMODEM_ACTION_TYPE { + ZMODEM_START = 'ZMODEM_START', + ZMODEM_END = 'ZMODEM_END', +} + +export enum MESSAGE_TYPE { + PING = 'PING', + CLOSE = 'CLOSE', + ERROR = 'ERROR', + CONNECT = 'CONNECT', + TERMINAL_SHARE = 'TERMINAL_SHARE', + TERMINAL_ERROR = 'TERMINAL_ERROR', + MESSAGE_NOTIFY = 'MESSAGE_NOTIFY', + TERMINAL_ACTION = 'TERMINAL_ACTION', + TERMINAL_SESSION = 'TERMINAL_SESSION', + TERMINAL_PERM_VALID = 'TERMINAL_PERM_VALID', + TERMINAL_SHARE_JOIN = 'TERMINAL_SHARE_JOIN', + TERMINAL_SHARE_LEAVE = 'TERMINAL_SHARE_LEAVE', + TERMINAL_PERM_EXPIRED = 'TERMINAL_PERM_EXPIRED', + TERMINAL_SESSION_PAUSE = 'TERMINAL_SESSION_PAUSE', + TERMINAL_SESSION_RESUME = 'TERMINAL_SESSION_RESUME', + TERMINAL_GET_SHARE_USER = 'TERMINAL_GET_SHARE_USER', + TERMINAL_SHARE_USER_REMOVE = 'TERMINAL_SHARE_USER_REMOVE', +} + +export enum LUNA_MESSAGE_TYPE { + PING = 'PING', + PONG = 'PONG', + CMD = 'CMD', + FOCUS = 'FOCUS', + OPEN = 'OPEN', + FILE = 'FILE', + CREATE_FILE_CONNECT_TOKEN = 'CREATE_FILE_CONNECT_TOKEN', + GET_FILE_CONNECT_TOKEN = 'GET_FILE_CONNECT_TOKEN', + + SESSION_INFO = 'SESSION_INFO', + + SHARE_USER = 'SHARE_USER', + SHARE_USER_REMOVE = 'SHARE_USER_REMOVE', + SHARE_USER_ADD = 'SHARE_USER_ADD', + SHARE_USER_LEAVE = 'SHARE_USER_LEAVE', + + TERMINAL_THEME_CHANGE = 'TERMINAL_THEME_CHANGE', + + SHARE_CODE_REQUEST = 'SHARE_CODE_REQUEST', + SHARE_CODE_RESPONSE = 'SHARE_CODE_RESPONSE', + + CLOSE = 'CLOSE', + CONNECT = 'CONNECT', + TERMINAL_ERROR = 'TERMINAL_ERROR', + MESSAGE_NOTIFY = 'MESSAGE_NOTIFY', + KEYEVENT = 'KEYEVENT', + + TERMINAL_CONTENT = 'TERMINAL_CONTENT_REQUEST', + TERMINAL_CONTENT_RESPONSE = 'TERMINAL_CONTENT_RESPONSE', + CLICK = 'CLICK', + CHANGE_MAIN_THEME = 'CHANGE_MAIN_THEME', + FILE_MANAGE_EXPIRED = 'FILE_MANAGE_EXPIRED', + OPEN_K8S_SETTING = 'OPEN_K8S_SETTING', + INPUT_ACTIVE = 'INPUT_ACTIVE', +} + +export enum SFTP_CMD { + RM = 'rm', + LIST = 'list', + MKDIR = 'mkdir', + MKFILE = 'mkfile', + RENAME = 'rename', + UPLOAD = 'upload', + DOWNLOAD = 'download', +} + +export enum FILE_MANAGE_MESSAGE_TYPE { + CONNECT = 'CONNECT', + CLOSE = 'CLOSE', + ERROR = 'ERROR', + PING = 'PING', + PONG = 'PONG', + CLOSED = 'closed', + SFTP_DATA = 'SFTP_DATA', + SFTP_BINARY = 'SFTP_BINARY', +} diff --git a/ui/src/types/modules/postmessage.type.ts b/ui/src/types/modules/postmessage.type.ts new file mode 100644 index 000000000..96c94efec --- /dev/null +++ b/ui/src/types/modules/postmessage.type.ts @@ -0,0 +1,143 @@ +import type { LUNA_MESSAGE_TYPE } from './message.type'; + +export interface LunaMessageEvents { + [LUNA_MESSAGE_TYPE.PING]: { + data: string; + }; + [LUNA_MESSAGE_TYPE.PONG]: { + data: string; + }; + [LUNA_MESSAGE_TYPE.CMD]: { + data: LunaMessage; + }; + [LUNA_MESSAGE_TYPE.FOCUS]: { + data: LunaMessage; + }; + [LUNA_MESSAGE_TYPE.OPEN]: { + data: LunaMessage; + }; + [LUNA_MESSAGE_TYPE.FILE]: { + data: LunaMessage; + }; + [LUNA_MESSAGE_TYPE.CREATE_FILE_CONNECT_TOKEN]: { + data: string; + }; + [LUNA_MESSAGE_TYPE.GET_FILE_CONNECT_TOKEN]: { + data: LunaMessage; + }; + [LUNA_MESSAGE_TYPE.SESSION_INFO]: { + data: LunaMessage; + }; + [LUNA_MESSAGE_TYPE.SHARE_USER]: { + data: LunaMessage; + }; + [LUNA_MESSAGE_TYPE.SHARE_USER_REMOVE]: { + data: LunaMessage; + }; + [LUNA_MESSAGE_TYPE.SHARE_USER_ADD]: { + data: LunaMessage; + }; + [LUNA_MESSAGE_TYPE.TERMINAL_THEME_CHANGE]: { + data: LunaMessage; + }; + + [LUNA_MESSAGE_TYPE.SHARE_CODE_REQUEST]: { + data: ShareUserRequest; + }; + [LUNA_MESSAGE_TYPE.SHARE_CODE_RESPONSE]: { + data: string; + }; + [LUNA_MESSAGE_TYPE.CLOSE]: { + data: string; + }; + [LUNA_MESSAGE_TYPE.CONNECT]: { + data: LunaMessage; + }; + [LUNA_MESSAGE_TYPE.TERMINAL_ERROR]: { + data: LunaMessage; + }; + [LUNA_MESSAGE_TYPE.MESSAGE_NOTIFY]: { + data: LunaMessage; + }; + [LUNA_MESSAGE_TYPE.KEYEVENT]: { + data: string; + }; + [LUNA_MESSAGE_TYPE.TERMINAL_CONTENT]: { + data: LunaMessage; + }; + [LUNA_MESSAGE_TYPE.TERMINAL_CONTENT_RESPONSE]: { + data: TerminalContentResponse; + }; + [LUNA_MESSAGE_TYPE.CLICK]: { + data: string; + }; + [LUNA_MESSAGE_TYPE.FILE_MANAGE_EXPIRED]: { + data: string; + }; + [LUNA_MESSAGE_TYPE.CHANGE_MAIN_THEME]: { + data: string; + }; + [LUNA_MESSAGE_TYPE.OPEN_K8S_SETTING]: { + data: string; + }; + [LUNA_MESSAGE_TYPE.INPUT_ACTIVE]: { + data: string; + }; +} + +export interface LunaMessage { + id: string; + name: string; + origin: string; + disbaleFileManager: boolean; + data: string | object | null; + theme?: string; + user_meta?: string; + token?: string; +} + +export interface ShareUserRequest { + name: string; + data: { + sessionId: string; + requestData: { + expired_time: number; + action_permission: string; + action_perm: string; + users: string[]; + }; + }; +} + +export interface ShareUserResponse { + shareId: string; + code: string; + terminalId: string; +} + +export interface TerminalSessionInfo { + session: TerminalSession; + permission: TerminalPermission; + backspaceAsCtrlH: boolean; + ctrlCAsCtrlZ: boolean; + themeName: string; +} + +export interface TerminalSession { + ip: string; + id: string; + user: string; + asset: string; + + userId: string; +} + +export interface TerminalPermission { + actions: string[]; +} + +export interface TerminalContentResponse { + terminalId: string; + content: string; + sessionId: string; +} diff --git a/ui/src/types/modules/setting.type.ts b/ui/src/types/modules/setting.type.ts new file mode 100644 index 000000000..d3e2c94f5 --- /dev/null +++ b/ui/src/types/modules/setting.type.ts @@ -0,0 +1,19 @@ +import type { FunctionalComponent } from 'vue'; + +export interface SettingConfig { + theme?: string; + drawerTitle: string; + + items: Array<{ + type: 'select' | 'keyboard' | 'create' | 'list'; + label: string; + labelIcon: FunctionalComponent; + labelStyle: { + fontSize: string; + }; + showMore?: boolean; + disabled?: boolean; + value?: string; + options?: any; + }>; +} diff --git a/ui/src/types/modules/store.type.ts b/ui/src/types/modules/store.type.ts new file mode 100644 index 000000000..bec69e15f --- /dev/null +++ b/ui/src/types/modules/store.type.ts @@ -0,0 +1,69 @@ +import type { customTreeOption, SettingConfig } from '@/types/modules/config.type'; + +export interface IGlobalState { + initialized: boolean; + + i18nLoaded: boolean; +} + +export interface IParamsState { + shareId: string; + + shareCode: string; + + currentUser: any; + + setting: SettingConfig; +} + +export interface ITerminalConfig { + // 主题 + themeName: string; + + // 快速粘贴 + quickPaste: string; + + // Ctrl + ctrlCAsCtrlZ: string; + + // 退格键 + backspaceAsCtrlH: string; + + // 字体大小 + fontSize: number; + + // 行高 + lineHeight: number; + + // 字体 + fontFamily: string; + + // 是否开启 Zmodem + enableZmodem: boolean; + + // 当前 Zmodem 状态 + zmodemStatus: boolean; + + // 当前页签 + currentTab: string; + + termSelectionText: string; +} + +export interface ITreeState { + connectInfo: any; + + treeNodes: customTreeOption[]; + + currentNode: customTreeOption; + + root: customTreeOption; + + isLoaded: boolean; + + terminalMap: Map; +} + +export type ObjToKeyValArray = { + [K in keyof T]: [K, T[K]]; +}[keyof T]; diff --git a/ui/src/types/modules/table.type.ts b/ui/src/types/modules/table.type.ts new file mode 100644 index 000000000..42c8f88bb --- /dev/null +++ b/ui/src/types/modules/table.type.ts @@ -0,0 +1,8 @@ +export interface RowData { + is_dir: boolean; + mod_time: string; + name: string; + perm: string; + size: string; + type: string; +} diff --git a/ui/src/types/modules/terminal.type.ts b/ui/src/types/modules/terminal.type.ts new file mode 100644 index 000000000..98a6a96dd --- /dev/null +++ b/ui/src/types/modules/terminal.type.ts @@ -0,0 +1,25 @@ +export interface ITerminalSettings { + // 终端字体大小 + fontSize: number; + + // 终端行高 + lineHeight: number; + + // 终端字体 + fontFamily: string; + + // 终端主题 + themeName: string; + + // 是否启用 Ctrl+C 作为 Ctrl+Z + ctrlCAsCtrlZ: string; + + // 是否启用快速粘贴 + quickPaste: string; + + // 是否启用退格键作为 Ctrl+H + backspaceAsCtrlH: string; + + // 主题 + theme: string; +} diff --git a/ui/src/types/modules/user.type.ts b/ui/src/types/modules/user.type.ts new file mode 100644 index 000000000..3ec345856 --- /dev/null +++ b/ui/src/types/modules/user.type.ts @@ -0,0 +1,17 @@ +export interface OnlineUser { + user_id: string; + user: string; + created: string; + remote_addr: string; + terminal_id: string; + primary: boolean; + writable: boolean; +} + +export interface ShareUserOptions { + id: string; + + name: string; + + username: string; +} diff --git a/ui/src/utils/common.js b/ui/src/utils/common.js deleted file mode 100644 index 57c62fe7e..000000000 --- a/ui/src/utils/common.js +++ /dev/null @@ -1,112 +0,0 @@ -const scheme = document.location.protocol === "https:" ? "wss" : "ws"; -const port = document.location.port ? ':' + document.location.port : ''; -const BASE_WS_URL = scheme + '://' + document.location.hostname + port; -const BASE_URL = document.location.protocol + '//' + document.location.hostname + port; -export {BASE_WS_URL, BASE_URL} - -export function decodeToStr(octets) { - if (typeof TextEncoder == "function") { - return new TextDecoder("utf-8").decode(new Uint8Array(octets)) - } - return decodeURIComponent(escape(String.fromCharCode.apply(null, octets))); -} - -export function fireEvent(e) { - window.dispatchEvent(e) -} - -export function bytesHuman(bytes, precision) { - if (!/^([-+])?|(\.\d+)(\d+(\.\d+)?|(\d+\.)|Infinity)$/.test(bytes)) { - return '-' - } - if (bytes === 0) return '0'; - if (typeof precision === 'undefined') precision = 1; - const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'BB']; - const num = Math.floor(Math.log(bytes) / Math.log(1024)); - const value = (bytes / Math.pow(1024, Math.floor(num))).toFixed(precision); - return `${value} ${units[num]}` -} - -export function CopyTextToClipboard(text) { - let transfer = document.createElement('textarea'); - document.body.appendChild(transfer); - transfer.value = text; - transfer.focus(); - transfer.select(); - document.execCommand('copy'); - document.body.removeChild(transfer); -} - -// 使用 ES6 的函数默认值方式设置参数的默认取值 -// 具体参见 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Functions/Default_parameters - -export function canvasWaterMark({ - container = document.body, - width = 300, - height = 300, - textAlign = 'center', - textBaseline = 'middle', - alpha = 0.3, - font = '20px monaco, microsoft yahei', - fillStyle = 'rgba(184, 184, 184, 0.8)', - content = 'JumpServer', - rotate = -45, - zIndex = 1000 - }) { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - - canvas.width = width; - canvas.height = height; - ctx.globalAlpha = 0.5; - - ctx.font = font; - ctx.fillStyle = fillStyle; - ctx.textAlign = textAlign; - ctx.textBaseline = textBaseline; - ctx.globalAlpha = alpha; - - ctx.translate(0.5 * width, 0.5 * height); - ctx.rotate((rotate * Math.PI) / 180); - - function generateMultiLineText(_ctx, _text, _width, _lineHeight) { - const words = _text.split('\n'); - let line = ''; - const x = 0; - let y = 0; - - for (let n = 0; n < words.length; n++) { - line = words[n]; - line = truncateCenter(line, 25); - _ctx.fillText(line, x, y); - y += _lineHeight; - } - } - - generateMultiLineText(ctx, content, width, 24); - - const base64Url = canvas.toDataURL(); - const watermarkDiv = document.createElement('div'); - watermarkDiv.setAttribute('style', ` - position:absolute; - top:0; - left:0; - width:100%; - height:100%; - z-index:${zIndex}; - pointer-events:none; - background-repeat:repeat; - background-image:url('${base64Url}')` - ); - - container.style.position = 'relative'; - container.insertBefore(watermarkDiv, container.firstChild); -} - -function truncateCenter(s, l) { - if (s.length <= l) { - return s; - } - const centerIndex = Math.ceil(l / 2); - return s.slice(0, centerIndex - 2) + '...' + s.slice(centerIndex + 1, l); -} \ No newline at end of file diff --git a/ui/src/utils/config.ts b/ui/src/utils/config.ts new file mode 100644 index 000000000..5f1ee5f34 --- /dev/null +++ b/ui/src/utils/config.ts @@ -0,0 +1,179 @@ +import { useCookies } from 'vue3-cookies'; + +const PORT = document.location.port ? `:${document.location.port}` : ''; +const SCHEME = document.location.protocol === 'https:' ? 'wss' : 'ws'; + +export const BASE_WS_URL = `${SCHEME}://${document.location.hostname}${PORT}`; +export const BASE_URL = `${document.location.protocol}//${document.location.hostname}${PORT}`; + +const { cookies } = useCookies(); + +const storeLang = cookies.get('lang'); +const cookieLang = cookies.get('django_language'); + +const browserLang = navigator.language || (navigator.languages && navigator.languages[0]) || 'en'; + +export const LanguageCode = cookieLang || storeLang || browserLang || 'en'; +export const ThemeCode = localStorage.getItem('themeType') || 'default'; + +export const AsciiDel = 127; +export const AsciiBackspace = 8; +export const AsciiCtrlC = 3; +export const AsciiCtrlZ = 26; + +export const MaxTimeout = 30 * 1000; + +export const MAX_TRANSFER_SIZE = 1024 * 1024 * 500; + +export const defaultTheme = { + background: '#121414', + foreground: '#ffffff', + black: '#2e3436', + red: '#cc0000', + green: '#4e9a06', + yellow: '#c4a000', + blue: '#3465a4', + magenta: '#75507b', + cyan: '#06989a', + white: '#d3d7cf', + brightBlack: '#555753', + brightRed: '#ef2929', + brightGreen: '#8ae234', + brightYellow: '#fce94f', + brightBlue: '#729fcf', + brightMagenta: '#ad7fa8', + brightCyan: '#34e2e2', + brightWhite: '#eeeeec', +}; + +// 图片类型的 +export const FILE_SUFFIX_IMAGE = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'svg', 'heic', 'heif']; +// 音频类型的 +export const FILE_SUFFIX_AUDIO = ['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac', 'm4b', 'm4p', 'm4b', 'm4p', 'm4b', 'm4p']; +// 视频类型的 +export const FILE_SUFFIX_VIDEO = [ + 'mp4', + 'avi', + 'mov', + 'wmv', + 'flv', + 'mpeg', + 'mpg', + 'm4v', + 'mkv', + 'webm', + 'vob', + 'm2ts', + 'mts', + 'ts', + 'm2t', + 'm2ts', + 'mts', + 'ts', + 'm2t', + 'm2ts', +]; +// 压缩包类型的 +export const FILE_SUFFIX_COMPRESSION = [ + 'zip', + 'rar', + '7z', + 'tar', + 'gz', + 'bz2', + 'iso', + 'dmg', + 'pkg', + 'deb', + 'rpm', + 'msi', + 'exe', + 'app', + 'dmg', + 'pkg', + 'deb', + 'rpm', + 'msi', + 'exe', + 'app', +]; +// 文档类型的 +export const FILE_SUFFIX_DOCUMENT = [ + 'doc', + 'docx', + 'xls', + 'xlsx', + 'ppt', + 'pptx', + 'pdf', + 'txt', + 'md', + 'csv', + 'json', + 'xml', + 'yaml', + 'yml', + 'toml', + 'ini', + 'conf', + 'cfg', + 'config', + 'log', + 'yml', + 'toml', + 'ini', + 'conf', + 'cfg', + 'config', + 'log', + 'lock', + 'sock', +]; +// 代码类型的 +export const FILE_SUFFIX_CODE = [ + 'js', + 'ts', + 'py', + 'java', + 'c', + 'cpp', + 'h', + 'hpp', + 'css', + 'html', + 'php', + 'ruby', + 'go', + 'rust', + 'swift', + 'kotlin', + 'dart', + 'scala', + 'haskell', + 'erlang', + 'elixir', + 'ocaml', + 'erlang', + 'elixir', + 'ocaml', + 'erlang', + 'elixir', + 'ocaml', + 'erlang', + 'elixir', + 'ocaml', +]; +// 安装包类型的 +export const FILE_SUFFIX_INSTALL = ['deb', 'rpm', 'msi', 'exe', 'app', 'dmg', 'pkg', 'deb', 'rpm', 'msi', 'exe', 'app']; +// 数据库类型 +export const FILE_SUFFIX_DATABASE = [ + 'mysql', + 'oracle', + 'postgresql', + 'sqlserver', + 'mongodb', + 'redis', + 'memcached', + 'sqlite', + 'mariadb', +]; diff --git a/ui/src/utils/guard.ts b/ui/src/utils/guard.ts new file mode 100644 index 000000000..4d7483eb1 --- /dev/null +++ b/ui/src/utils/guard.ts @@ -0,0 +1,96 @@ +import type { NavigationGuardNext } from 'vue-router'; + +import type { ITerminalSettings } from '@/types/modules/terminal.type'; +import type { CommandLineConfig, ILocalTerminalConfig } from '@/types/modules/guard.type.ts'; + +import { useTerminalSettingsStore } from '@/store/modules/terminalSettings.ts'; + +/** + * @description 获取本地 Terminal 配置 + */ +function getLocalKokoSetting() { + const terminalSettingsStore = useTerminalSettingsStore(); + const localTerminalSetting = localStorage.getItem('LunaSetting'); + + const { setDefaultTerminalConfig } = terminalSettingsStore; + + if (localTerminalSetting) { + const parsedSetting: ILocalTerminalConfig = JSON.parse(localTerminalSetting); + const commandLine: CommandLineConfig = parsedSetting.command_line; + + let fontSize = 13; + + if (commandLine) { + fontSize = commandLine.character_terminal_font_size; + setDefaultTerminalConfig('quickPaste', commandLine.is_right_click_quickly_paste ? '1' : '0'); + setDefaultTerminalConfig('backspaceAsCtrlH', commandLine.is_backspace_as_ctrl_h ? '1' : '0'); + } + + if (!fontSize || fontSize < 5 || fontSize > 50) { + fontSize = 13; + } + + setDefaultTerminalConfig('fontSize', fontSize); + } +} + +export function guard(next: NavigationGuardNext) { + try { + getLocalKokoSetting(); + next(); + } catch (error) { + throw new Error(`Initialization failed: ${error}`); + } +} + +export function getLocalDefaultKokoSetting(): CommandLineConfig { + const localTerminalSetting = localStorage.getItem('LunaSetting'); + const defaultCommandLine: CommandLineConfig = { + character_terminal_font_size: 13, + is_backspace_as_ctrl_h: false, + is_right_click_quickly_paste: true, + terminal_theme_name: 'Default', + }; + + if (localTerminalSetting) { + const parsedSetting: ILocalTerminalConfig = JSON.parse(localTerminalSetting); + const commandLine: CommandLineConfig = parsedSetting.command_line; + + let fontSize = 13; + + if (commandLine) { + const fontSize = commandLine.character_terminal_font_size; + const is_backspace_as_ctrl_h = commandLine.is_backspace_as_ctrl_h; + const is_right_click_quickly_paste = commandLine.is_right_click_quickly_paste; + const terminal_theme_name = commandLine.terminal_theme_name; + + defaultCommandLine.character_terminal_font_size = fontSize || 13; + defaultCommandLine.is_backspace_as_ctrl_h = is_backspace_as_ctrl_h || false; + defaultCommandLine.terminal_theme_name = terminal_theme_name || 'Default'; + defaultCommandLine.is_right_click_quickly_paste = is_right_click_quickly_paste + ? is_right_click_quickly_paste + : false; + } + + if (!fontSize || fontSize < 5 || fontSize > 50) { + fontSize = 13; + } + } + + return defaultCommandLine; +} + +export function getDefaultTerminalConfig(): ITerminalSettings { + const defaultCommandLine = getLocalDefaultKokoSetting(); + + return { + fontSize: defaultCommandLine.character_terminal_font_size, + lineHeight: 1.2, + fontFamily: 'monaco, Consolas, "Lucida Console", monospace', + themeName: defaultCommandLine.terminal_theme_name, + quickPaste: defaultCommandLine.is_right_click_quickly_paste ? '1' : '0', + ctrlCAsCtrlZ: '0', + backspaceAsCtrlH: defaultCommandLine.is_backspace_as_ctrl_h ? '1' : '0', + theme: defaultCommandLine.terminal_theme_name, + }; +} diff --git a/ui/src/utils/index.ts b/ui/src/utils/index.ts new file mode 100644 index 000000000..213f61e02 --- /dev/null +++ b/ui/src/utils/index.ts @@ -0,0 +1,118 @@ +import type { Terminal } from '@xterm/xterm'; + +import { createDiscreteApi } from 'naive-ui'; + +import type { TranslateFunction } from '@/types'; +import type { ILunaConfig } from '@/types/modules/config.type'; +import type { RowData } from '@/components/Drawer/components/FileManagement/index.vue'; + +import { AsciiBackspace, AsciiCtrlC, AsciiCtrlZ, AsciiDel } from '@/utils/config'; + +const { message } = createDiscreteApi(['message']); + +/** + * @description 获取分钟标签 + * @param item + * @param t + */ +export function getMinuteLabel(item: number, t: TranslateFunction): string { + let minuteLabel = t('Minute'); + + if (item > 1) { + minuteLabel = t('Minutes'); + } + + return `${item} ${minuteLabel}`; +} + +/** + * @description 将缓冲区写入终端 + * @param enableZmodem + * @param zmodemStatus + * @param terminal + * @param data + */ +export function writeBufferToTerminal( + enableZmodem: boolean, + zmodemStatus: boolean, + terminal: Terminal | null, + data: any +) { + if (!enableZmodem && zmodemStatus) return message.error('未开启 Zmodem 且当前在 Zmodem 状态, 不允许显示'); + if (!terminal) return; + terminal.write(new Uint8Array(data)); +} + +export function preprocessInput(data: string, config: Partial) { + // 如果配置项 backspaceAsCtrlH 启用(值为 "1"),并且输入数据包含删除键的 ASCII 码 (AsciiDel,即 127), + // 它会将其替换为退格键的 ASCII 码 (AsciiBackspace,即 8) + if (config.backspaceAsCtrlH === '1') { + if (data.charCodeAt(0) === AsciiDel) { + data = String.fromCharCode(AsciiBackspace); + } + } + + if (config.ctrlCAsCtrlZ === '1') { + if (data.charCodeAt(0) === AsciiCtrlC) { + data = String.fromCharCode(AsciiCtrlZ); + } + } + + // 使用字符串替换方法避免在正则表达式中使用控制字符 + // const escSeq200 = '\u001B[200~'; + // const escSeq201 = '\u001B[201~'; + + // if (data.includes(escSeq200) || data.includes(escSeq201)) { + // return data.replace(escSeq200, '').replace(escSeq201, ''); + // } + + return data; +} + +/** + * @description 处理文件名称 + * @param row + */ +export function getFileName(row: RowData) { + if (row.is_dir) { + return 'Folder'; + } + + const lastDotIndex = row.name.lastIndexOf('.'); + + return lastDotIndex !== -1 ? row.name.slice(lastDotIndex + 1) : 'File'; +} + +/** + * @description 使用 postMessage 发送事件到父窗口。 + * + * @param {string} name - 事件的名称。 + * @param {any} data - 要随事件发送的数据。 + * @param {string | null} [lunaId] - Luna 实例的 ID。 + * @param {string | null} [origin] - 消息的来源。 + */ +export function sendEventToLuna(name: string, data: any, lunaId: string | null = '', origin: string | null = '') { + if (lunaId !== null && origin !== null) { + try { + window.parent.postMessage({ name, id: lunaId, data }, origin); + } catch (e) { + console.error(e); + } + } +} + +/** + * @description 格式化消息为 JSON 字符串。 + * + * @param id - 消息的 ID。 + * @param type - 消息的类型。 + * @param data - 消息的数据。 + * @returns 格式化的 JSON 字符串。 + */ +export function formatMessage(id: string, type: string, data: any) { + return JSON.stringify({ + id, + type, + data, + }); +} diff --git a/ui/src/utils/lunaBus.ts b/ui/src/utils/lunaBus.ts new file mode 100644 index 000000000..2b77c1aaf --- /dev/null +++ b/ui/src/utils/lunaBus.ts @@ -0,0 +1,114 @@ +import type { Emitter } from 'mitt'; + +import mitt from 'mitt'; + +import type { LunaMessage, LunaMessageEvents } from '@/types/modules/postmessage.type'; + +import { LUNA_MESSAGE_TYPE } from '@/types/modules/message.type'; + +// 获取所有事件类型 +export type LunaEventType = keyof LunaMessageEvents; + +// 创建事件-数据映射类型 +type EventPayloadMap = { + [K in LunaEventType]: LunaMessageEvents[K]['data'] extends undefined ? void : LunaMessageEvents[K]['data']; +}; + +const allEventTypes = Object.keys(LUNA_MESSAGE_TYPE) as LunaEventType[]; + +export class LunaCommunicator { + private mitt: Emitter; + private lunaId: string = ''; + private targetOrigin: string = '*'; + + disbaleFileManager = false; + + constructor() { + this.mitt = mitt(); + this.setupMessageListener(); + } + + public getLunaId() { + return this.lunaId; + } + + public getTargetOrigin() { + return this.targetOrigin; + } + + private setupMessageListener() { + window.addEventListener('message', (event: MessageEvent) => { + const message: LunaMessage = event.data; + + switch (message.name) { + case LUNA_MESSAGE_TYPE.PING: { + this.lunaId = message.id; + this.disbaleFileManager = message.disbaleFileManager; + + this.targetOrigin = event.origin; + this.sendLuna(LUNA_MESSAGE_TYPE.PONG, ''); + + // 发送 PING 事件,让组件能够监听到 + const eventType = message.name as keyof T; + const data = message as T[keyof T]; + this.mitt.emit(eventType, data); + break; + } + default: + if (allEventTypes.includes(message.name as LunaEventType)) { + const eventType = message.name as keyof T; + const data = message as T[keyof T]; + + this.mitt.emit(eventType, data); + } else { + console.warn(`Unhandled message type: ${message.name}`, message); + } + } + }); + } + + // 发送消息到目标窗口 + public sendLuna(name: K, data: T[K]) { + if (!this.lunaId || !this.targetOrigin) { + console.warn('Target window not set'); + } + + window.parent.postMessage({ name, id: this.lunaId, data }, this.targetOrigin); + } + + // 监听事件 + public onLuna(type: K, handler: (data: T[K]) => void) { + this.mitt.on(type, handler); + } + + // 移除监听器 + public offLuna(type: K, handler?: (data: T[K]) => void) { + this.mitt.off(type, handler); + } + + // 监听一次性事件 + public once(type: K, handler: (data: T[K]) => void) { + const onceHandler = (data: T[K]) => { + handler(data); + this.offLuna(type, onceHandler); + }; + this.onLuna(type, onceHandler); + } + + // 获取是否禁用文件管理器 + public getDisbaleFileManager() { + return this.disbaleFileManager; + } + + // 销毁实例 + public destroy() { + this.mitt.all.clear(); + } + + // 获取所有事件类型 + public getEventTypes(): Array { + return Object.keys(this.mitt.all) as Array; + } +} + +export const lunaCommunicator = new LunaCommunicator(); diff --git a/ui/src/utils/mittBus.ts b/ui/src/utils/mittBus.ts new file mode 100644 index 000000000..ca311f82b --- /dev/null +++ b/ui/src/utils/mittBus.ts @@ -0,0 +1,57 @@ +import type { Ref } from 'vue'; +import type { UploadFileInfo } from 'naive-ui'; + +import mitt from 'mitt'; + +import type { ManageTypes } from '@/hooks/useFileManage.ts'; +import type { ShareUserOptions } from '@/types/modules/user.type'; +import type { customTreeOption } from '@/types/modules/config.type'; + +interface Event { + 'remove-event': void; + 'alt-shift-right': void; + 'alt-shift-left': void; + 'open-setting': void; + 'reload-table': void; + 'open-fileList': void; + 'fold-tree-click': void; + 'show-theme-config': void; + 'set-Terminal-theme': string; + 'connect-terminal': customTreeOption; + 'set-theme': { themeName: string }; + 'file-manage': { path: string; type: ManageTypes; new_name?: string }; + 'file-upload': { + uploadFileList: Ref>; + onFinish: () => void; + onError: () => void; + onProgress: (e: { percent: number }) => void; + loadingMessage?: any; + }; + 'download-file': { path: string; is_dir: boolean; size: string }; + 'stop-upload': { fileInfo: UploadFileInfo }; + 'upload-stopped': { fileInfo: UploadFileInfo }; + 'terminal-search': { keyword: string; type?: string }; + 'share-user': { type: string; query: string }; + 'sync-theme': { type: string; data: any }; + 'remove-share-user': { sessionId: string; userMeta: any; type: string }; + 'create-share-url': { + type: string; + sessionId: string; + shareLinkRequest: { + expiredTime: number; + actionPerm: string; + users: ShareUserOptions[]; + }; + }; + writeDataToTerminal: { type: string }; + 'write-command': { type: string }; + 'open-search': void; + 'file-manager-expired': void; + 'connect-error': void; + 'close-drawer': void; +} + +// @ts-expect-error mittBus is not typed +const mittBus = mitt(); + +export default mittBus; diff --git a/ui/src/views/Connection.vue b/ui/src/views/Connection.vue deleted file mode 100644 index 0f03f60c2..000000000 --- a/ui/src/views/Connection.vue +++ /dev/null @@ -1,248 +0,0 @@ - - - - - \ No newline at end of file diff --git a/ui/src/views/Monitor.vue b/ui/src/views/Monitor.vue deleted file mode 100644 index 068b25986..000000000 --- a/ui/src/views/Monitor.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - - - \ No newline at end of file diff --git a/ui/src/views/ShareTerminal.vue b/ui/src/views/ShareTerminal.vue deleted file mode 100644 index d4462a4d1..000000000 --- a/ui/src/views/ShareTerminal.vue +++ /dev/null @@ -1,177 +0,0 @@ - - - - - \ No newline at end of file diff --git a/ui/src/views/connection/index.vue b/ui/src/views/connection/index.vue new file mode 100644 index 000000000..0eb4cc223 --- /dev/null +++ b/ui/src/views/connection/index.vue @@ -0,0 +1,19 @@ + + + diff --git a/ui/src/views/file/index.vue b/ui/src/views/file/index.vue new file mode 100644 index 000000000..a2c371e45 --- /dev/null +++ b/ui/src/views/file/index.vue @@ -0,0 +1,12 @@ + + + diff --git a/ui/src/views/kubernetes/index.scss b/ui/src/views/kubernetes/index.scss new file mode 100644 index 000000000..01e127dff --- /dev/null +++ b/ui/src/views/kubernetes/index.scss @@ -0,0 +1,32 @@ +.custom-layout { + height: 100vh; + + :deep(.n-layout-header) { + background-color: var(--nav-header-bg-color) !important; + } + + :deep(.n-layout-scroll-container) { + .n-layout-sider { + background-color: var(--sidebar-bg-color) !important; + + .n-scrollbar .n-scrollbar-container .n-scrollbar-content { + padding: 0 !important; + } + + &::after { + position: absolute; + top: 0; + right: 0; + width: 10px; + height: 100%; + cursor: ew-resize; + content: ''; + } + + // 设置折叠状态下 padding 为零,否则侧边 item 图标无法点击 + &.n-layout-sider--collapsed .n-layout-sider-scroll-container { + padding: 0 !important; + } + } + } +} diff --git a/ui/src/views/kubernetes/index.vue b/ui/src/views/kubernetes/index.vue new file mode 100644 index 000000000..f5ee29109 --- /dev/null +++ b/ui/src/views/kubernetes/index.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/ui/src/views/monitor/index.vue b/ui/src/views/monitor/index.vue new file mode 100644 index 000000000..4c656a459 --- /dev/null +++ b/ui/src/views/monitor/index.vue @@ -0,0 +1,12 @@ + + + diff --git a/ui/src/views/share/index.vue b/ui/src/views/share/index.vue new file mode 100644 index 000000000..f9ab16b9c --- /dev/null +++ b/ui/src/views/share/index.vue @@ -0,0 +1,79 @@ + + + diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json new file mode 100644 index 000000000..dbcafb539 --- /dev/null +++ b/ui/tsconfig.app.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "jsx": "preserve", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "moduleDetection": "force", + "useDefineForClassFields": true, + + "baseUrl": ".", + "module": "ESNext", + + /* Bundler mode */ + "moduleResolution": "bundler", + "paths": { + "@/*": [ + "./src/*" + ] + }, + "resolveJsonModule": true, + "types": ["vue"], + "allowImportingTsExtensions": true, + /* Linting */ + "strict": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "exclude": ["node_modules"] +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 000000000..31a62df21 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ], + "files": [] +} diff --git a/ui/tsconfig.node.json b/ui/tsconfig.node.json new file mode 100644 index 000000000..02098fd65 --- /dev/null +++ b/ui/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true + }, + "include": ["vite.config.ts"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 000000000..0f37c866e --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,95 @@ +import type { ConfigEnv, UserConfig } from 'vite'; + +import process from 'node:process'; +import { resolve } from 'node:path'; + +import vue from '@vitejs/plugin-vue'; +import tailwindcss from '@tailwindcss/vite'; +import vueJsx from '@vitejs/plugin-vue-jsx'; +import { defineConfig, loadEnv } from 'vite'; +import Components from 'unplugin-vue-components/vite'; +import { manualChunksPlugin } from 'vite-plugin-webpackchunkname'; +import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'; + +function pathResolve(dir: string): string { + return resolve(__dirname, '.', dir); +} + +export default defineConfig(({ mode }: ConfigEnv): UserConfig => { + const root = process.cwd(); + const env = loadEnv(mode, root); + + return { + plugins: [ + vue(), + vueJsx(), + tailwindcss(), + manualChunksPlugin(), + Components({ dts: true, resolvers: [NaiveUiResolver()] }), + ], + resolve: { + extensions: ['.js', '.ts', '.tsx', '.vue', '.json'], + alias: { + '@': pathResolve('src'), + }, + }, + base: env.VITE_PUBLIC_PATH, + server: { + port: 9530, + // port: 9527, + proxy: { + '^/koko/ws/': { + target: env.VITE_KOKO_WS_URL, + ws: true, + changeOrigin: true, + }, + '^/api/': { + target: env.VITE_KOKO_API_URL, + ws: true, + changeOrigin: true, + }, + '^/static/': { + target: env.VITE_KOKO_STATIC_URL, + ws: true, + changeOrigin: true, + }, + }, + }, + build: { + assetsDir: 'assets', + outDir: 'dist', + terserOptions: { + compress: { + drop_console: true, + drop_debugger: true, + }, + }, + // 关闭文件计算 + reportCompressedSize: false, + sourcemap: false, + minify: false, + cssCodeSplit: true, + rollupOptions: { + output: { + entryFileNames: `assets/js/[name]-[hash].js`, + chunkFileNames: `assets/js/[name]-[hash].js`, + assetFileNames: `assets/[ext]/[name]-[hash].[ext]`, + manualChunks(id) { + if (id.includes('node_modules')) { + // 把 naive-ui 核心模块打包成一个文件 + if (id.includes('naive-ui')) { + return 'naive-vendor'; + } + + if (id.includes('@xterm/xterm')) { + return 'xterm-vendor'; + } + + return id.toString().split('node_modules/')[1].split('/')[0].toString(); + } + }, + }, + }, + }, + }; +}); diff --git a/ui/vue.config.js b/ui/vue.config.js deleted file mode 100644 index c0b805209..000000000 --- a/ui/vue.config.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = { - publicPath: '/koko/', - outputDir: 'dist', - assetsDir: 'assets', - devServer: { - port: 9530, - proxy: { - '^/koko/ws/': { - target: 'http://127.0.0.1:5000/', - ws: true, - changeOrigin: true - } - } - }, - chainWebpack(config) { - } -} diff --git a/ui/yarn.lock b/ui/yarn.lock new file mode 100644 index 000000000..0c1127d52 --- /dev/null +++ b/ui/yarn.lock @@ -0,0 +1,4236 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@alova/shared@1.2.0": + version "1.2.0" + resolved "https://registry.npmmirror.com/@alova/shared/-/shared-1.2.0.tgz" + integrity sha512-/LSlP4VqpD+ji7+BwTwfKJObDb0LV3mNT6sE3TUjnopjz2HQaL6f1Qicjf8vplv4dlHki9HEbFFAu8sBNDAMrA== + +"@ampproject/remapping@^2.2.0", "@ampproject/remapping@^2.3.0": + version "2.3.0" + resolved "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz" + integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@antfu/eslint-config@^4.16.1": + version "4.16.1" + resolved "https://registry.yarnpkg.com/@antfu/eslint-config/-/eslint-config-4.16.1.tgz#4dfb801f5c3642e56980e0face74140892d0b7be" + integrity sha512-20hA+bjnEmYnZChnQFM9ugPF+FR5N2yd6UNUjhZSmTeYpaKnkJ1EvZyEWxnmVGKC5O5HNDEJY3BXUQymdOoftQ== + dependencies: + "@antfu/install-pkg" "^1.1.0" + "@clack/prompts" "^0.11.0" + "@eslint-community/eslint-plugin-eslint-comments" "^4.5.0" + "@eslint/markdown" "^6.6.0" + "@stylistic/eslint-plugin" "^5.0.0" + "@typescript-eslint/eslint-plugin" "^8.34.1" + "@typescript-eslint/parser" "^8.34.1" + "@vitest/eslint-plugin" "^1.2.7" + ansis "^4.1.0" + cac "^6.7.14" + eslint-config-flat-gitignore "^2.1.0" + eslint-flat-config-utils "^2.1.0" + eslint-merge-processors "^2.0.0" + eslint-plugin-antfu "^3.1.1" + eslint-plugin-command "^3.3.1" + eslint-plugin-import-lite "^0.3.0" + eslint-plugin-jsdoc "^51.2.1" + eslint-plugin-jsonc "^2.20.1" + eslint-plugin-n "^17.20.0" + eslint-plugin-no-only-tests "^3.3.0" + eslint-plugin-perfectionist "^4.15.0" + eslint-plugin-pnpm "^0.3.1" + eslint-plugin-regexp "^2.9.0" + eslint-plugin-toml "^0.12.0" + eslint-plugin-unicorn "^59.0.1" + eslint-plugin-unused-imports "^4.1.4" + eslint-plugin-vue "^10.2.0" + eslint-plugin-yml "^1.18.0" + eslint-processor-vue-blocks "^2.0.0" + globals "^16.2.0" + jsonc-eslint-parser "^2.4.0" + local-pkg "^1.1.1" + parse-gitignore "^2.0.0" + toml-eslint-parser "^0.10.0" + vue-eslint-parser "^10.1.3" + yaml-eslint-parser "^1.3.0" + +"@antfu/install-pkg@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz#78fa036be1a6081b5a77a5cf59f50c7752b6ba26" + integrity sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ== + dependencies: + package-manager-detector "^1.3.0" + tinyexec "^1.0.1" + +"@antfu/utils@^0.7.10": + version "0.7.10" + resolved "https://registry.npmmirror.com/@antfu/utils/-/utils-0.7.10.tgz" + integrity sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww== + +"@babel/code-frame@^7.26.2", "@babel/code-frame@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.27.1.tgz" + integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== + dependencies: + "@babel/helper-validator-identifier" "^7.27.1" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/compat-data@^7.27.2": + version "7.27.5" + resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.27.5.tgz" + integrity sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg== + +"@babel/core@^7.27.1": + version "7.27.4" + resolved "https://registry.npmmirror.com/@babel/core/-/core-7.27.4.tgz" + integrity sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.27.3" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-module-transforms" "^7.27.3" + "@babel/helpers" "^7.27.4" + "@babel/parser" "^7.27.4" + "@babel/template" "^7.27.2" + "@babel/traverse" "^7.27.4" + "@babel/types" "^7.27.3" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.27.3": + version "7.27.5" + resolved "https://registry.npmmirror.com/@babel/generator/-/generator-7.27.5.tgz" + integrity sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw== + dependencies: + "@babel/parser" "^7.27.5" + "@babel/types" "^7.27.3" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^3.0.2" + +"@babel/helper-annotate-as-pure@^7.27.1": + version "7.27.3" + resolved "https://registry.npmmirror.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz" + integrity sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg== + dependencies: + "@babel/types" "^7.27.3" + +"@babel/helper-compilation-targets@^7.27.2": + version "7.27.2" + resolved "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz" + integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ== + dependencies: + "@babel/compat-data" "^7.27.2" + "@babel/helper-validator-option" "^7.27.1" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-create-class-features-plugin@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz" + integrity sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-member-expression-to-functions" "^7.27.1" + "@babel/helper-optimise-call-expression" "^7.27.1" + "@babel/helper-replace-supers" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/traverse" "^7.27.1" + semver "^6.3.1" + +"@babel/helper-member-expression-to-functions@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz" + integrity sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + +"@babel/helper-module-imports@^7.25.9", "@babel/helper-module-imports@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz" + integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + +"@babel/helper-module-transforms@^7.27.3": + version "7.27.3" + resolved "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz" + integrity sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg== + dependencies: + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@babel/traverse" "^7.27.3" + +"@babel/helper-optimise-call-expression@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz" + integrity sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw== + dependencies: + "@babel/types" "^7.27.1" + +"@babel/helper-plugin-utils@^7.26.5", "@babel/helper-plugin-utils@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz" + integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== + +"@babel/helper-replace-supers@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz" + integrity sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.27.1" + "@babel/helper-optimise-call-expression" "^7.27.1" + "@babel/traverse" "^7.27.1" + +"@babel/helper-skip-transparent-expression-wrappers@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz" + integrity sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-validator-identifier@^7.25.9", "@babel/helper-validator-identifier@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz" + integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== + +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== + +"@babel/helpers@^7.27.4": + version "7.27.4" + resolved "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.27.4.tgz" + integrity sha512-Y+bO6U+I7ZKaM5G5rDUZiYfUvQPUibYmAFe7EnKdnKBbVXDZxvp+MWOH5gYciY0EPk4EScsuFMQBbEfpdRKSCQ== + dependencies: + "@babel/template" "^7.27.2" + "@babel/types" "^7.27.3" + +"@babel/parser@^7.26.9", "@babel/parser@^7.27.2", "@babel/parser@^7.27.4", "@babel/parser@^7.27.5": + version "7.27.5" + resolved "https://registry.npmmirror.com/@babel/parser/-/parser-7.27.5.tgz" + integrity sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg== + dependencies: + "@babel/types" "^7.27.3" + +"@babel/plugin-syntax-jsx@^7.25.9": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz" + integrity sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-syntax-typescript@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz" + integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-typescript@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.1.tgz" + integrity sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-create-class-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/plugin-syntax-typescript" "^7.27.1" + +"@babel/template@^7.26.9", "@babel/template@^7.27.2": + version "7.27.2" + resolved "https://registry.npmmirror.com/@babel/template/-/template-7.27.2.tgz" + integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/parser" "^7.27.2" + "@babel/types" "^7.27.1" + +"@babel/traverse@^7.26.9", "@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3", "@babel/traverse@^7.27.4": + version "7.27.4" + resolved "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.27.4.tgz" + integrity sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.27.3" + "@babel/parser" "^7.27.4" + "@babel/template" "^7.27.2" + "@babel/types" "^7.27.3" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/types@^7.26.9", "@babel/types@^7.27.1", "@babel/types@^7.27.3": + version "7.27.3" + resolved "https://registry.npmmirror.com/@babel/types/-/types-7.27.3.tgz" + integrity sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + +"@clack/core@0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@clack/core/-/core-0.5.0.tgz#970df024a927d6af90111667a0384e233b5ffa1a" + integrity sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow== + dependencies: + picocolors "^1.0.0" + sisteransi "^1.0.5" + +"@clack/prompts@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@clack/prompts/-/prompts-0.11.0.tgz#5c0218f2b46626a50d72d8a485681eb8d94bd2a7" + integrity sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw== + dependencies: + "@clack/core" "0.5.0" + picocolors "^1.0.0" + sisteransi "^1.0.5" + +"@css-render/plugin-bem@^0.15.14": + version "0.15.14" + resolved "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz" + integrity sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg== + +"@css-render/vue3-ssr@^0.15.10", "@css-render/vue3-ssr@^0.15.14": + version "0.15.14" + resolved "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz" + integrity sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g== + +"@emnapi/core@^1.4.3": + version "1.4.3" + resolved "https://registry.npmmirror.com/@emnapi/core/-/core-1.4.3.tgz#9ac52d2d5aea958f67e52c40a065f51de59b77d6" + integrity sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g== + dependencies: + "@emnapi/wasi-threads" "1.0.2" + tslib "^2.4.0" + +"@emnapi/runtime@^1.4.3": + version "1.4.3" + resolved "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.4.3.tgz#c0564665c80dc81c448adac23f9dfbed6c838f7d" + integrity sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.0.2", "@emnapi/wasi-threads@^1.0.2": + version "1.0.2" + resolved "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz#977f44f844eac7d6c138a415a123818c655f874c" + integrity sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA== + dependencies: + tslib "^2.4.0" + +"@emotion/hash@~0.8.0": + version "0.8.0" + resolved "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz" + integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== + +"@es-joy/jsdoccomment@^0.50.2": + version "0.50.2" + resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.50.2.tgz#707768f0cb62abe0703d51aa9086986d230a5d5c" + integrity sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA== + dependencies: + "@types/estree" "^1.0.6" + "@typescript-eslint/types" "^8.11.0" + comment-parser "1.4.1" + esquery "^1.6.0" + jsdoc-type-pratt-parser "~4.1.0" + +"@es-joy/jsdoccomment@~0.52.0": + version "0.52.0" + resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.52.0.tgz#106945b6d1abed89597aa104b80ff8f9fb7038a6" + integrity sha512-BXuN7BII+8AyNtn57euU2Yxo9yA/KUDNzrpXyi3pfqKmBhhysR6ZWOebFh3vyPoqA3/j1SOvGgucElMGwlXing== + dependencies: + "@types/estree" "^1.0.8" + "@typescript-eslint/types" "^8.34.1" + comment-parser "1.4.1" + esquery "^1.6.0" + jsdoc-type-pratt-parser "~4.1.0" + +"@esbuild/aix-ppc64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18" + integrity sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA== + +"@esbuild/android-arm64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz#bc766407f1718923f6b8079c8c61bf86ac3a6a4f" + integrity sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg== + +"@esbuild/android-arm@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.5.tgz#4290d6d3407bae3883ad2cded1081a234473ce26" + integrity sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA== + +"@esbuild/android-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.5.tgz#40c11d9cbca4f2406548c8a9895d321bc3b35eff" + integrity sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw== + +"@esbuild/darwin-arm64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz#49d8bf8b1df95f759ac81eb1d0736018006d7e34" + integrity sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ== + +"@esbuild/darwin-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz#e27a5d92a14886ef1d492fd50fc61a2d4d87e418" + integrity sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ== + +"@esbuild/freebsd-arm64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz#97cede59d638840ca104e605cdb9f1b118ba0b1c" + integrity sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw== + +"@esbuild/freebsd-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz#71c77812042a1a8190c3d581e140d15b876b9c6f" + integrity sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw== + +"@esbuild/linux-arm64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz#f7b7c8f97eff8ffd2e47f6c67eb5c9765f2181b8" + integrity sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg== + +"@esbuild/linux-arm@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz#2a0be71b6cd8201fa559aea45598dffabc05d911" + integrity sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw== + +"@esbuild/linux-ia32@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz#763414463cd9ea6fa1f96555d2762f9f84c61783" + integrity sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA== + +"@esbuild/linux-loong64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz#428cf2213ff786a502a52c96cf29d1fcf1eb8506" + integrity sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg== + +"@esbuild/linux-mips64el@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz#5cbcc7fd841b4cd53358afd33527cd394e325d96" + integrity sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg== + +"@esbuild/linux-ppc64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz#0d954ab39ce4f5e50f00c4f8c4fd38f976c13ad9" + integrity sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ== + +"@esbuild/linux-riscv64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz#0e7dd30730505abd8088321e8497e94b547bfb1e" + integrity sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA== + +"@esbuild/linux-s390x@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz#5669af81327a398a336d7e40e320b5bbd6e6e72d" + integrity sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ== + +"@esbuild/linux-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz#b2357dd153aa49038967ddc1ffd90c68a9d2a0d4" + integrity sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw== + +"@esbuild/netbsd-arm64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz#53b4dfb8fe1cee93777c9e366893bd3daa6ba63d" + integrity sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw== + +"@esbuild/netbsd-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz#a0206f6314ce7dc8713b7732703d0f58de1d1e79" + integrity sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ== + +"@esbuild/openbsd-arm64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz#2a796c87c44e8de78001d808c77d948a21ec22fd" + integrity sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw== + +"@esbuild/openbsd-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz#28d0cd8909b7fa3953af998f2b2ed34f576728f0" + integrity sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg== + +"@esbuild/sunos-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz#a28164f5b997e8247d407e36c90d3fd5ddbe0dc5" + integrity sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA== + +"@esbuild/win32-arm64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz#6eadbead38e8bd12f633a5190e45eff80e24007e" + integrity sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw== + +"@esbuild/win32-ia32@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz#bab6288005482f9ed2adb9ded7e88eba9a62cc0d" + integrity sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ== + +"@esbuild/win32-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz#7fc114af5f6563f19f73324b5d5ff36ece0803d1" + integrity sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g== + +"@eslint-community/eslint-plugin-eslint-comments@^4.5.0": + version "4.5.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.5.0.tgz#4ffa576583bd99dfbaf74c893635e2c76acba048" + integrity sha512-MAhuTKlr4y/CE3WYX26raZjy+I/kS2PLKSzvfmDCGrBLTFHOYwqROZdr4XwPgXwX3K9rjzMr4pSmUWGnzsUyMg== + dependencies: + escape-string-regexp "^4.0.0" + ignore "^5.2.4" + +"@eslint-community/eslint-utils@^4.1.2", "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0", "@eslint-community/eslint-utils@^4.5.0", "@eslint-community/eslint-utils@^4.5.1", "@eslint-community/eslint-utils@^4.7.0": + version "4.7.0" + resolved "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz" + integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.11.0", "@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.8.0": + version "4.12.1" + resolved "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz" + integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== + +"@eslint/compat@^1.2.5": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@eslint/compat/-/compat-1.3.0.tgz#ce9dbd81e06c942a9570fbe3c2e10e3103f12c1c" + integrity sha512-ZBygRBqpDYiIHsN+d1WyHn3TYgzgpzLEcgJUxTATyiInQbKZz6wZb6+ljwdg8xeeOe4v03z6Uh6lELiw0/mVhQ== + +"@eslint/config-array@^0.20.0": + version "0.20.0" + resolved "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.20.0.tgz" + integrity sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ== + dependencies: + "@eslint/object-schema" "^2.1.6" + debug "^4.3.1" + minimatch "^3.1.2" + +"@eslint/config-helpers@^0.2.1": + version "0.2.2" + resolved "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.2.2.tgz" + integrity sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg== + +"@eslint/core@^0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.13.0.tgz#bf02f209846d3bf996f9e8009db62df2739b458c" + integrity sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/core@^0.14.0": + version "0.14.0" + resolved "https://registry.npmmirror.com/@eslint/core/-/core-0.14.0.tgz" + integrity sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/eslintrc@^3.3.1": + version "3.3.1" + resolved "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz" + integrity sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@9.28.0", "@eslint/js@^9.23.0": + version "9.28.0" + resolved "https://registry.npmmirror.com/@eslint/js/-/js-9.28.0.tgz" + integrity sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg== + +"@eslint/markdown@^6.6.0": + version "6.6.0" + resolved "https://registry.yarnpkg.com/@eslint/markdown/-/markdown-6.6.0.tgz#b9f226f9f464de161be7136e5c879239a4339631" + integrity sha512-IsWPy2jU3gaQDlioDC4sT4I4kG1hX1OMWs/q2sWwJrPoMASHW/Z4SDw+6Aql6EsHejGbagYuJbFq9Zvx+Y1b1Q== + dependencies: + "@eslint/core" "^0.14.0" + "@eslint/plugin-kit" "^0.3.1" + github-slugger "^2.0.0" + mdast-util-from-markdown "^2.0.2" + mdast-util-frontmatter "^2.0.1" + mdast-util-gfm "^3.0.0" + micromark-extension-frontmatter "^2.0.0" + micromark-extension-gfm "^3.0.0" + +"@eslint/object-schema@^2.1.6": + version "2.1.6" + resolved "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.6.tgz" + integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA== + +"@eslint/plugin-kit@^0.2.7": + version "0.2.8" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz#47488d8f8171b5d4613e833313f3ce708e3525f8" + integrity sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA== + dependencies: + "@eslint/core" "^0.13.0" + levn "^0.4.1" + +"@eslint/plugin-kit@^0.3.1": + version "0.3.1" + resolved "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz" + integrity sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w== + dependencies: + "@eslint/core" "^0.14.0" + levn "^0.4.1" + +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.6" + resolved "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.6.tgz" + integrity sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.3.0" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.3.0": + version "0.3.1" + resolved "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.3.1.tgz" + integrity sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA== + +"@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@intlify/core-base@11.1.5": + version "11.1.5" + resolved "https://registry.npmmirror.com/@intlify/core-base/-/core-base-11.1.5.tgz" + integrity sha512-xGRkISwV/2Trqb8yVQevlHm5roaQqy+75qwUzEQrviaQF0o4c5VDhjBW7WEGEoKFx09HSgq7NkvK/DAyuerTDg== + dependencies: + "@intlify/message-compiler" "11.1.5" + "@intlify/shared" "11.1.5" + +"@intlify/message-compiler@11.1.5": + version "11.1.5" + resolved "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-11.1.5.tgz" + integrity sha512-YLSBbjD7qUdShe3ZAat9Hnf9E8FRpN6qmNFD/x5Xg5JVXjsks0kJ90Zj6aAuyoppJQA/YJdWZ8/bB7k3dg2TjQ== + dependencies: + "@intlify/shared" "11.1.5" + source-map-js "^1.0.2" + +"@intlify/shared@11.1.5": + version "11.1.5" + resolved "https://registry.npmmirror.com/@intlify/shared/-/shared-11.1.5.tgz" + integrity sha512-+I4vRzHm38VjLr/CAciEPJhGYFzWWW4HMTm+6H3WqknXLh0ozNX9oC8ogMUwTSXYR/wGUb1/lTpNziiCH5MybQ== + +"@isaacs/fs-minipass@^4.0.0": + version "4.0.1" + resolved "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz" + integrity sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w== + dependencies: + minipass "^7.0.4" + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.8" + resolved "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz" + integrity sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.npmmirror.com/@jridgewell/set-array/-/set-array-1.2.1.tgz" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.0" + resolved "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@juggle/resize-observer@^3.3.1": + version "3.4.0" + resolved "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz" + integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== + +"@napi-rs/wasm-runtime@^0.2.10": + version "0.2.11" + resolved "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz#192c1610e1625048089ab4e35bc0649ce478500e" + integrity sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA== + dependencies: + "@emnapi/core" "^1.4.3" + "@emnapi/runtime" "^1.4.3" + "@tybys/wasm-util" "^0.9.0" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@parcel/watcher-android-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz#507f836d7e2042f798c7d07ad19c3546f9848ac1" + integrity sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA== + +"@parcel/watcher-darwin-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz" + integrity sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw== + +"@parcel/watcher-darwin-x64@2.5.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz#99f3af3869069ccf774e4ddfccf7e64fd2311ef8" + integrity sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg== + +"@parcel/watcher-freebsd-x64@2.5.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz#14d6857741a9f51dfe51d5b08b7c8afdbc73ad9b" + integrity sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ== + +"@parcel/watcher-linux-arm-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz#43c3246d6892381db473bb4f663229ad20b609a1" + integrity sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA== + +"@parcel/watcher-linux-arm-musl@2.5.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz#663750f7090bb6278d2210de643eb8a3f780d08e" + integrity sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q== + +"@parcel/watcher-linux-arm64-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz#ba60e1f56977f7e47cd7e31ad65d15fdcbd07e30" + integrity sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w== + +"@parcel/watcher-linux-arm64-musl@2.5.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz#f7fbcdff2f04c526f96eac01f97419a6a99855d2" + integrity sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg== + +"@parcel/watcher-linux-x64-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz#4d2ea0f633eb1917d83d483392ce6181b6a92e4e" + integrity sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A== + +"@parcel/watcher-linux-x64-musl@2.5.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz#277b346b05db54f55657301dd77bdf99d63606ee" + integrity sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg== + +"@parcel/watcher-win32-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz#7e9e02a26784d47503de1d10e8eab6cceb524243" + integrity sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw== + +"@parcel/watcher-win32-ia32@2.5.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz#2d0f94fa59a873cdc584bf7f6b1dc628ddf976e6" + integrity sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ== + +"@parcel/watcher-win32-x64@2.5.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz#ae52693259664ba6f2228fa61d7ee44b64ea0947" + integrity sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA== + +"@parcel/watcher@^2.4.1": + version "2.5.1" + resolved "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.1.tgz" + integrity sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg== + dependencies: + detect-libc "^1.0.3" + is-glob "^4.0.3" + micromatch "^4.0.5" + node-addon-api "^7.0.0" + optionalDependencies: + "@parcel/watcher-android-arm64" "2.5.1" + "@parcel/watcher-darwin-arm64" "2.5.1" + "@parcel/watcher-darwin-x64" "2.5.1" + "@parcel/watcher-freebsd-x64" "2.5.1" + "@parcel/watcher-linux-arm-glibc" "2.5.1" + "@parcel/watcher-linux-arm-musl" "2.5.1" + "@parcel/watcher-linux-arm64-glibc" "2.5.1" + "@parcel/watcher-linux-arm64-musl" "2.5.1" + "@parcel/watcher-linux-x64-glibc" "2.5.1" + "@parcel/watcher-linux-x64-musl" "2.5.1" + "@parcel/watcher-win32-arm64" "2.5.1" + "@parcel/watcher-win32-ia32" "2.5.1" + "@parcel/watcher-win32-x64" "2.5.1" + +"@pkgr/core@^0.2.4": + version "0.2.7" + resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.7.tgz#eb5014dfd0b03e7f3ba2eeeff506eed89b028058" + integrity sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg== + +"@rolldown/pluginutils@^1.0.0-beta.9": + version "1.0.0-beta.11" + resolved "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz" + integrity sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag== + +"@rollup/plugin-alias@*": + version "5.1.1" + resolved "https://registry.npmmirror.com/@rollup/plugin-alias/-/plugin-alias-5.1.1.tgz" + integrity sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ== + +"@rollup/pluginutils@*", "@rollup/pluginutils@^5.1.3": + version "5.1.4" + resolved "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.1.4.tgz" + integrity sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^4.0.2" + +"@rollup/rollup-android-arm-eabi@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.0.tgz#a3e4e4b2baf0bade6918cf5135c3ef7eee653196" + integrity sha512-xEiEE5oDW6tK4jXCAyliuntGR+amEMO7HLtdSshVuhFnKTYoeYMyXQK7pLouAJJj5KHdwdn87bfHAR2nSdNAUA== + +"@rollup/rollup-android-arm64@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.0.tgz#63566b0e76c62d4f96d44448f38a290562280200" + integrity sha512-uNSk/TgvMbskcHxXYHzqwiyBlJ/lGcv8DaUfcnNwict8ba9GTTNxfn3/FAoFZYgkaXXAdrAA+SLyKplyi349Jw== + +"@rollup/rollup-darwin-arm64@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz#60a51a61b22b1f4fdf97b4adf5f0f447f492759d" + integrity sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA== + +"@rollup/rollup-darwin-x64@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz#bfe3059440f7032de11e749ece868cd7f232e609" + integrity sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ== + +"@rollup/rollup-freebsd-arm64@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.0.tgz#d5d4c6cd3b8acb7493b76227d8b2b4a2d732a37b" + integrity sha512-u5AZzdQJYJXByB8giQ+r4VyfZP+walV+xHWdaFx/1VxsOn6eWJhK2Vl2eElvDJFKQBo/hcYIBg/jaKS8ZmKeNQ== + +"@rollup/rollup-freebsd-x64@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.0.tgz#cb4e1547b572cd0144c5fbd6c4a0edfed5fe6024" + integrity sha512-qC0kS48c/s3EtdArkimctY7h3nHicQeEUdjJzYVJYR3ct3kWSafmn6jkNCA8InbUdge6PVx6keqjk5lVGJf99g== + +"@rollup/rollup-linux-arm-gnueabihf@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.0.tgz#feb81bd086f6a469777f75bec07e1bdf93352e69" + integrity sha512-x+e/Z9H0RAWckn4V2OZZl6EmV0L2diuX3QB0uM1r6BvhUIv6xBPL5mrAX2E3e8N8rEHVPwFfz/ETUbV4oW9+lQ== + +"@rollup/rollup-linux-arm-musleabihf@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.0.tgz#68bff1c6620c155c9d8f5ee6a83c46eb50486f18" + integrity sha512-1exwiBFf4PU/8HvI8s80icyCcnAIB86MCBdst51fwFmH5dyeoWVPVgmQPcKrMtBQ0W5pAs7jBCWuRXgEpRzSCg== + +"@rollup/rollup-linux-arm64-gnu@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz#dbc5036a85e3ca3349887c8bdbebcfd011e460b0" + integrity sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ== + +"@rollup/rollup-linux-arm64-musl@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz#72efc633aa0b93531bdfc69d70bcafa88e6152fc" + integrity sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q== + +"@rollup/rollup-linux-loongarch64-gnu@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.0.tgz#9b6a49afde86c8f57ca11efdf8fd8d7c52048817" + integrity sha512-xw+FTGcov/ejdusVOqKgMGW3c4+AgqrfvzWEVXcNP6zq2ue+lsYUgJ+5Rtn/OTJf7e2CbgTFvzLW2j0YAtj0Gg== + +"@rollup/rollup-linux-powerpc64le-gnu@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.0.tgz#93cb96073efab0cdbf419c8dfc44b5e2bd815139" + integrity sha512-bKGibTr9IdF0zr21kMvkZT4K6NV+jjRnBoVMt2uNMG0BYWm3qOVmYnXKzx7UhwrviKnmK46IKMByMgvpdQlyJQ== + +"@rollup/rollup-linux-riscv64-gnu@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.0.tgz#028708f73c8130ae924e5c3755de50fe93687249" + integrity sha512-vV3cL48U5kDaKZtXrti12YRa7TyxgKAIDoYdqSIOMOFBXqFj2XbChHAtXquEn2+n78ciFgr4KIqEbydEGPxXgA== + +"@rollup/rollup-linux-riscv64-musl@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.0.tgz#878bfb158b2cf6671b7611fd58e5c80d9144ac6c" + integrity sha512-TDKO8KlHJuvTEdfw5YYFBjhFts2TR0VpZsnLLSYmB7AaohJhM8ctDSdDnUGq77hUh4m/djRafw+9zQpkOanE2Q== + +"@rollup/rollup-linux-s390x-gnu@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.0.tgz#59b4ebb2129d34b7807ed8c462ff0baaefca9ad4" + integrity sha512-8541GEyktXaw4lvnGp9m84KENcxInhAt6vPWJ9RodsB/iGjHoMB2Pp5MVBCiKIRxrxzJhGCxmNzdu+oDQ7kwRA== + +"@rollup/rollup-linux-x64-gnu@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz#597d40f60d4b15bedbbacf2491a69c5b67a58e93" + integrity sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw== + +"@rollup/rollup-linux-x64-musl@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz#0a062d6fee35ec4fbb607b2a9d933a9372ccf63a" + integrity sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA== + +"@rollup/rollup-win32-arm64-msvc@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz#41ffab489857987c75385b0fc8cccf97f7e69d0a" + integrity sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w== + +"@rollup/rollup-win32-ia32-msvc@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.0.tgz#d9fb61d98eedfa52720b6ed9f31442b3ef4b839f" + integrity sha512-3XJ0NQtMAXTWFW8FqZKcw3gOQwBtVWP/u8TpHP3CRPXD7Pd6s8lLdH3sHWh8vqKCyyiI8xW5ltJScQmBU9j7WA== + +"@rollup/rollup-win32-x64-msvc@4.44.0": + version "4.44.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz#a36e79b6ccece1533f777a1bca1f89c13f0c5f62" + integrity sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ== + +"@stylistic/eslint-plugin@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@stylistic/eslint-plugin/-/eslint-plugin-5.0.0.tgz#587a2d0ca80e3395ad16d8044a62d40119e1b4a7" + integrity sha512-nVV2FSzeTJ3oFKw+3t9gQYQcrgbopgCASSY27QOtkhEGgSfdQQjDmzZd41NeT1myQ8Wc6l+pZllST9qIu4NKzg== + dependencies: + "@eslint-community/eslint-utils" "^4.7.0" + "@typescript-eslint/types" "^8.34.1" + eslint-visitor-keys "^4.2.1" + espree "^10.4.0" + estraverse "^5.3.0" + picomatch "^4.0.2" + +"@tailwindcss/node@4.1.8": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.1.8.tgz" + integrity sha512-OWwBsbC9BFAJelmnNcrKuf+bka2ZxCE2A4Ft53Tkg4uoiE67r/PMEYwCsourC26E+kmxfwE0hVzMdxqeW+xu7Q== + dependencies: + "@ampproject/remapping" "^2.3.0" + enhanced-resolve "^5.18.1" + jiti "^2.4.2" + lightningcss "1.30.1" + magic-string "^0.30.17" + source-map-js "^1.2.1" + tailwindcss "4.1.8" + +"@tailwindcss/oxide-android-arm64@4.1.8": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.8.tgz#4cb4b464636fc7e3154a1bb7df38a828291b3e9a" + integrity sha512-Fbz7qni62uKYceWYvUjRqhGfZKwhZDQhlrJKGtnZfuNtHFqa8wmr+Wn74CTWERiW2hn3mN5gTpOoxWKk0jRxjg== + +"@tailwindcss/oxide-darwin-arm64@4.1.8": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.8.tgz" + integrity sha512-RdRvedGsT0vwVVDztvyXhKpsU2ark/BjgG0huo4+2BluxdXo8NDgzl77qh0T1nUxmM11eXwR8jA39ibvSTbi7A== + +"@tailwindcss/oxide-darwin-x64@4.1.8": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.8.tgz#d0f3fa4c3bde21a772e29e31c9739d91db79de12" + integrity sha512-t6PgxjEMLp5Ovf7uMb2OFmb3kqzVTPPakWpBIFzppk4JE4ix0yEtbtSjPbU8+PZETpaYMtXvss2Sdkx8Vs4XRw== + +"@tailwindcss/oxide-freebsd-x64@4.1.8": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.8.tgz#545c94c941007ed1aa2e449465501b70d59cb3da" + integrity sha512-g8C8eGEyhHTqwPStSwZNSrOlyx0bhK/V/+zX0Y+n7DoRUzyS8eMbVshVOLJTDDC+Qn9IJnilYbIKzpB9n4aBsg== + +"@tailwindcss/oxide-linux-arm-gnueabihf@4.1.8": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.8.tgz#e1bdbf63a179081669b8cd1c9523889774760eb9" + integrity sha512-Jmzr3FA4S2tHhaC6yCjac3rGf7hG9R6Gf2z9i9JFcuyy0u79HfQsh/thifbYTF2ic82KJovKKkIB6Z9TdNhCXQ== + +"@tailwindcss/oxide-linux-arm64-gnu@4.1.8": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.8.tgz#8d28093bbd43bdae771a2dcca720e926baa57093" + integrity sha512-qq7jXtO1+UEtCmCeBBIRDrPFIVI4ilEQ97qgBGdwXAARrUqSn/L9fUrkb1XP/mvVtoVeR2bt/0L77xx53bPZ/Q== + +"@tailwindcss/oxide-linux-arm64-musl@4.1.8": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.8.tgz#cc6cece814d813885ead9cd8b9d55aeb3db56c97" + integrity sha512-O6b8QesPbJCRshsNApsOIpzKt3ztG35gfX9tEf4arD7mwNinsoCKxkj8TgEE0YRjmjtO3r9FlJnT/ENd9EVefQ== + +"@tailwindcss/oxide-linux-x64-gnu@4.1.8": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.8.tgz#4cac14fa71382574773fb7986d9f0681ad89e3de" + integrity sha512-32iEXX/pXwikshNOGnERAFwFSfiltmijMIAbUhnNyjFr3tmWmMJWQKU2vNcFX0DACSXJ3ZWcSkzNbaKTdngH6g== + +"@tailwindcss/oxide-linux-x64-musl@4.1.8": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.8.tgz#e085f1ccbc8f97625773a6a3afc2a6f88edf59da" + integrity sha512-s+VSSD+TfZeMEsCaFaHTaY5YNj3Dri8rST09gMvYQKwPphacRG7wbuQ5ZJMIJXN/puxPcg/nU+ucvWguPpvBDg== + +"@tailwindcss/oxide-wasm32-wasi@4.1.8": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.8.tgz#c5e19fffe67f25cabf12a357bba4e87128151ea0" + integrity sha512-CXBPVFkpDjM67sS1psWohZ6g/2/cd+cq56vPxK4JeawelxwK4YECgl9Y9TjkE2qfF+9/s1tHHJqrC4SS6cVvSg== + dependencies: + "@emnapi/core" "^1.4.3" + "@emnapi/runtime" "^1.4.3" + "@emnapi/wasi-threads" "^1.0.2" + "@napi-rs/wasm-runtime" "^0.2.10" + "@tybys/wasm-util" "^0.9.0" + tslib "^2.8.0" + +"@tailwindcss/oxide-win32-arm64-msvc@4.1.8": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.8.tgz#77521f23f91604c587736927fd2cb526667b7344" + integrity sha512-7GmYk1n28teDHUjPlIx4Z6Z4hHEgvP5ZW2QS9ygnDAdI/myh3HTHjDqtSqgu1BpRoI4OiLx+fThAyA1JePoENA== + +"@tailwindcss/oxide-win32-x64-msvc@4.1.8": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.8.tgz#55c876ab35f8779d1dceec61483cd9834d7365ac" + integrity sha512-fou+U20j+Jl0EHwK92spoWISON2OBnCazIc038Xj2TdweYV33ZRkS9nwqiUi2d/Wba5xg5UoHfvynnb/UB49cQ== + +"@tailwindcss/oxide@4.1.8": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.1.8.tgz" + integrity sha512-d7qvv9PsM5N3VNKhwVUhpK6r4h9wtLkJ6lz9ZY9aeZgrUWk1Z8VPyqyDT9MZlem7GTGseRQHkeB1j3tC7W1P+A== + dependencies: + detect-libc "^2.0.4" + tar "^7.4.3" + optionalDependencies: + "@tailwindcss/oxide-android-arm64" "4.1.8" + "@tailwindcss/oxide-darwin-arm64" "4.1.8" + "@tailwindcss/oxide-darwin-x64" "4.1.8" + "@tailwindcss/oxide-freebsd-x64" "4.1.8" + "@tailwindcss/oxide-linux-arm-gnueabihf" "4.1.8" + "@tailwindcss/oxide-linux-arm64-gnu" "4.1.8" + "@tailwindcss/oxide-linux-arm64-musl" "4.1.8" + "@tailwindcss/oxide-linux-x64-gnu" "4.1.8" + "@tailwindcss/oxide-linux-x64-musl" "4.1.8" + "@tailwindcss/oxide-wasm32-wasi" "4.1.8" + "@tailwindcss/oxide-win32-arm64-msvc" "4.1.8" + "@tailwindcss/oxide-win32-x64-msvc" "4.1.8" + +"@tailwindcss/vite@^4.0.17": + version "4.1.8" + resolved "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.1.8.tgz" + integrity sha512-CQ+I8yxNV5/6uGaJjiuymgw0kEQiNKRinYbZXPdx1fk5WgiyReG0VaUx/Xq6aVNSUNJFzxm6o8FNKS5aMaim5A== + dependencies: + "@tailwindcss/node" "4.1.8" + "@tailwindcss/oxide" "4.1.8" + tailwindcss "4.1.8" + +"@tybys/wasm-util@^0.9.0": + version "0.9.0" + resolved "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz#3e75eb00604c8d6db470bf18c37b7d984a0e3355" + integrity sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw== + dependencies: + tslib "^2.4.0" + +"@types/debug@^4.0.0": + version "4.1.12" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" + integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== + dependencies: + "@types/ms" "*" + +"@types/estree@1.0.8", "@types/estree@^1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/estree@^1.0.0", "@types/estree@^1.0.6": + version "1.0.7" + resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.7.tgz" + integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/katex@^0.16.2": + version "0.16.7" + resolved "https://registry.npmmirror.com/@types/katex/-/katex-0.16.7.tgz" + integrity sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ== + +"@types/lodash-es@^4.17.9": + version "4.17.12" + resolved "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz" + integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*", "@types/lodash@^4.14.198": + version "4.17.17" + resolved "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.17.tgz" + integrity sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ== + +"@types/mdast@^4.0.0": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" + integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA== + dependencies: + "@types/unist" "*" + +"@types/ms@*": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" + integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== + +"@types/node@^20.14.11": + version "20.17.57" + resolved "https://registry.npmmirror.com/@types/node/-/node-20.17.57.tgz" + integrity sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ== + dependencies: + undici-types "~6.19.2" + +"@types/sortablejs@^1.15.0", "@types/sortablejs@^1.15.8": + version "1.15.8" + resolved "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.8.tgz" + integrity sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg== + +"@types/unist@*", "@types/unist@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" + integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== + +"@types/uuid@^10.0.0": + version "10.0.0" + resolved "https://registry.npmmirror.com/@types/uuid/-/uuid-10.0.0.tgz" + integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ== + +"@types/web-bluetooth@^0.0.20": + version "0.0.20" + resolved "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz" + integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow== + +"@typescript-eslint/eslint-plugin@8.33.1": + version "8.33.1" + resolved "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.1.tgz" + integrity sha512-TDCXj+YxLgtvxvFlAvpoRv9MAncDLBV2oT9Bd7YBGC/b/sEURoOYuIwLI99rjWOfY3QtDzO+mk0n4AmdFExW8A== + dependencies: + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "8.33.1" + "@typescript-eslint/type-utils" "8.33.1" + "@typescript-eslint/utils" "8.33.1" + "@typescript-eslint/visitor-keys" "8.33.1" + graphemer "^1.4.0" + ignore "^7.0.0" + natural-compare "^1.4.0" + ts-api-utils "^2.1.0" + +"@typescript-eslint/eslint-plugin@^8.34.1": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz#515170100ff867445fe0a17ce05c14fc5fd9ca63" + integrity sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg== + dependencies: + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "8.35.0" + "@typescript-eslint/type-utils" "8.35.0" + "@typescript-eslint/utils" "8.35.0" + "@typescript-eslint/visitor-keys" "8.35.0" + graphemer "^1.4.0" + ignore "^7.0.0" + natural-compare "^1.4.0" + ts-api-utils "^2.1.0" + +"@typescript-eslint/parser@8.33.1": + version "8.33.1" + resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.33.1.tgz" + integrity sha512-qwxv6dq682yVvgKKp2qWwLgRbscDAYktPptK4JPojCwwi3R9cwrvIxS4lvBpzmcqzR4bdn54Z0IG1uHFskW4dA== + dependencies: + "@typescript-eslint/scope-manager" "8.33.1" + "@typescript-eslint/types" "8.33.1" + "@typescript-eslint/typescript-estree" "8.33.1" + "@typescript-eslint/visitor-keys" "8.33.1" + debug "^4.3.4" + +"@typescript-eslint/parser@^8.34.1": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.35.0.tgz#20a0e17778a329a6072722f5ac418d4376b767d2" + integrity sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA== + dependencies: + "@typescript-eslint/scope-manager" "8.35.0" + "@typescript-eslint/types" "8.35.0" + "@typescript-eslint/typescript-estree" "8.35.0" + "@typescript-eslint/visitor-keys" "8.35.0" + debug "^4.3.4" + +"@typescript-eslint/project-service@8.33.1": + version "8.33.1" + resolved "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.33.1.tgz" + integrity sha512-DZR0efeNklDIHHGRpMpR5gJITQpu6tLr9lDJnKdONTC7vvzOlLAG/wcfxcdxEWrbiZApcoBCzXqU/Z458Za5Iw== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.33.1" + "@typescript-eslint/types" "^8.33.1" + debug "^4.3.4" + +"@typescript-eslint/project-service@8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.35.0.tgz#00bd77e6845fbdb5684c6ab2d8a400a58dcfb07b" + integrity sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.35.0" + "@typescript-eslint/types" "^8.35.0" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@8.33.1": + version "8.33.1" + resolved "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.33.1.tgz" + integrity sha512-dM4UBtgmzHR9bS0Rv09JST0RcHYearoEoo3pG5B6GoTR9XcyeqX87FEhPo+5kTvVfKCvfHaHrcgeJQc6mrDKrA== + dependencies: + "@typescript-eslint/types" "8.33.1" + "@typescript-eslint/visitor-keys" "8.33.1" + +"@typescript-eslint/scope-manager@8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz#8ccb2ab63383544fab98fc4b542d8d141259ff4f" + integrity sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA== + dependencies: + "@typescript-eslint/types" "8.35.0" + "@typescript-eslint/visitor-keys" "8.35.0" + +"@typescript-eslint/tsconfig-utils@8.33.1", "@typescript-eslint/tsconfig-utils@^8.33.1": + version "8.33.1" + resolved "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.33.1.tgz" + integrity sha512-STAQsGYbHCF0/e+ShUQ4EatXQ7ceh3fBCXkNU7/MZVKulrlq1usH7t2FhxvCpuCi5O5oi1vmVaAjrGeL71OK1g== + +"@typescript-eslint/tsconfig-utils@8.35.0", "@typescript-eslint/tsconfig-utils@^8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz#6e05aeb999999e31d562ceb4fe144f3cbfbd670e" + integrity sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA== + +"@typescript-eslint/type-utils@8.33.1": + version "8.33.1" + resolved "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.33.1.tgz" + integrity sha512-1cG37d9xOkhlykom55WVwG2QRNC7YXlxMaMzqw2uPeJixBFfKWZgaP/hjAObqMN/u3fr5BrTwTnc31/L9jQ2ww== + dependencies: + "@typescript-eslint/typescript-estree" "8.33.1" + "@typescript-eslint/utils" "8.33.1" + debug "^4.3.4" + ts-api-utils "^2.1.0" + +"@typescript-eslint/type-utils@8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz#0201eae9d83ffcc3451ef8c94f53ecfbf2319ecc" + integrity sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA== + dependencies: + "@typescript-eslint/typescript-estree" "8.35.0" + "@typescript-eslint/utils" "8.35.0" + debug "^4.3.4" + ts-api-utils "^2.1.0" + +"@typescript-eslint/types@8.33.1", "@typescript-eslint/types@^8.33.1": + version "8.33.1" + resolved "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.33.1.tgz" + integrity sha512-xid1WfizGhy/TKMTwhtVOgalHwPtV8T32MS9MaH50Cwvz6x6YqRIPdD2WvW0XaqOzTV9p5xdLY0h/ZusU5Lokg== + +"@typescript-eslint/types@8.35.0", "@typescript-eslint/types@^8.11.0", "@typescript-eslint/types@^8.34.0", "@typescript-eslint/types@^8.34.1", "@typescript-eslint/types@^8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.35.0.tgz#e60d062907930e30008d796de5c4170f02618a93" + integrity sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ== + +"@typescript-eslint/typescript-estree@8.33.1": + version "8.33.1" + resolved "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.33.1.tgz" + integrity sha512-+s9LYcT8LWjdYWu7IWs7FvUxpQ/DGkdjZeE/GGulHvv8rvYwQvVaUZ6DE+j5x/prADUgSbbCWZ2nPI3usuVeOA== + dependencies: + "@typescript-eslint/project-service" "8.33.1" + "@typescript-eslint/tsconfig-utils" "8.33.1" + "@typescript-eslint/types" "8.33.1" + "@typescript-eslint/visitor-keys" "8.33.1" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^2.1.0" + +"@typescript-eslint/typescript-estree@8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz#86141e6c55b75bc1eaecc0781bd39704de14e52a" + integrity sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w== + dependencies: + "@typescript-eslint/project-service" "8.35.0" + "@typescript-eslint/tsconfig-utils" "8.35.0" + "@typescript-eslint/types" "8.35.0" + "@typescript-eslint/visitor-keys" "8.35.0" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^2.1.0" + +"@typescript-eslint/utils@8.33.1": + version "8.33.1" + resolved "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.33.1.tgz" + integrity sha512-52HaBiEQUaRYqAXpfzWSR2U3gxk92Kw006+xZpElaPMg3C4PgM+A5LqwoQI1f9E5aZ/qlxAZxzm42WX+vn92SQ== + dependencies: + "@eslint-community/eslint-utils" "^4.7.0" + "@typescript-eslint/scope-manager" "8.33.1" + "@typescript-eslint/types" "8.33.1" + "@typescript-eslint/typescript-estree" "8.33.1" + +"@typescript-eslint/utils@8.35.0", "@typescript-eslint/utils@^8.24.1", "@typescript-eslint/utils@^8.26.1", "@typescript-eslint/utils@^8.34.1": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.35.0.tgz#aaf0afab5ab51ea2f1897002907eacd9834606d5" + integrity sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg== + dependencies: + "@eslint-community/eslint-utils" "^4.7.0" + "@typescript-eslint/scope-manager" "8.35.0" + "@typescript-eslint/types" "8.35.0" + "@typescript-eslint/typescript-estree" "8.35.0" + +"@typescript-eslint/visitor-keys@8.33.1": + version "8.33.1" + resolved "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.33.1.tgz" + integrity sha512-3i8NrFcZeeDHJ+7ZUuDkGT+UHq+XoFGsymNK2jZCOHcfEzRQ0BdpRtdpSx/Iyf3MHLWIcLS0COuOPibKQboIiQ== + dependencies: + "@typescript-eslint/types" "8.33.1" + eslint-visitor-keys "^4.2.0" + +"@typescript-eslint/visitor-keys@8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz#93e905e7f1e94d26a79771d1b1eb0024cb159dbf" + integrity sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g== + dependencies: + "@typescript-eslint/types" "8.35.0" + eslint-visitor-keys "^4.2.1" + +"@vicons/carbon@^0.12.0": + version "0.12.0" + resolved "https://registry.npmmirror.com/@vicons/carbon/-/carbon-0.12.0.tgz" + integrity sha512-kCOgr/ZOhZzoiFLJ8pwxMa2TMxrkCUOA22qExPabus35F4+USqzcsxaPoYtqRd9ROOYiHrSqwapak/ywF0D9bg== + +"@vicons/tabler@^0.12.0": + version "0.12.0" + resolved "https://registry.npmmirror.com/@vicons/tabler/-/tabler-0.12.0.tgz" + integrity sha512-3+wUFuxb7e8OzZ8Wryct1pzfA2vyoF4lwW98O9s27ZrfCGaJGNmqG+q8A7vQ92Mf+COCgxpK+rhNPTtTvaU6qw== + +"@vitejs/plugin-vue-jsx@^4.1.2": + version "4.2.0" + resolved "https://registry.npmmirror.com/@vitejs/plugin-vue-jsx/-/plugin-vue-jsx-4.2.0.tgz" + integrity sha512-DSTrmrdLp+0LDNF77fqrKfx7X0ErRbOcUAgJL/HbSesqQwoUvUQ4uYQqaex+rovqgGcoPqVk+AwUh3v9CuiYIw== + dependencies: + "@babel/core" "^7.27.1" + "@babel/plugin-transform-typescript" "^7.27.1" + "@rolldown/pluginutils" "^1.0.0-beta.9" + "@vue/babel-plugin-jsx" "^1.4.0" + +"@vitejs/plugin-vue@^5.0.5": + version "5.2.4" + resolved "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz" + integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA== + +"@vitest/eslint-plugin@^1.2.7": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@vitest/eslint-plugin/-/eslint-plugin-1.2.7.tgz#c09191ba0bf8eb6c66ac4de05bc645a06ed454c9" + integrity sha512-7WHcGZo6uXsE4SsSnpGDqKyGrd6NfOMM52WKoHSpTRZLbjMuDyHfA5P7m8yrr73tpqYjsiAdSjSerOnx8uEhpA== + dependencies: + "@typescript-eslint/utils" "^8.24.1" + +"@volar/language-core@2.4.14", "@volar/language-core@~2.4.11": + version "2.4.14" + resolved "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.14.tgz" + integrity sha512-X6beusV0DvuVseaOEy7GoagS4rYHgDHnTrdOj5jeUb49fW5ceQyP9Ej5rBhqgz2wJggl+2fDbbojq1XKaxDi6w== + dependencies: + "@volar/source-map" "2.4.14" + +"@volar/source-map@2.4.14": + version "2.4.14" + resolved "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.14.tgz" + integrity sha512-5TeKKMh7Sfxo8021cJfmBzcjfY1SsXsPMMjMvjY7ivesdnybqqS+GxGAoXHAOUawQTwtdUxgP65Im+dEmvWtYQ== + +"@volar/typescript@~2.4.11": + version "2.4.14" + resolved "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.14.tgz" + integrity sha512-p8Z6f/bZM3/HyCdRNFZOEEzts51uV8WHeN8Tnfnm2EBv6FDB2TQLzfVx7aJvnl8ofKAOnS64B2O8bImBFaauRw== + dependencies: + "@volar/language-core" "2.4.14" + path-browserify "^1.0.1" + vscode-uri "^3.0.8" + +"@vue/babel-helper-vue-transform-on@1.4.0": + version "1.4.0" + resolved "https://registry.npmmirror.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.4.0.tgz" + integrity sha512-mCokbouEQ/ocRce/FpKCRItGo+013tHg7tixg3DUNS+6bmIchPt66012kBMm476vyEIJPafrvOf4E5OYj3shSw== + +"@vue/babel-plugin-jsx@^1.4.0": + version "1.4.0" + resolved "https://registry.npmmirror.com/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.4.0.tgz" + integrity sha512-9zAHmwgMWlaN6qRKdrg1uKsBKHvnUU+Py+MOCTuYZBoZsopa90Di10QRjB+YPnVss0BZbG/H5XFwJY1fTxJWhA== + dependencies: + "@babel/helper-module-imports" "^7.25.9" + "@babel/helper-plugin-utils" "^7.26.5" + "@babel/plugin-syntax-jsx" "^7.25.9" + "@babel/template" "^7.26.9" + "@babel/traverse" "^7.26.9" + "@babel/types" "^7.26.9" + "@vue/babel-helper-vue-transform-on" "1.4.0" + "@vue/babel-plugin-resolve-type" "1.4.0" + "@vue/shared" "^3.5.13" + +"@vue/babel-plugin-resolve-type@1.4.0": + version "1.4.0" + resolved "https://registry.npmmirror.com/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.4.0.tgz" + integrity sha512-4xqDRRbQQEWHQyjlYSgZsWj44KfiF6D+ktCuXyZ8EnVDYV3pztmXJDf1HveAjUAXxAnR8daCQT51RneWWxtTyQ== + dependencies: + "@babel/code-frame" "^7.26.2" + "@babel/helper-module-imports" "^7.25.9" + "@babel/helper-plugin-utils" "^7.26.5" + "@babel/parser" "^7.26.9" + "@vue/compiler-sfc" "^3.5.13" + +"@vue/compiler-core@3.5.16": + version "3.5.16" + resolved "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.16.tgz" + integrity sha512-AOQS2eaQOaaZQoL1u+2rCJIKDruNXVBZSiUD3chnUrsoX5ZTQMaCvXlWNIfxBJuU15r1o7+mpo5223KVtIhAgQ== + dependencies: + "@babel/parser" "^7.27.2" + "@vue/shared" "3.5.16" + entities "^4.5.0" + estree-walker "^2.0.2" + source-map-js "^1.2.1" + +"@vue/compiler-dom@3.5.16", "@vue/compiler-dom@^3.5.0": + version "3.5.16" + resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.16.tgz" + integrity sha512-SSJIhBr/teipXiXjmWOVWLnxjNGo65Oj/8wTEQz0nqwQeP75jWZ0n4sF24Zxoht1cuJoWopwj0J0exYwCJ0dCQ== + dependencies: + "@vue/compiler-core" "3.5.16" + "@vue/shared" "3.5.16" + +"@vue/compiler-sfc@3.5.16", "@vue/compiler-sfc@^3.5.13": + version "3.5.16" + resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.16.tgz" + integrity sha512-rQR6VSFNpiinDy/DVUE0vHoIDUF++6p910cgcZoaAUm3POxgNOOdS/xgoll3rNdKYTYPnnbARDCZOyZ+QSe6Pw== + dependencies: + "@babel/parser" "^7.27.2" + "@vue/compiler-core" "3.5.16" + "@vue/compiler-dom" "3.5.16" + "@vue/compiler-ssr" "3.5.16" + "@vue/shared" "3.5.16" + estree-walker "^2.0.2" + magic-string "^0.30.17" + postcss "^8.5.3" + source-map-js "^1.2.1" + +"@vue/compiler-ssr@3.5.16": + version "3.5.16" + resolved "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.16.tgz" + integrity sha512-d2V7kfxbdsjrDSGlJE7my1ZzCXViEcqN6w14DOsDrUCHEA6vbnVCpRFfrc4ryCP/lCKzX2eS1YtnLE/BuC9f/A== + dependencies: + "@vue/compiler-dom" "3.5.16" + "@vue/shared" "3.5.16" + +"@vue/compiler-vue2@^2.7.16": + version "2.7.16" + resolved "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz" + integrity sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A== + dependencies: + de-indent "^1.0.2" + he "^1.2.0" + +"@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.6.4": + version "6.6.4" + resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz" + integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g== + +"@vue/devtools-api@^7.7.2": + version "7.7.6" + resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.6.tgz" + integrity sha512-b2Xx0KvXZObePpXPYHvBRRJLDQn5nhKjXh7vUhMEtWxz1AYNFOVIsh5+HLP8xDGL7sy+Q7hXeUxPHB/KgbtsPw== + dependencies: + "@vue/devtools-kit" "^7.7.6" + +"@vue/devtools-kit@^7.7.6": + version "7.7.6" + resolved "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.6.tgz" + integrity sha512-geu7ds7tem2Y7Wz+WgbnbZ6T5eadOvozHZ23Atk/8tksHMFOFylKi1xgGlQlVn0wlkEf4hu+vd5ctj1G4kFtwA== + dependencies: + "@vue/devtools-shared" "^7.7.6" + birpc "^2.3.0" + hookable "^5.5.3" + mitt "^3.0.1" + perfect-debounce "^1.0.0" + speakingurl "^14.0.1" + superjson "^2.2.2" + +"@vue/devtools-shared@^7.7.6": + version "7.7.6" + resolved "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.6.tgz" + integrity sha512-yFEgJZ/WblEsojQQceuyK6FzpFDx4kqrz2ohInxNj5/DnhoX023upTv4OD6lNPLAA5LLkbwPVb10o/7b+Y4FVA== + dependencies: + rfdc "^1.4.1" + +"@vue/language-core@2.2.10": + version "2.2.10" + resolved "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.10.tgz" + integrity sha512-+yNoYx6XIKuAO8Mqh1vGytu8jkFEOH5C8iOv3i8Z/65A7x9iAOXA97Q+PqZ3nlm2lxf5rOJuIGI/wDtx/riNYw== + dependencies: + "@volar/language-core" "~2.4.11" + "@vue/compiler-dom" "^3.5.0" + "@vue/compiler-vue2" "^2.7.16" + "@vue/shared" "^3.5.0" + alien-signals "^1.0.3" + minimatch "^9.0.3" + muggle-string "^0.4.1" + path-browserify "^1.0.1" + +"@vue/reactivity@3.5.16": + version "3.5.16" + resolved "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.16.tgz" + integrity sha512-FG5Q5ee/kxhIm1p2bykPpPwqiUBV3kFySsHEQha5BJvjXdZTUfmya7wP7zC39dFuZAcf/PD5S4Lni55vGLMhvA== + dependencies: + "@vue/shared" "3.5.16" + +"@vue/runtime-core@3.5.16": + version "3.5.16" + resolved "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.16.tgz" + integrity sha512-bw5Ykq6+JFHYxrQa7Tjr+VSzw7Dj4ldR/udyBZbq73fCdJmyy5MPIFR9IX/M5Qs+TtTjuyUTCnmK3lWWwpAcFQ== + dependencies: + "@vue/reactivity" "3.5.16" + "@vue/shared" "3.5.16" + +"@vue/runtime-dom@3.5.16": + version "3.5.16" + resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.16.tgz" + integrity sha512-T1qqYJsG2xMGhImRUV9y/RseB9d0eCYZQ4CWca9ztCuiPj/XWNNN+lkNBuzVbia5z4/cgxdL28NoQCvC0Xcfww== + dependencies: + "@vue/reactivity" "3.5.16" + "@vue/runtime-core" "3.5.16" + "@vue/shared" "3.5.16" + csstype "^3.1.3" + +"@vue/server-renderer@3.5.16": + version "3.5.16" + resolved "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.16.tgz" + integrity sha512-BrX0qLiv/WugguGsnQUJiYOE0Fe5mZTwi6b7X/ybGB0vfrPH9z0gD/Y6WOR1sGCgX4gc25L1RYS5eYQKDMoNIg== + dependencies: + "@vue/compiler-ssr" "3.5.16" + "@vue/shared" "3.5.16" + +"@vue/shared@3.5.16", "@vue/shared@^3.5.0", "@vue/shared@^3.5.13": + version "3.5.16" + resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.16.tgz" + integrity sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg== + +"@vueuse/core@^10.11.0": + version "10.11.1" + resolved "https://registry.npmmirror.com/@vueuse/core/-/core-10.11.1.tgz" + integrity sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww== + dependencies: + "@types/web-bluetooth" "^0.0.20" + "@vueuse/metadata" "10.11.1" + "@vueuse/shared" "10.11.1" + vue-demi ">=0.14.8" + +"@vueuse/metadata@10.11.1": + version "10.11.1" + resolved "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-10.11.1.tgz" + integrity sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw== + +"@vueuse/shared@10.11.1": + version "10.11.1" + resolved "https://registry.npmmirror.com/@vueuse/shared/-/shared-10.11.1.tgz" + integrity sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA== + dependencies: + vue-demi ">=0.14.8" + +"@xterm/addon-fit@^0.10.0": + version "0.10.0" + resolved "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.10.0.tgz" + integrity sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ== + +"@xterm/addon-search@^0.15.0": + version "0.15.0" + resolved "https://registry.npmmirror.com/@xterm/addon-search/-/addon-search-0.15.0.tgz" + integrity sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg== + +"@xterm/addon-webgl@^0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz#9e927cee10af971595fb2a72fd4c3bc2819f0096" + integrity sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w== + +"@xterm/xterm@^5.5.0": + version "5.5.0" + resolved "https://registry.npmmirror.com/@xterm/xterm/-/xterm-5.5.0.tgz" + integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A== + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.14.0: + version "8.14.1" + resolved "https://registry.npmmirror.com/acorn/-/acorn-8.14.1.tgz" + integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== + +acorn@^8.15.0, acorn@^8.5.0, acorn@^8.9.0: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +alien-signals@^1.0.3: + version "1.0.13" + resolved "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz" + integrity sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg== + +alova@^3.2.10: + version "3.2.13" + resolved "https://registry.npmmirror.com/alova/-/alova-3.2.13.tgz" + integrity sha512-TmAgR42CMPywTxKTAtKjRQBhnzuO4i66UpcWm+LCmUgC+mL6DJjBx3N5B8rwNxw8YHDHfvegqNE/Pga3oujtCQ== + dependencies: + "@alova/shared" "1.2.0" + rate-limiter-flexible "^5.0.3" + +ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansis@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ansis/-/ansis-4.1.0.tgz#cd43ecd3f814f37223e518291c0e0b04f2915a0d" + integrity sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w== + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +are-docs-informative@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/are-docs-informative/-/are-docs-informative-0.0.2.tgz#387f0e93f5d45280373d387a59d34c96db321963" + integrity sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig== + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +async-validator@^4.2.5: + version "4.2.5" + resolved "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz" + integrity sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +birpc@^2.3.0: + version "2.3.0" + resolved "https://registry.npmmirror.com/birpc/-/birpc-2.3.0.tgz" + integrity sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g== + +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.1.tgz" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.3, braces@~3.0.2: + version "3.0.3" + resolved "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browserslist@^4.24.0, browserslist@^4.25.0: + version "4.25.0" + resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.25.0.tgz" + integrity sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA== + dependencies: + caniuse-lite "^1.0.30001718" + electron-to-chromium "^1.5.160" + node-releases "^2.0.19" + update-browserslist-db "^1.1.3" + +builtin-modules@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-5.0.0.tgz#9be95686dedad2e9eed05592b07733db87dcff1a" + integrity sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg== + +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +caniuse-lite@^1.0.30001718: + version "1.0.30001721" + resolved "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001721.tgz" + integrity sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ== + +ccount@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" + integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== + +chalk@^4.0.0, chalk@^4.1.2: + version "4.1.2" + resolved "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +character-entities@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22" + integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ== + +chokidar@^3.6.0: + version "3.6.0" + resolved "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chokidar@^4.0.0: + version "4.0.3" + resolved "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + +chownr@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz" + integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g== + +ci-info@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.2.0.tgz#cbd21386152ebfe1d56f280a3b5feccbd96764c7" + integrity sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg== + +clean-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/clean-regexp/-/clean-regexp-1.0.0.tgz#8df7c7aae51fd36874e8f8d05b9180bc11a3fed7" + integrity sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw== + dependencies: + escape-string-regexp "^1.0.5" + +clipboard-polyfill@^4.1.0: + version "4.1.1" + resolved "https://registry.npmmirror.com/clipboard-polyfill/-/clipboard-polyfill-4.1.1.tgz" + integrity sha512-nbvNLrcX0zviek5QHLFRAaLrx8y/s8+RF2stH43tuS+kP5XlHMrcD0UGBWq43Hwp6WuuK7KefRMP56S45ibZkA== + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +comment-parser@1.4.1, comment-parser@^1.4.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.4.1.tgz#bdafead37961ac079be11eb7ec65c4d021eaf9cc" + integrity sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +confbox@^0.1.8: + version "0.1.8" + resolved "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz" + integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== + +confbox@^0.2.1: + version "0.2.2" + resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.2.2.tgz#8652f53961c74d9e081784beed78555974a9c110" + integrity sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +copy-anything@^3.0.2: + version "3.0.5" + resolved "https://registry.npmmirror.com/copy-anything/-/copy-anything-3.0.5.tgz" + integrity sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w== + dependencies: + is-what "^4.1.8" + +core-js-compat@^3.41.0: + version "3.43.0" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.43.0.tgz#055587369c458795ef316f65e0aabb808fb15840" + integrity sha512-2GML2ZsCc5LR7hZYz4AXmjQw8zuy2T//2QntwdnpuYI7jteT6GVYJL7F6C2C57R7gSYrcqVW3lAALefdbhBLDA== + dependencies: + browserslist "^4.25.0" + +crc-32@^1.1.1, crc-32@^1.2.2: + version "1.2.2" + resolved "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz" + integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== + +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +css-render@^0.15.10, css-render@^0.15.14: + version "0.15.14" + resolved "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz" + integrity sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg== + dependencies: + "@emotion/hash" "~0.8.0" + csstype "~3.0.5" + +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz" + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + +csstype@^3.1.3: + version "3.1.3" + resolved "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + +csstype@~3.0.5: + version "3.0.11" + resolved "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz" + integrity sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw== + +date-fns-tz@^3.1.3: + version "3.2.0" + resolved "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz" + integrity sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ== + +date-fns@^3.6.0: + version "3.6.0" + resolved "https://registry.npmmirror.com/date-fns/-/date-fns-3.6.0.tgz" + integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww== + +dayjs@^1.11.13: + version "1.11.13" + resolved "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz" + integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== + +de-indent@^1.0.2: + version "1.0.2" + resolved "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz" + integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg== + +debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.7, debug@^4.4.0, debug@^4.4.1: + version "4.4.1" + resolved "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz" + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== + dependencies: + ms "^2.1.3" + +decode-named-character-reference@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz#25c32ae6dd5e21889549d40f676030e9514cc0ed" + integrity sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q== + dependencies: + character-entities "^2.0.0" + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +dequal@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.npmmirror.com/detect-libc/-/detect-libc-1.0.3.tgz" + integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== + +detect-libc@^2.0.3, detect-libc@^2.0.4: + version "2.0.4" + resolved "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.0.4.tgz" + integrity sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA== + +devlop@^1.0.0, devlop@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" + +electron-to-chromium@^1.5.160: + version "1.5.165" + resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.165.tgz" + integrity sha512-naiMx1Z6Nb2TxPU6fiFrUrDTjyPMLdTtaOd2oLmG8zVSg2hCWGkhPyxwk+qRmZ1ytwVqUv0u7ZcDA5+ALhaUtw== + +enhanced-resolve@^5.17.1, enhanced-resolve@^5.18.1: + version "5.18.1" + resolved "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz" + integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" + +entities@^4.5.0: + version "4.5.0" + resolved "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +es-module-lexer@*: + version "1.7.0" + resolved "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + +esbuild@^0.25.0: + version "0.25.5" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.5.tgz#71075054993fdfae76c66586f9b9c1f8d7edd430" + integrity sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ== + optionalDependencies: + "@esbuild/aix-ppc64" "0.25.5" + "@esbuild/android-arm" "0.25.5" + "@esbuild/android-arm64" "0.25.5" + "@esbuild/android-x64" "0.25.5" + "@esbuild/darwin-arm64" "0.25.5" + "@esbuild/darwin-x64" "0.25.5" + "@esbuild/freebsd-arm64" "0.25.5" + "@esbuild/freebsd-x64" "0.25.5" + "@esbuild/linux-arm" "0.25.5" + "@esbuild/linux-arm64" "0.25.5" + "@esbuild/linux-ia32" "0.25.5" + "@esbuild/linux-loong64" "0.25.5" + "@esbuild/linux-mips64el" "0.25.5" + "@esbuild/linux-ppc64" "0.25.5" + "@esbuild/linux-riscv64" "0.25.5" + "@esbuild/linux-s390x" "0.25.5" + "@esbuild/linux-x64" "0.25.5" + "@esbuild/netbsd-arm64" "0.25.5" + "@esbuild/netbsd-x64" "0.25.5" + "@esbuild/openbsd-arm64" "0.25.5" + "@esbuild/openbsd-x64" "0.25.5" + "@esbuild/sunos-x64" "0.25.5" + "@esbuild/win32-arm64" "0.25.5" + "@esbuild/win32-ia32" "0.25.5" + "@esbuild/win32-x64" "0.25.5" + +escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + +escape-string-regexp@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" + integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== + +eslint-compat-utils@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz#7fc92b776d185a70c4070d03fd26fde3d59652e4" + integrity sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q== + dependencies: + semver "^7.5.4" + +eslint-compat-utils@^0.6.0, eslint-compat-utils@^0.6.4: + version "0.6.5" + resolved "https://registry.yarnpkg.com/eslint-compat-utils/-/eslint-compat-utils-0.6.5.tgz#6b06350a1c947c4514cfa64a170a6bfdbadc7ec2" + integrity sha512-vAUHYzue4YAa2hNACjB8HvUQj5yehAZgiClyFVVom9cP8z5NSFq3PwB/TtJslN2zAMgRX6FCFCjYBbQh71g5RQ== + dependencies: + semver "^7.5.4" + +eslint-config-flat-gitignore@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/eslint-config-flat-gitignore/-/eslint-config-flat-gitignore-2.1.0.tgz#8b93caa703977f04dee11e4c3c8303432462921c" + integrity sha512-cJzNJ7L+psWp5mXM7jBX+fjHtBvvh06RBlcweMhKD8jWqQw0G78hOW5tpVALGHGFPsBV+ot2H+pdDGJy6CV8pA== + dependencies: + "@eslint/compat" "^1.2.5" + +eslint-flat-config-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/eslint-flat-config-utils/-/eslint-flat-config-utils-2.1.0.tgz#ddb7f71493d33bd2459dadd3a79f81943910214b" + integrity sha512-6fjOJ9tS0k28ketkUcQ+kKptB4dBZY2VijMZ9rGn8Cwnn1SH0cZBoPXT8AHBFHxmHcLFQK9zbELDinZ2Mr1rng== + dependencies: + pathe "^2.0.3" + +eslint-json-compat-utils@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/eslint-json-compat-utils/-/eslint-json-compat-utils-0.2.1.tgz#32931d42c723da383712f25177a2c57b9ef5f079" + integrity sha512-YzEodbDyW8DX8bImKhAcCeu/L31Dd/70Bidx2Qex9OFUtgzXLqtfWL4Hr5fM/aCCB8QUZLuJur0S9k6UfgFkfg== + dependencies: + esquery "^1.6.0" + +eslint-merge-processors@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/eslint-merge-processors/-/eslint-merge-processors-2.0.0.tgz#f1e02bd863962fab7fd038c293979283e61b473c" + integrity sha512-sUuhSf3IrJdGooquEUB5TNpGNpBoQccbnaLHsb1XkBLUPPqCNivCpY05ZcpCOiV9uHwO2yxXEWVczVclzMxYlA== + +eslint-plugin-antfu@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-antfu/-/eslint-plugin-antfu-3.1.1.tgz#4d085ff6d9241d6d8d84cd6afa92cb8cabba129b" + integrity sha512-7Q+NhwLfHJFvopI2HBZbSxWXngTwBLKxW1AGXLr2lEGxcEIK/AsDs8pn8fvIizl5aZjBbVbVK5ujmMpBe4Tvdg== + +eslint-plugin-command@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-command/-/eslint-plugin-command-3.3.1.tgz#fa7d1dd8b115dfc138405816720d0deed3b2b63f" + integrity sha512-fBVTXQ2y48TVLT0+4A6PFINp7GcdIailHAXbvPBixE7x+YpYnNQhFZxTdvnb+aWk+COgNebQKen/7m4dmgyWAw== + dependencies: + "@es-joy/jsdoccomment" "^0.50.2" + +eslint-plugin-es-x@^7.8.0: + version "7.8.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz#a207aa08da37a7923f2a9599e6d3eb73f3f92b74" + integrity sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ== + dependencies: + "@eslint-community/eslint-utils" "^4.1.2" + "@eslint-community/regexpp" "^4.11.0" + eslint-compat-utils "^0.5.1" + +eslint-plugin-import-lite@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import-lite/-/eslint-plugin-import-lite-0.3.0.tgz#78fb6df41ed6e4ddfa40706ebe7a35563d4173d0" + integrity sha512-dkNBAL6jcoCsXZsQ/Tt2yXmMDoNt5NaBh/U7yvccjiK8cai6Ay+MK77bMykmqQA2bTF6lngaLCDij6MTO3KkvA== + dependencies: + "@eslint-community/eslint-utils" "^4.7.0" + "@typescript-eslint/types" "^8.34.0" + +eslint-plugin-jsdoc@^51.2.1: + version "51.2.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-51.2.2.tgz#bbae4e07c218ec6e7b36b6385a2c86a8a05ad0f8" + integrity sha512-5e3VGUk3rvZ6ZuxJr5fCTVMj7TrMC80F1GbymjyUkplCbj6dXW41qX3ZzF8YULXM74cBfjnWy/nSp/I0eLl3vg== + dependencies: + "@es-joy/jsdoccomment" "~0.52.0" + are-docs-informative "^0.0.2" + comment-parser "1.4.1" + debug "^4.4.1" + escape-string-regexp "^4.0.0" + espree "^10.4.0" + esquery "^1.6.0" + parse-imports-exports "^0.2.4" + semver "^7.7.2" + spdx-expression-parse "^4.0.0" + +eslint-plugin-jsonc@^2.20.1: + version "2.20.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsonc/-/eslint-plugin-jsonc-2.20.1.tgz#138b41e857a2add02b5408b13f3bc6f14d51d702" + integrity sha512-gUzIwQHXx7ZPypUoadcyRi4WbHW2TPixDr0kqQ4miuJBU0emJmyGTlnaT3Og9X2a8R1CDayN9BFSq5weGWbTng== + dependencies: + "@eslint-community/eslint-utils" "^4.5.1" + eslint-compat-utils "^0.6.4" + eslint-json-compat-utils "^0.2.1" + espree "^9.6.1 || ^10.3.0" + graphemer "^1.4.0" + jsonc-eslint-parser "^2.4.0" + natural-compare "^1.4.0" + synckit "^0.6.2 || ^0.7.3 || ^0.11.5" + +eslint-plugin-n@^17.20.0: + version "17.20.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-17.20.0.tgz#000a7a39675d737824d704ae77b626c257b318ef" + integrity sha512-IRSoatgB/NQJZG5EeTbv/iAx1byOGdbbyhQrNvWdCfTnmPxUT0ao9/eGOeG7ljD8wJBsxwE8f6tES5Db0FRKEw== + dependencies: + "@eslint-community/eslint-utils" "^4.5.0" + "@typescript-eslint/utils" "^8.26.1" + enhanced-resolve "^5.17.1" + eslint-plugin-es-x "^7.8.0" + get-tsconfig "^4.8.1" + globals "^15.11.0" + ignore "^5.3.2" + minimatch "^9.0.5" + semver "^7.6.3" + ts-declaration-location "^1.0.6" + +eslint-plugin-no-only-tests@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-no-only-tests/-/eslint-plugin-no-only-tests-3.3.0.tgz#d9d42ccd4b5d099b4872fb5046cf95441188cfb5" + integrity sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q== + +eslint-plugin-perfectionist@^4.15.0: + version "4.15.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-4.15.0.tgz#320029162b0ec439af522d5b146903f0e47bfbd4" + integrity sha512-pC7PgoXyDnEXe14xvRUhBII8A3zRgggKqJFx2a82fjrItDs1BSI7zdZnQtM2yQvcyod6/ujmzb7ejKPx8lZTnw== + dependencies: + "@typescript-eslint/types" "^8.34.1" + "@typescript-eslint/utils" "^8.34.1" + natural-orderby "^5.0.0" + +eslint-plugin-pnpm@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-pnpm/-/eslint-plugin-pnpm-0.3.1.tgz#b41c6456b6b8804c0d066a0e44a89f21a2938994" + integrity sha512-vi5iHoELIAlBbX4AW8ZGzU3tUnfxuXhC/NKo3qRcI5o9igbz6zJUqSlQ03bPeMqWIGTPatZnbWsNR1RnlNERNQ== + dependencies: + find-up-simple "^1.0.1" + jsonc-eslint-parser "^2.4.0" + pathe "^2.0.3" + pnpm-workspace-yaml "0.3.1" + tinyglobby "^0.2.12" + yaml-eslint-parser "^1.3.0" + +eslint-plugin-regexp@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-regexp/-/eslint-plugin-regexp-2.9.0.tgz#0783be374e68c7a7af98543334fd4f8572949e3f" + integrity sha512-9WqJMnOq8VlE/cK+YAo9C9YHhkOtcEtEk9d12a+H7OSZFwlpI6stiHmYPGa2VE0QhTzodJyhlyprUaXDZLgHBw== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.11.0" + comment-parser "^1.4.0" + jsdoc-type-pratt-parser "^4.0.0" + refa "^0.12.1" + regexp-ast-analysis "^0.7.1" + scslre "^0.3.0" + +eslint-plugin-spellcheck@^0.0.20: + version "0.0.20" + resolved "https://registry.npmmirror.com/eslint-plugin-spellcheck/-/eslint-plugin-spellcheck-0.0.20.tgz#32c7a42d26238e42e65a20e695062f5ce18f0115" + integrity sha512-GJa6vgzWAYqe0elKADAsiBRrhvqBnKyt7tpFSqlCZJsK2W9+K80oMyHhKolA7vJ13H5RCGs5/KCN+mKUyKoAiA== + dependencies: + globals "^13.0.0" + hunspell-spellchecker "^1.0.2" + lodash "^4.17.15" + +eslint-plugin-toml@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-toml/-/eslint-plugin-toml-0.12.0.tgz#4990524e6ad32af4b9839ad5283b2cc246fbb6fc" + integrity sha512-+/wVObA9DVhwZB1nG83D2OAQRrcQZXy+drqUnFJKymqnmbnbfg/UPmEMCKrJNcEboUGxUjYrJlgy+/Y930mURQ== + dependencies: + debug "^4.1.1" + eslint-compat-utils "^0.6.0" + lodash "^4.17.19" + toml-eslint-parser "^0.10.0" + +eslint-plugin-unicorn@^59.0.1: + version "59.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-59.0.1.tgz#e76ca18f6b92633440973e5442923a36544a1422" + integrity sha512-EtNXYuWPUmkgSU2E7Ttn57LbRREQesIP1BiLn7OZLKodopKfDXfBUkC/0j6mpw2JExwf43Uf3qLSvrSvppgy8Q== + dependencies: + "@babel/helper-validator-identifier" "^7.25.9" + "@eslint-community/eslint-utils" "^4.5.1" + "@eslint/plugin-kit" "^0.2.7" + ci-info "^4.2.0" + clean-regexp "^1.0.0" + core-js-compat "^3.41.0" + esquery "^1.6.0" + find-up-simple "^1.0.1" + globals "^16.0.0" + indent-string "^5.0.0" + is-builtin-module "^5.0.0" + jsesc "^3.1.0" + pluralize "^8.0.0" + regexp-tree "^0.1.27" + regjsparser "^0.12.0" + semver "^7.7.1" + strip-indent "^4.0.0" + +eslint-plugin-unused-imports@^4.1.4: + version "4.1.4" + resolved "https://registry.yarnpkg.com/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz#62ddc7446ccbf9aa7b6f1f0b00a980423cda2738" + integrity sha512-YptD6IzQjDardkl0POxnnRBhU1OEePMV0nd6siHaRBbd+lyh6NAhFEobiznKU7kTsSsDeSD62Pe7kAM1b7dAZQ== + +eslint-plugin-vue@^10.0.0: + version "10.1.0" + resolved "https://registry.npmmirror.com/eslint-plugin-vue/-/eslint-plugin-vue-10.1.0.tgz" + integrity sha512-/VTiJ1eSfNLw6lvG9ENySbGmcVvz6wZ9nA7ZqXlLBY2RkaF15iViYKxglWiIch12KiLAj0j1iXPYU6W4wTROFA== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + natural-compare "^1.4.0" + nth-check "^2.1.1" + postcss-selector-parser "^6.0.15" + semver "^7.6.3" + xml-name-validator "^4.0.0" + +eslint-plugin-vue@^10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-10.2.0.tgz#0c0bae71683d78e99736c5f3c500bf3d7d9c7c0d" + integrity sha512-tl9s+KN3z0hN2b8fV2xSs5ytGl7Esk1oSCxULLwFcdaElhZ8btYYZFrWxvh4En+czrSDtuLCeCOGa8HhEZuBdQ== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + natural-compare "^1.4.0" + nth-check "^2.1.1" + postcss-selector-parser "^6.0.15" + semver "^7.6.3" + xml-name-validator "^4.0.0" + +eslint-plugin-yml@^1.18.0: + version "1.18.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-yml/-/eslint-plugin-yml-1.18.0.tgz#bc3cc31d300de93d856b53f56012774c8287d36c" + integrity sha512-9NtbhHRN2NJa/s3uHchO3qVVZw0vyOIvWlXWGaKCr/6l3Go62wsvJK5byiI6ZoYztDsow4GnS69BZD3GnqH3hA== + dependencies: + debug "^4.3.2" + escape-string-regexp "4.0.0" + eslint-compat-utils "^0.6.0" + natural-compare "^1.4.0" + yaml-eslint-parser "^1.2.1" + +eslint-processor-vue-blocks@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/eslint-processor-vue-blocks/-/eslint-processor-vue-blocks-2.0.0.tgz#b06a2e2bdefda75792e9fc9f00a9de305e657472" + integrity sha512-u4W0CJwGoWY3bjXAuFpc/b6eK3NQEI8MoeW7ritKj3G3z/WtHrKjkqf+wk8mPEy5rlMGS+k6AZYOw2XBoN/02Q== + +eslint-scope@^8.2.0, eslint-scope@^8.3.0: + version "8.3.0" + resolved "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.3.0.tgz" + integrity sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.0.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^4.2.0: + version "4.2.0" + resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz" + integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw== + +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint@^9.28.0: + version "9.28.0" + resolved "https://registry.npmjs.org/eslint/-/eslint-9.28.0.tgz" + integrity sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.12.1" + "@eslint/config-array" "^0.20.0" + "@eslint/config-helpers" "^0.2.1" + "@eslint/core" "^0.14.0" + "@eslint/eslintrc" "^3.3.1" + "@eslint/js" "9.28.0" + "@eslint/plugin-kit" "^0.3.1" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + "@types/json-schema" "^7.0.15" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^8.3.0" + eslint-visitor-keys "^4.2.0" + espree "^10.3.0" + esquery "^1.5.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^10.0.1, espree@^10.3.0: + version "10.3.0" + resolved "https://registry.npmmirror.com/espree/-/espree-10.3.0.tgz" + integrity sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg== + dependencies: + acorn "^8.14.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.2.0" + +espree@^10.4.0, "espree@^9.6.1 || ^10.3.0": + version "10.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== + dependencies: + acorn "^8.15.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.2.1" + +espree@^9.0.0: + version "9.6.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" + integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== + dependencies: + acorn "^8.9.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.4.1" + +esquery@^1.5.0, esquery@^1.6.0: + version "1.6.0" + resolved "https://registry.npmmirror.com/esquery/-/esquery-1.6.0.tgz" + integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: + version "5.3.0" + resolved "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +evtd@^0.2.2, evtd@^0.2.4: + version "0.2.4" + resolved "https://registry.npmmirror.com/evtd/-/evtd-0.2.4.tgz" + integrity sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw== + +exsolve@^1.0.1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.7.tgz#3b74e4c7ca5c5f9a19c3626ca857309fa99f9e9e" + integrity sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-glob@^3.3.2: + version "3.3.3" + resolved "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.8" + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fastq@^1.6.0: + version "1.19.1" + resolved "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz" + integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== + dependencies: + reusify "^1.0.4" + +fault@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fault/-/fault-2.0.1.tgz#d47ca9f37ca26e4bd38374a7c500b5a384755b6c" + integrity sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ== + dependencies: + format "^0.2.0" + +fdir@^6.4.4: + version "6.4.6" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.6.tgz#2b268c0232697063111bbf3f64810a2a741ba281" + integrity sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w== + +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +find-up-simple@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/find-up-simple/-/find-up-simple-1.0.1.tgz#18fb90ad49e45252c4d7fca56baade04fa3fca1e" + integrity sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ== + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flatted@^3.2.9: + version "3.3.3" + resolved "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz" + integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + +format@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" + integrity sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww== + +fs-extra@^10.0.0: + version "10.1.0" + resolved "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz" + integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-tsconfig@^4.8.1: + version "4.10.1" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.10.1.tgz#d34c1c01f47d65a606c37aa7a177bc3e56ab4b2e" + integrity sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ== + dependencies: + resolve-pkg-maps "^1.0.0" + +github-slugger@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-2.0.0.tgz#52cf2f9279a21eb6c59dd385b410f0c0adda8f1a" + integrity sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw== + +glob-parent@^5.1.2, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.npmmirror.com/globals/-/globals-11.12.0.tgz" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +globals@^13.0.0: + version "13.24.0" + resolved "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz" + integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== + dependencies: + type-fest "^0.20.2" + +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== + +globals@^15.11.0: + version "15.15.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-15.15.0.tgz#7c4761299d41c32b075715a4ce1ede7897ff72a8" + integrity sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg== + +globals@^16.0.0, globals@^16.2.0: + version "16.2.0" + resolved "https://registry.npmmirror.com/globals/-/globals-16.2.0.tgz" + integrity sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg== + +graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4: + version "4.2.11" + resolved "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +he@^1.2.0: + version "1.2.0" + resolved "https://registry.npmmirror.com/he/-/he-1.2.0.tgz" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +highlight.js@^11.8.0: + version "11.11.1" + resolved "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz" + integrity sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w== + +hookable@^5.5.3: + version "5.5.3" + resolved "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz" + integrity sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ== + +hunspell-spellchecker@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/hunspell-spellchecker/-/hunspell-spellchecker-1.0.2.tgz" + integrity sha512-4DwmFAvlz+ChsqLDsZT2cwBsYNXh+oWboemxXtafwKIyItq52xfR4e4kr017sLAoPaSYVofSOvPUfmOAhXyYvw== + +ignore@^5.2.0, ignore@^5.2.4, ignore@^5.3.2: + version "5.3.2" + resolved "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +ignore@^7.0.0: + version "7.0.5" + resolved "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + +immutable@^5.0.2: + version "5.1.2" + resolved "https://registry.npmmirror.com/immutable/-/immutable-5.1.2.tgz" + integrity sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ== + +import-fresh@^3.2.1: + version "3.3.1" + resolved "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +indent-string@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-5.0.0.tgz#4fd2980fccaf8622d14c64d694f4cf33c81951a5" + integrity sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg== + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-builtin-module@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-5.0.0.tgz#19df4b9c7451149b68176b0e06d18646db6308dd" + integrity sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA== + dependencies: + builtin-modules "^5.0.0" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-what@^4.1.8: + version "4.1.16" + resolved "https://registry.npmmirror.com/is-what/-/is-what-4.1.16.tgz" + integrity sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jiti@^2.4.2: + version "2.4.2" + resolved "https://registry.npmmirror.com/jiti/-/jiti-2.4.2.tgz" + integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A== + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +jsdoc-type-pratt-parser@^4.0.0, jsdoc-type-pratt-parser@~4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz#ff6b4a3f339c34a6c188cbf50a16087858d22113" + integrity sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg== + +jsesc@^3.0.2, jsesc@^3.1.0: + version "3.1.0" + resolved "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +jsesc@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.0.2.tgz#bb8b09a6597ba426425f2e4a07245c3d00b9343e" + integrity sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g== + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +jsonc-eslint-parser@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.0.tgz#74ded53f9d716e8d0671bd167bf5391f452d5461" + integrity sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg== + dependencies: + acorn "^8.5.0" + eslint-visitor-keys "^3.0.0" + espree "^9.0.0" + semver "^7.3.5" + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.1.0.tgz" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +lightningcss-darwin-arm64@1.30.1: + version "1.30.1" + resolved "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz" + integrity sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ== + +lightningcss-darwin-x64@1.30.1: + version "1.30.1" + resolved "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz#e81105d3fd6330860c15fe860f64d39cff5fbd22" + integrity sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA== + +lightningcss-freebsd-x64@1.30.1: + version "1.30.1" + resolved "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz#a0e732031083ff9d625c5db021d09eb085af8be4" + integrity sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig== + +lightningcss-linux-arm-gnueabihf@1.30.1: + version "1.30.1" + resolved "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz#1f5ecca6095528ddb649f9304ba2560c72474908" + integrity sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q== + +lightningcss-linux-arm64-gnu@1.30.1: + version "1.30.1" + resolved "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz#eee7799726103bffff1e88993df726f6911ec009" + integrity sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw== + +lightningcss-linux-arm64-musl@1.30.1: + version "1.30.1" + resolved "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz#f2e4b53f42892feeef8f620cbb889f7c064a7dfe" + integrity sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ== + +lightningcss-linux-x64-gnu@1.30.1: + version "1.30.1" + resolved "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz#2fc7096224bc000ebb97eea94aea248c5b0eb157" + integrity sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw== + +lightningcss-linux-x64-musl@1.30.1: + version "1.30.1" + resolved "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz#66dca2b159fd819ea832c44895d07e5b31d75f26" + integrity sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ== + +lightningcss-win32-arm64-msvc@1.30.1: + version "1.30.1" + resolved "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz#7d8110a19d7c2d22bfdf2f2bb8be68e7d1b69039" + integrity sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA== + +lightningcss-win32-x64-msvc@1.30.1: + version "1.30.1" + resolved "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz#fd7dd008ea98494b85d24b4bea016793f2e0e352" + integrity sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg== + +lightningcss@1.30.1: + version "1.30.1" + resolved "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.1.tgz" + integrity sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-darwin-arm64 "1.30.1" + lightningcss-darwin-x64 "1.30.1" + lightningcss-freebsd-x64 "1.30.1" + lightningcss-linux-arm-gnueabihf "1.30.1" + lightningcss-linux-arm64-gnu "1.30.1" + lightningcss-linux-arm64-musl "1.30.1" + lightningcss-linux-x64-gnu "1.30.1" + lightningcss-linux-x64-musl "1.30.1" + lightningcss-win32-arm64-msvc "1.30.1" + lightningcss-win32-x64-msvc "1.30.1" + +local-pkg@^0.5.1: + version "0.5.1" + resolved "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz" + integrity sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ== + dependencies: + mlly "^1.7.3" + pkg-types "^1.2.1" + +local-pkg@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-1.1.1.tgz#f5fe74a97a3bd3c165788ee08ca9fbe998dc58dd" + integrity sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg== + dependencies: + mlly "^1.7.4" + pkg-types "^2.0.1" + quansync "^0.2.8" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +longest-streak@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4" + integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +lucide-vue-next@^0.525.0: + version "0.525.0" + resolved "https://registry.yarnpkg.com/lucide-vue-next/-/lucide-vue-next-0.525.0.tgz#94bafb8dcb6b6344dbbd8a00d8230cf5478e444e" + integrity sha512-Xf8+x8B2DrnGDV/rxylS+KBp2FIe6ljwDn2JsGTZZvXIfhmm/q+nv8RuGO1OyoMjOVkkz7CqtUqJfwtFPRbB2w== + +magic-string@*, magic-string@^0.30.14, magic-string@^0.30.17: + version "0.30.17" + resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz" + integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + +markdown-table@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.4.tgz#fe44d6d410ff9d6f2ea1797a3f60aa4d2b631c2a" + integrity sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw== + +mdast-util-find-and-replace@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz#70a3174c894e14df722abf43bc250cbae44b11df" + integrity sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg== + dependencies: + "@types/mdast" "^4.0.0" + escape-string-regexp "^5.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + +mdast-util-from-markdown@^2.0.0, mdast-util-from-markdown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz#4850390ca7cf17413a9b9a0fbefcd1bc0eb4160a" + integrity sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA== + dependencies: + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + mdast-util-to-string "^4.0.0" + micromark "^4.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-decode-string "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + unist-util-stringify-position "^4.0.0" + +mdast-util-frontmatter@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz#f5f929eb1eb36c8a7737475c7eb438261f964ee8" + integrity sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA== + dependencies: + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + escape-string-regexp "^5.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + micromark-extension-frontmatter "^2.0.0" + +mdast-util-gfm-autolink-literal@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz#abd557630337bd30a6d5a4bd8252e1c2dc0875d5" + integrity sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ== + dependencies: + "@types/mdast" "^4.0.0" + ccount "^2.0.0" + devlop "^1.0.0" + mdast-util-find-and-replace "^3.0.0" + micromark-util-character "^2.0.0" + +mdast-util-gfm-footnote@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz#7778e9d9ca3df7238cc2bd3fa2b1bf6a65b19403" + integrity sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ== + dependencies: + "@types/mdast" "^4.0.0" + devlop "^1.1.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + +mdast-util-gfm-strikethrough@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz#d44ef9e8ed283ac8c1165ab0d0dfd058c2764c16" + integrity sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-gfm-table@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz#7a435fb6223a72b0862b33afbd712b6dae878d38" + integrity sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg== + dependencies: + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + markdown-table "^3.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-gfm-task-list-item@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz#e68095d2f8a4303ef24094ab642e1047b991a936" + integrity sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ== + dependencies: + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-gfm@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz#2cdf63b92c2a331406b0fb0db4c077c1b0331751" + integrity sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ== + dependencies: + mdast-util-from-markdown "^2.0.0" + mdast-util-gfm-autolink-literal "^2.0.0" + mdast-util-gfm-footnote "^2.0.0" + mdast-util-gfm-strikethrough "^2.0.0" + mdast-util-gfm-table "^2.0.0" + mdast-util-gfm-task-list-item "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-phrasing@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz#7cc0a8dec30eaf04b7b1a9661a92adb3382aa6e3" + integrity sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w== + dependencies: + "@types/mdast" "^4.0.0" + unist-util-is "^6.0.0" + +mdast-util-to-markdown@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz#f910ffe60897f04bb4b7e7ee434486f76288361b" + integrity sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA== + dependencies: + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + longest-streak "^3.0.0" + mdast-util-phrasing "^4.0.0" + mdast-util-to-string "^4.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-decode-string "^2.0.0" + unist-util-visit "^5.0.0" + zwitch "^2.0.0" + +mdast-util-to-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814" + integrity sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg== + dependencies: + "@types/mdast" "^4.0.0" + +merge2@^1.3.0: + version "1.4.1" + resolved "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromark-core-commonmark@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz#c691630e485021a68cf28dbc2b2ca27ebf678cd4" + integrity sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg== + dependencies: + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-factory-destination "^2.0.0" + micromark-factory-label "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-factory-title "^2.0.0" + micromark-factory-whitespace "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-html-tag-name "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-frontmatter@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz#651c52ffa5d7a8eeed687c513cd869885882d67a" + integrity sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg== + dependencies: + fault "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-autolink-literal@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz#6286aee9686c4462c1e3552a9d505feddceeb935" + integrity sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-footnote@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz#4dab56d4e398b9853f6fe4efac4fc9361f3e0750" + integrity sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw== + dependencies: + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-strikethrough@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz#86106df8b3a692b5f6a92280d3879be6be46d923" + integrity sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw== + dependencies: + devlop "^1.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-table@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz#fac70bcbf51fe65f5f44033118d39be8a9b5940b" + integrity sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg== + dependencies: + devlop "^1.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-tagfilter@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz#f26d8a7807b5985fba13cf61465b58ca5ff7dc57" + integrity sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg== + dependencies: + micromark-util-types "^2.0.0" + +micromark-extension-gfm-task-list-item@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz#bcc34d805639829990ec175c3eea12bb5b781f2c" + integrity sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw== + dependencies: + devlop "^1.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz#3e13376ab95dd7a5cfd0e29560dfe999657b3c5b" + integrity sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w== + dependencies: + micromark-extension-gfm-autolink-literal "^2.0.0" + micromark-extension-gfm-footnote "^2.0.0" + micromark-extension-gfm-strikethrough "^2.0.0" + micromark-extension-gfm-table "^2.0.0" + micromark-extension-gfm-tagfilter "^2.0.0" + micromark-extension-gfm-task-list-item "^2.0.0" + micromark-util-combine-extensions "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-destination@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639" + integrity sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-label@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz#5267efa97f1e5254efc7f20b459a38cb21058ba1" + integrity sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg== + dependencies: + devlop "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-space@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz#36d0212e962b2b3121f8525fc7a3c7c029f334fc" + integrity sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-title@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz#237e4aa5d58a95863f01032d9ee9b090f1de6e94" + integrity sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-whitespace@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz#06b26b2983c4d27bfcc657b33e25134d4868b0b1" + integrity sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-character@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6" + integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q== + dependencies: + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-chunked@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz#47fbcd93471a3fccab86cff03847fc3552db1051" + integrity sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-classify-character@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz#d399faf9c45ca14c8b4be98b1ea481bced87b629" + integrity sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-combine-extensions@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz#2a0f490ab08bff5cc2fd5eec6dd0ca04f89b30a9" + integrity sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg== + dependencies: + micromark-util-chunked "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-decode-numeric-character-reference@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz#fcf15b660979388e6f118cdb6bf7d79d73d26fe5" + integrity sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-decode-string@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz#6cb99582e5d271e84efca8e61a807994d7161eb2" + integrity sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ== + dependencies: + decode-named-character-reference "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-encode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8" + integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== + +micromark-util-html-tag-name@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz#e40403096481986b41c106627f98f72d4d10b825" + integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA== + +micromark-util-normalize-identifier@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz#c30d77b2e832acf6526f8bf1aa47bc9c9438c16d" + integrity sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-resolve-all@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz#e1a2d62cdd237230a2ae11839027b19381e31e8b" + integrity sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg== + dependencies: + micromark-util-types "^2.0.0" + +micromark-util-sanitize-uri@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7" + integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-subtokenize@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz#d8ade5ba0f3197a1cf6a2999fbbfe6357a1a19ee" + integrity sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA== + dependencies: + devlop "^1.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-symbol@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8" + integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== + +micromark-util-types@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e" + integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== + +micromark@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.2.tgz#91395a3e1884a198e62116e33c9c568e39936fdb" + integrity sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA== + dependencies: + "@types/debug" "^4.0.0" + debug "^4.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-combine-extensions "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromatch@^4.0.5, micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +min-indent@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== + +minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.3, minimatch@^9.0.4, minimatch@^9.0.5: + version "9.0.5" + resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minipass@^7.0.4, minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +minizlib@^3.0.1: + version "3.0.2" + resolved "https://registry.npmmirror.com/minizlib/-/minizlib-3.0.2.tgz" + integrity sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA== + dependencies: + minipass "^7.1.2" + +mitt@^3.0.1: + version "3.0.1" + resolved "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz" + integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== + +mkdirp@^3.0.1: + version "3.0.1" + resolved "https://registry.npmmirror.com/mkdirp/-/mkdirp-3.0.1.tgz" + integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== + +mlly@^1.7.3, mlly@^1.7.4: + version "1.7.4" + resolved "https://registry.npmmirror.com/mlly/-/mlly-1.7.4.tgz" + integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw== + dependencies: + acorn "^8.14.0" + pathe "^2.0.1" + pkg-types "^1.3.0" + ufo "^1.5.4" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +muggle-string@^0.4.1: + version "0.4.1" + resolved "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz" + integrity sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ== + +naive-ui@^2.42.0: + version "2.42.0" + resolved "https://registry.yarnpkg.com/naive-ui/-/naive-ui-2.42.0.tgz#8c4bee39a82a59b9e6ba647dab30f28345ed68d3" + integrity sha512-c7cXR2YgOjgtBadXHwiWL4Y0tpGLAI5W5QzzHksOi22iuHXoSGMAzdkVTGVPE/PM0MSGQ/JtUIzCx2Y0hU0vTQ== + dependencies: + "@css-render/plugin-bem" "^0.15.14" + "@css-render/vue3-ssr" "^0.15.14" + "@types/katex" "^0.16.2" + "@types/lodash" "^4.14.198" + "@types/lodash-es" "^4.17.9" + async-validator "^4.2.5" + css-render "^0.15.14" + csstype "^3.1.3" + date-fns "^3.6.0" + date-fns-tz "^3.1.3" + evtd "^0.2.4" + highlight.js "^11.8.0" + lodash "^4.17.21" + lodash-es "^4.17.21" + seemly "^0.3.8" + treemate "^0.3.11" + vdirs "^0.1.8" + vooks "^0.2.12" + vueuc "^0.4.63" + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +natural-orderby@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/natural-orderby/-/natural-orderby-5.0.0.tgz#bb655f669ee9c84e82cdc6cddbba25eb263cd9f4" + integrity sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg== + +node-addon-api@^7.0.0: + version "7.1.1" + resolved "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== + +node-releases@^2.0.19: + version "2.0.19" + resolved "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.19.tgz" + integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== + +nora-zmodemjs@^1.1.1: + version "1.1.1" + resolved "https://registry.npmmirror.com/nora-zmodemjs/-/nora-zmodemjs-1.1.1.tgz" + integrity sha512-iUyKDEIsYIpdnLru+cr4C6WXeUn2t+jVlYTrK8IeA8uPAHXqnvUy62YuFOSezzdugDNiEpbAH1y2Bhv/fYXWkw== + dependencies: + crc-32 "^1.1.1" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +nth-check@^2.1.1: + version "2.1.1" + resolved "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +package-manager-detector@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-1.3.0.tgz#b42d641c448826e03c2b354272456a771ce453c0" + integrity sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-gitignore@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/parse-gitignore/-/parse-gitignore-2.0.0.tgz#81156b265115c507129f3faea067b8476da3b642" + integrity sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog== + +parse-imports-exports@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz#e3fb3b5e264cfb55c25b5dfcbe7f410f8dc4e7af" + integrity sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ== + dependencies: + parse-statements "1.0.11" + +parse-statements@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/parse-statements/-/parse-statements-1.0.11.tgz#8787c5d383ae5746568571614be72b0689584344" + integrity sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA== + +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +pathe@^2.0.1, pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +perfect-debounce@^1.0.0: + version "1.0.0" + resolved "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz" + integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA== + +picocolors@^1.0.0, picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +picomatch@^4.0.2: + version "4.0.2" + resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.2.tgz" + integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== + +pinia@^3.0.2: + version "3.0.3" + resolved "https://registry.npmmirror.com/pinia/-/pinia-3.0.3.tgz" + integrity sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA== + dependencies: + "@vue/devtools-api" "^7.7.2" + +pkg-types@^1.2.1, pkg-types@^1.3.0: + version "1.3.1" + resolved "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz" + integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== + dependencies: + confbox "^0.1.8" + mlly "^1.7.4" + pathe "^2.0.1" + +pkg-types@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-2.1.0.tgz#70c9e1b9c74b63fdde749876ee0aa007ea9edead" + integrity sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A== + dependencies: + confbox "^0.2.1" + exsolve "^1.0.1" + pathe "^2.0.3" + +pluralize@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" + integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== + +pnpm-workspace-yaml@0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/pnpm-workspace-yaml/-/pnpm-workspace-yaml-0.3.1.tgz#55970ecec0bf3b8b1dc16a9b50912f397b00055e" + integrity sha512-3nW5RLmREmZ8Pm8MbPsO2RM+99RRjYd25ynj3NV0cFsN7CcEl4sDFzgoFmSyduFwxFQ2Qbu3y2UdCh6HlyUOeA== + dependencies: + yaml "^2.7.0" + +postcss-selector-parser@^6.0.15: + version "6.1.2" + resolved "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz" + integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + +postcss@^8.5.3: + version "8.5.4" + resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.4.tgz" + integrity sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier@^3.3.3: + version "3.5.3" + resolved "https://registry.npmmirror.com/prettier/-/prettier-3.5.3.tgz" + integrity sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw== + +pretty-bytes@^7.0.0: + version "7.0.0" + resolved "https://registry.npmmirror.com/pretty-bytes/-/pretty-bytes-7.0.0.tgz" + integrity sha512-U5otLYPR3L0SVjHGrkEUx5mf7MxV2ceXeE7VwWPk+hyzC5drNohsOGNPDZqxCqyX1lkbEN4kl1LiI8QFd7r0ZA== + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +quansync@^0.2.8: + version "0.2.10" + resolved "https://registry.yarnpkg.com/quansync/-/quansync-0.2.10.tgz#32053cf166fa36511aae95fc49796116f2dc20e1" + integrity sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A== + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +rate-limiter-flexible@^5.0.3: + version "5.0.5" + resolved "https://registry.npmmirror.com/rate-limiter-flexible/-/rate-limiter-flexible-5.0.5.tgz" + integrity sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ== + +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +refa@^0.12.0, refa@^0.12.1: + version "0.12.1" + resolved "https://registry.yarnpkg.com/refa/-/refa-0.12.1.tgz#dac13c4782dc22b6bae6cce81a2b863888ea39c6" + integrity sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g== + dependencies: + "@eslint-community/regexpp" "^4.8.0" + +regexp-ast-analysis@^0.7.0, regexp-ast-analysis@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz#c0e24cb2a90f6eadd4cbaaba129317e29d29c482" + integrity sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A== + dependencies: + "@eslint-community/regexpp" "^4.8.0" + refa "^0.12.1" + +regexp-tree@^0.1.27: + version "0.1.27" + resolved "https://registry.yarnpkg.com/regexp-tree/-/regexp-tree-0.1.27.tgz#2198f0ef54518ffa743fe74d983b56ffd631b6cd" + integrity sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA== + +regjsparser@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.12.0.tgz#0e846df6c6530586429377de56e0475583b088dc" + integrity sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ== + dependencies: + jsesc "~3.0.2" + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-pkg-maps@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" + integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== + +reusify@^1.0.4: + version "1.1.0" + resolved "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== + +rfdc@^1.4.1: + version "1.4.1" + resolved "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz" + integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== + +rollup@^4.34.9: + version "4.44.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.44.0.tgz#0e10b98339b306edad1e612f1e5590a79aef521c" + integrity sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.44.0" + "@rollup/rollup-android-arm64" "4.44.0" + "@rollup/rollup-darwin-arm64" "4.44.0" + "@rollup/rollup-darwin-x64" "4.44.0" + "@rollup/rollup-freebsd-arm64" "4.44.0" + "@rollup/rollup-freebsd-x64" "4.44.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.44.0" + "@rollup/rollup-linux-arm-musleabihf" "4.44.0" + "@rollup/rollup-linux-arm64-gnu" "4.44.0" + "@rollup/rollup-linux-arm64-musl" "4.44.0" + "@rollup/rollup-linux-loongarch64-gnu" "4.44.0" + "@rollup/rollup-linux-powerpc64le-gnu" "4.44.0" + "@rollup/rollup-linux-riscv64-gnu" "4.44.0" + "@rollup/rollup-linux-riscv64-musl" "4.44.0" + "@rollup/rollup-linux-s390x-gnu" "4.44.0" + "@rollup/rollup-linux-x64-gnu" "4.44.0" + "@rollup/rollup-linux-x64-musl" "4.44.0" + "@rollup/rollup-win32-arm64-msvc" "4.44.0" + "@rollup/rollup-win32-ia32-msvc" "4.44.0" + "@rollup/rollup-win32-x64-msvc" "4.44.0" + fsevents "~2.3.2" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +sass@^1.77.8: + version "1.89.1" + resolved "https://registry.npmmirror.com/sass/-/sass-1.89.1.tgz" + integrity sha512-eMLLkl+qz7tx/0cJ9wI+w09GQ2zodTkcE/aVfywwdlRcI3EO19xGnbmJwg/JMIm+5MxVJ6outddLZ4Von4E++Q== + dependencies: + chokidar "^4.0.0" + immutable "^5.0.2" + source-map-js ">=0.6.2 <2.0.0" + optionalDependencies: + "@parcel/watcher" "^2.4.1" + +scslre@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/scslre/-/scslre-0.3.0.tgz#c3211e9bfc5547fc86b1eabaa34ed1a657060155" + integrity sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ== + dependencies: + "@eslint-community/regexpp" "^4.8.0" + refa "^0.12.0" + regexp-ast-analysis "^0.7.0" + +seemly@^0.3.6, seemly@^0.3.8: + version "0.3.10" + resolved "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz" + integrity sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q== + +semver@^6.3.1: + version "6.3.1" + resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.3.5, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3, semver@^7.7.1, semver@^7.7.2: + version "7.7.2" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + +sortablejs@^1.15.3: + version "1.15.6" + resolved "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.6.tgz" + integrity sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A== + +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2, source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +spdx-exceptions@^2.1.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz#5d607d27fc806f66d7b64a766650fa890f04ed66" + integrity sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w== + +spdx-expression-parse@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz#a23af9f3132115465dac215c099303e4ceac5794" + integrity sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.21" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz#6d6e980c9df2b6fc905343a3b2d702a6239536c3" + integrity sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg== + +speakingurl@^14.0.1: + version "14.0.1" + resolved "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz" + integrity sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ== + +strip-indent@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-4.0.0.tgz#b41379433dd06f5eae805e21d631e07ee670d853" + integrity sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA== + dependencies: + min-indent "^1.0.1" + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +superjson@^2.2.2: + version "2.2.2" + resolved "https://registry.npmmirror.com/superjson/-/superjson-2.2.2.tgz" + integrity sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q== + dependencies: + copy-anything "^3.0.2" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +"synckit@^0.6.2 || ^0.7.3 || ^0.11.5": + version "0.11.8" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.8.tgz#b2aaae998a4ef47ded60773ad06e7cb821f55457" + integrity sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A== + dependencies: + "@pkgr/core" "^0.2.4" + +tailwindcss@4.1.8, tailwindcss@^4.0.17: + version "4.1.8" + resolved "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.8.tgz" + integrity sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og== + +tapable@^2.2.0: + version "2.2.2" + resolved "https://registry.npmmirror.com/tapable/-/tapable-2.2.2.tgz" + integrity sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg== + +tar@^7.4.3: + version "7.4.3" + resolved "https://registry.npmmirror.com/tar/-/tar-7.4.3.tgz" + integrity sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw== + dependencies: + "@isaacs/fs-minipass" "^4.0.0" + chownr "^3.0.0" + minipass "^7.1.2" + minizlib "^3.0.1" + mkdirp "^3.0.1" + yallist "^5.0.0" + +tinyexec@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.1.tgz#70c31ab7abbb4aea0a24f55d120e5990bfa1e0b1" + integrity sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw== + +tinyglobby@^0.2.12, tinyglobby@^0.2.13: + version "0.2.14" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d" + integrity sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ== + dependencies: + fdir "^6.4.4" + picomatch "^4.0.2" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toml-eslint-parser@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/toml-eslint-parser/-/toml-eslint-parser-0.10.0.tgz#52000b150a8b298feefeea701c29cca8b4730a38" + integrity sha512-khrZo4buq4qVmsGzS5yQjKe/WsFvV8fGfOjDQN0q4iy9FjRfPWRgTFrU8u1R2iu/SfWLhY9WnCi4Jhdrcbtg+g== + dependencies: + eslint-visitor-keys "^3.0.0" + +treemate@^0.3.11: + version "0.3.11" + resolved "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz" + integrity sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg== + +ts-api-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz" + integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== + +ts-declaration-location@^1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/ts-declaration-location/-/ts-declaration-location-1.0.7.tgz#d4068fe9975828b3b453b3ab112b4711d8267688" + integrity sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA== + dependencies: + picomatch "^4.0.2" + +tslib@^2.4.0, tslib@^2.8.0: + version "2.8.1" + resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +typescript-eslint@^8.29.0: + version "8.33.1" + resolved "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.33.1.tgz" + integrity sha512-AgRnV4sKkWOiZ0Kjbnf5ytTJXMUZQ0qhSVdQtDNYLPLnjsATEYhaO94GlRQwi4t4gO8FfjM6NnikHeKjUm8D7A== + dependencies: + "@typescript-eslint/eslint-plugin" "8.33.1" + "@typescript-eslint/parser" "8.33.1" + "@typescript-eslint/utils" "8.33.1" + +typescript@^5.2.2: + version "5.8.3" + resolved "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz" + integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== + +ufo@^1.5.4: + version "1.6.1" + resolved "https://registry.npmmirror.com/ufo/-/ufo-1.6.1.tgz" + integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA== + +undici-types@~6.19.2: + version "6.19.8" + resolved "https://registry.npmmirror.com/undici-types/-/undici-types-6.19.8.tgz" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + +unist-util-is@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.0.tgz#b775956486aff107a9ded971d996c173374be424" + integrity sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-stringify-position@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" + integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-visit-parents@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz#4d5f85755c3b8f0dc69e21eca5d6d82d22162815" + integrity sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + +unist-util-visit@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6" + integrity sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + +universalify@^2.0.0: + version "2.0.1" + resolved "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz" + integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== + +unplugin-vue-components@^0.27.3: + version "0.27.5" + resolved "https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-0.27.5.tgz" + integrity sha512-m9j4goBeNwXyNN8oZHHxvIIYiG8FQ9UfmKWeNllpDvhU7btKNNELGPt+o3mckQKuPwrE7e0PvCsx+IWuDSD9Vg== + dependencies: + "@antfu/utils" "^0.7.10" + "@rollup/pluginutils" "^5.1.3" + chokidar "^3.6.0" + debug "^4.3.7" + fast-glob "^3.3.2" + local-pkg "^0.5.1" + magic-string "^0.30.14" + minimatch "^9.0.5" + mlly "^1.7.3" + unplugin "^1.16.0" + +unplugin@^1.16.0: + version "1.16.1" + resolved "https://registry.npmmirror.com/unplugin/-/unplugin-1.16.1.tgz" + integrity sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w== + dependencies: + acorn "^8.14.0" + webpack-virtual-modules "^0.6.2" + +update-browserslist-db@^1.1.3: + version "1.1.3" + resolved "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz" + integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +util-deprecate@^1.0.2: + version "1.0.2" + resolved "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +uuid@^10.0.0: + version "10.0.0" + resolved "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz" + integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + +vdirs@^0.1.4, vdirs@^0.1.8: + version "0.1.8" + resolved "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz" + integrity sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw== + dependencies: + evtd "^0.2.2" + +vite-plugin-compression@^0.5.1: + version "0.5.1" + resolved "https://registry.npmmirror.com/vite-plugin-compression/-/vite-plugin-compression-0.5.1.tgz" + integrity sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg== + dependencies: + chalk "^4.1.2" + debug "^4.3.3" + fs-extra "^10.0.0" + +vite-plugin-webpackchunkname@^1.0.3: + version "1.0.3" + resolved "https://registry.npmmirror.com/vite-plugin-webpackchunkname/-/vite-plugin-webpackchunkname-1.0.3.tgz" + integrity sha512-88lt6IrgCumnf4Up8eyaSJbmo4V0ZIaR4M94fbZvGGmK2aWMmPGVsiFBszYE7Kq04I9tGjLFnyremn+KEgEGyw== + dependencies: + "@rollup/plugin-alias" "*" + "@rollup/pluginutils" "*" + es-module-lexer "*" + magic-string "*" + +vite@^6.3.5: + version "6.3.5" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.3.5.tgz#fec73879013c9c0128c8d284504c6d19410d12a3" + integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ== + dependencies: + esbuild "^0.25.0" + fdir "^6.4.4" + picomatch "^4.0.2" + postcss "^8.5.3" + rollup "^4.34.9" + tinyglobby "^0.2.13" + optionalDependencies: + fsevents "~2.3.3" + +vooks@^0.2.12, vooks@^0.2.4: + version "0.2.12" + resolved "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz" + integrity sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q== + dependencies: + evtd "^0.2.2" + +vscode-uri@^3.0.8: + version "3.1.0" + resolved "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz" + integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ== + +vue-demi@>=0.14.8: + version "0.14.10" + resolved "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz" + integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg== + +vue-draggable-plus@^0.5.2: + version "0.5.6" + resolved "https://registry.npmmirror.com/vue-draggable-plus/-/vue-draggable-plus-0.5.6.tgz" + integrity sha512-RMt8YuoB534GH2LD2M60KxHDPtfjfl2C9mH3YSEsfcZBclcoRs5RyrLiEKhUM0QCAQG2T/c3ktHujzxxomqfIw== + dependencies: + "@types/sortablejs" "^1.15.8" + +vue-eslint-parser@^10.1.3: + version "10.1.3" + resolved "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.1.3.tgz" + integrity sha512-dbCBnd2e02dYWsXoqX5yKUZlOt+ExIpq7hmHKPb5ZqKcjf++Eo0hMseFTZMLKThrUk61m+Uv6A2YSBve6ZvuDQ== + dependencies: + debug "^4.4.0" + eslint-scope "^8.2.0" + eslint-visitor-keys "^4.2.0" + espree "^10.3.0" + esquery "^1.6.0" + lodash "^4.17.21" + semver "^7.6.3" + +vue-i18n@^11.1.5: + version "11.1.5" + resolved "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-11.1.5.tgz" + integrity sha512-XCwuaEA5AF97g1frvH/EI1zI9uo1XKTf2/OCFgts7NvUWRsjlgeHPrkJV+a3gpzai2pC4quZ4AnOHFO8QK9hsg== + dependencies: + "@intlify/core-base" "11.1.5" + "@intlify/shared" "11.1.5" + "@vue/devtools-api" "^6.5.0" + +vue-router@^4.4.0: + version "4.5.1" + resolved "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.1.tgz" + integrity sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw== + dependencies: + "@vue/devtools-api" "^6.6.4" + +vue-tsc@^2.0.24: + version "2.2.10" + resolved "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.10.tgz" + integrity sha512-jWZ1xSaNbabEV3whpIDMbjVSVawjAyW+x1n3JeGQo7S0uv2n9F/JMgWW90tGWNFRKya4YwKMZgCtr0vRAM7DeQ== + dependencies: + "@volar/typescript" "~2.4.11" + "@vue/language-core" "2.2.10" + +vue3-cookies@^1.0.6: + version "1.0.6" + resolved "https://registry.npmmirror.com/vue3-cookies/-/vue3-cookies-1.0.6.tgz" + integrity sha512-a1UvVD0qIgxyOqjlSOwnLnqAnz8ASltugEv8yX+96i/WGZAN9fEDci7xO4HIWZE1uToUnRq9JnFhvfDCSo45OA== + dependencies: + vue "^3.0.0" + +vue@^3.0.0, vue@^3.5.16: + version "3.5.16" + resolved "https://registry.npmmirror.com/vue/-/vue-3.5.16.tgz" + integrity sha512-rjOV2ecxMd5SiAmof2xzh2WxntRcigkX/He4YFJ6WdRvVUrbt6DxC1Iujh10XLl8xCDRDtGKMeO3D+pRQ1PP9w== + dependencies: + "@vue/compiler-dom" "3.5.16" + "@vue/compiler-sfc" "3.5.16" + "@vue/runtime-dom" "3.5.16" + "@vue/server-renderer" "3.5.16" + "@vue/shared" "3.5.16" + +vueuc@^0.4.63: + version "0.4.64" + resolved "https://registry.npmmirror.com/vueuc/-/vueuc-0.4.64.tgz" + integrity sha512-wlJQj7fIwKK2pOEoOq4Aro8JdPOGpX8aWQhV8YkTW9OgWD2uj2O8ANzvSsIGjx7LTOc7QbS7sXdxHi6XvRnHPA== + dependencies: + "@css-render/vue3-ssr" "^0.15.10" + "@juggle/resize-observer" "^3.3.1" + css-render "^0.15.10" + evtd "^0.2.4" + seemly "^0.3.6" + vdirs "^0.1.4" + vooks "^0.2.4" + +webpack-virtual-modules@^0.6.2: + version "0.6.2" + resolved "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz" + integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ== + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.npmmirror.com/which/-/which-2.0.2.tgz" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +xml-name-validator@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz" + integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== + +xterm-theme@^1.1.0: + version "1.1.0" + resolved "https://registry.npmmirror.com/xterm-theme/-/xterm-theme-1.1.0.tgz" + integrity sha512-n2GocBEbqcz4vEl4OYkU93hEVia8GWdnqchiz/0nQ/olRUyhulGf4wfha23x/D2m0imWaIavRZtt8c6kWZXdsA== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yallist@^5.0.0: + version "5.0.0" + resolved "https://registry.npmmirror.com/yallist/-/yallist-5.0.0.tgz" + integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== + +yaml-eslint-parser@^1.2.1, yaml-eslint-parser@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/yaml-eslint-parser/-/yaml-eslint-parser-1.3.0.tgz#975dd11f8349e18c15c88b0e41a6d0b0377969cd" + integrity sha512-E/+VitOorXSLiAqtTd7Yqax0/pAS3xaYMP+AUUJGOK1OZG3rhcj9fcJOM5HJ2VrP1FrStVCWr1muTfQCdj4tAA== + dependencies: + eslint-visitor-keys "^3.0.0" + yaml "^2.0.0" + +yaml@^2.0.0, yaml@^2.7.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.0.tgz#15f8c9866211bdc2d3781a0890e44d4fa1a5fff6" + integrity sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zmodem-ts@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/zmodem-ts/-/zmodem-ts-1.0.5.tgz#2adab064aff1eb8e258a170560cf80e81e9785f0" + integrity sha512-yyHiWd6OEZFSWB4F6JBTlJ1srOb+AOSr26JmHwzBxaiHZClm8It0YUPvwAyZC1ZP8AmWQ9sN8AIE4bmqsAiD9Q== + dependencies: + crc-32 "^1.2.2" + +zwitch@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== diff --git a/utils/build.sh b/utils/build.sh index 14770b7f8..ebeb89714 100644 --- a/utils/build.sh +++ b/utils/build.sh @@ -1,5 +1,4 @@ #!/bin/sh -# 该build基于 golang:1.12-alpine utils_dir=$(pwd) project_dir=$(dirname "$utils_dir") release_dir=${project_dir}/release @@ -29,11 +28,10 @@ if [[ -n "${VERSION-}" ]]; then fi goldflags="-X 'main.Buildstamp=$buildStamp' -X 'main.Githash=$gitHash' -X 'main.Goversion=$goVersion' -X 'github.com/jumpserver/koko/pkg/koko.Version=$kokoVersion' -X 'github.com/jumpserver/koko/pkg/config.CipherKey=$cipherKey'" -kubectlflags="-X 'github.com/jumpserver/koko/pkg/config.CipherKey=$cipherKey'" +k8scmdflags="-X 'github.com/jumpserver/koko/pkg/config.CipherKey=$cipherKey'" # 下载依赖模块并构建 cd .. && go mod download || exit 3 CGO_ENABLED=0 GOOS="$OS" go build -ldflags "$goldflags" -o koko ${project_dir}/cmd/koko/ || exit 4 -CGO_ENABLED=0 GOOS="$OS" go build -ldflags "$kubectlflags" -o kubectl ${project_dir}/cmd/kubectl/ || exit 4 set -x # 打包 @@ -43,6 +41,6 @@ mkdir -p "${to_dir}" cp -r "${utils_dir}/init-kubectl.sh" "${to_dir}" -for i in koko kubectl static templates locale config_example.yml;do +for i in koko kubectl helm static templates locale config_example.yml;do cp -r $i "${to_dir}" done diff --git a/utils/init-kubectl.sh b/utils/init-kubectl.sh index 02ef8a0dc..637228450 100644 --- a/utils/init-kubectl.sh +++ b/utils/init-kubectl.sh @@ -1,11 +1,12 @@ #!/bin/bash set -e -function init_nobody_user(){ - echo `getent passwd | grep 'nobody' | grep '/nonexistent' || usermod -d /nonexistent nobody` > /dev/null 2>&1 - echo `getent group | grep 'nogroup' || groupadd nogroup` > /dev/null 2>&1 +function init_jms_k8s_user(){ + echo `getent passwd | grep 'jms_k8s_user' || useradd -M -U -d /nonexistent jms_k8s_user` > /dev/null 2>&1 + echo `getent passwd | grep 'jms_k8s_user' | grep '/nonexistent' || usermod -d /nonexistent jms_k8s_user` > /dev/null 2>&1 + echo `getent group | grep 'jms_k8s_user' || groupadd jms_k8s_user` > /dev/null 2>&1 } export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -init_nobody_user +init_jms_k8s_user if [ "${WELCOME_BANNER}" ]; then echo ${WELCOME_BANNER} @@ -15,7 +16,7 @@ mkdir -p /nonexistent mount -t tmpfs -o size=10M tmpfs /nonexistent cd /nonexistent touch .bashrc -echo 'PS1="# "' >> .bashrc +echo 'PS1="${K8S_NAME}# "' >> .bashrc echo "export TERM=xterm" >> .bashrc echo "source /usr/share/bash-completion/bash_completion" >> .bashrc echo 'source /opt/kubectl-aliases/.kubectl_aliases' >> .bashrc @@ -24,19 +25,21 @@ echo 'complete -F __start_kubectl k' >> .bashrc mkdir -p .kube export HOME=/nonexistent +export LANG=en_US.UTF-8 -echo `rawkubectl config set-credentials JumpServer-user` > /dev/null 2>&1 -echo `rawkubectl config set-cluster kubernetes --server=${KUBECTL_CLUSTER}` > /dev/null 2>&1 -echo `rawkubectl config set-context kubernetes --cluster=kubernetes --user=JumpServer-user` > /dev/null 2>&1 -echo `rawkubectl config use-context kubernetes` > /dev/null 2>&1 +echo `kubectl config set-credentials JumpServer-user --token=${KUBECTL_TOKEN}` > /dev/null 2>&1 +echo `kubectl config set-cluster kubernetes --server=${KUBECTL_CLUSTER}` > /dev/null 2>&1 +echo `kubectl config set-context kubernetes --namespace=${KUBECTL_NAMESPACE}` > /dev/null 2>&1 +echo `kubectl config set-context kubernetes --cluster=kubernetes --user=JumpServer-user` > /dev/null 2>&1 +echo `kubectl config use-context kubernetes` > /dev/null 2>&1 if [ ${KUBECTL_INSECURE_SKIP_TLS_VERIFY} == "true" ];then { - clusters=`rawkubectl config get-clusters | tail -n +2` + clusters=`kubectl config get-clusters | tail -n +2` for s in ${clusters[@]}; do { - echo `rawkubectl config set-cluster ${s} --insecure-skip-tls-verify=true` > /dev/null 2>&1 - echo `rawkubectl config unset clusters.${s}.certificate-authority-data` > /dev/null 2>&1 + echo `kubectl config set-cluster ${s} --insecure-skip-tls-verify=true` > /dev/null 2>&1 + echo `kubectl config unset clusters.${s}.certificate-authority-data` > /dev/null 2>&1 } || { echo err > /dev/null 2>&1 } @@ -46,9 +49,9 @@ if [ ${KUBECTL_INSECURE_SKIP_TLS_VERIFY} == "true" ];then } fi -chown -R nobody:nogroup .kube -chown -R nobody:nogroup .bashrc +chown -R jms_k8s_user:jms_k8s_user .kube +chown -R jms_k8s_user:jms_k8s_user .bashrc export TMPDIR=/nonexistent -exec su -s /bin/bash nobody \ No newline at end of file +exec su -s /bin/bash jms_k8s_user diff --git a/utils/message.sh b/utils/message.sh index 44fbcf807..fe5f90f12 100755 --- a/utils/message.sh +++ b/utils/message.sh @@ -3,7 +3,7 @@ BASE_DIR=$(cd $(dirname $0);pwd) PROJECT_DIR=$(dirname ${BASE_DIR}) -LANG="zh_CN en_US ja_JP" +LANG="zh zh_Hant en ja pt_BR ko ru es" DOMAIN=koko BIN=${PROJECT_DIR}/cmd/i18ntool/geni18n.go INPUT=pkg