Compare commits
1 Commits
develop
...
fix/EE-681
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf6d205d78 |
52
.air.toml
52
.air.toml
@@ -1,52 +0,0 @@
|
|||||||
root = "."
|
|
||||||
testdata_dir = "testdata"
|
|
||||||
tmp_dir = ".tmp"
|
|
||||||
|
|
||||||
[build]
|
|
||||||
args_bin = []
|
|
||||||
bin = "./dist/portainer"
|
|
||||||
cmd = "SKIP_GO_GET=true make build-server"
|
|
||||||
delay = 1000
|
|
||||||
exclude_dir = []
|
|
||||||
exclude_file = []
|
|
||||||
exclude_regex = ["_test.go"]
|
|
||||||
exclude_unchanged = false
|
|
||||||
follow_symlink = false
|
|
||||||
full_bin = "./dist/portainer --log-level=DEBUG"
|
|
||||||
include_dir = ["api"]
|
|
||||||
include_ext = ["go"]
|
|
||||||
include_file = []
|
|
||||||
kill_delay = "0s"
|
|
||||||
log = "build-errors.log"
|
|
||||||
poll = false
|
|
||||||
poll_interval = 0
|
|
||||||
post_cmd = []
|
|
||||||
pre_cmd = []
|
|
||||||
rerun = false
|
|
||||||
rerun_delay = 500
|
|
||||||
send_interrupt = false
|
|
||||||
stop_on_error = false
|
|
||||||
|
|
||||||
[color]
|
|
||||||
app = ""
|
|
||||||
build = "yellow"
|
|
||||||
main = "magenta"
|
|
||||||
runner = "green"
|
|
||||||
watcher = "cyan"
|
|
||||||
|
|
||||||
[log]
|
|
||||||
main_only = false
|
|
||||||
silent = false
|
|
||||||
time = false
|
|
||||||
|
|
||||||
[misc]
|
|
||||||
clean_on_exit = false
|
|
||||||
|
|
||||||
[proxy]
|
|
||||||
app_port = 0
|
|
||||||
enabled = false
|
|
||||||
proxy_port = 0
|
|
||||||
|
|
||||||
[screen]
|
|
||||||
clear_on_rebuild = false
|
|
||||||
keep_scroll = true
|
|
||||||
@@ -17,7 +17,7 @@ plugins:
|
|||||||
- import
|
- import
|
||||||
|
|
||||||
parserOptions:
|
parserOptions:
|
||||||
ecmaVersion: latest
|
ecmaVersion: 2018
|
||||||
sourceType: module
|
sourceType: module
|
||||||
project: './tsconfig.json'
|
project: './tsconfig.json'
|
||||||
ecmaFeatures:
|
ecmaFeatures:
|
||||||
@@ -87,7 +87,6 @@ overrides:
|
|||||||
version: 'detect'
|
version: 'detect'
|
||||||
|
|
||||||
rules:
|
rules:
|
||||||
no-console: error
|
|
||||||
import/order:
|
import/order:
|
||||||
[
|
[
|
||||||
'error',
|
'error',
|
||||||
@@ -114,13 +113,7 @@ overrides:
|
|||||||
'@typescript-eslint/explicit-module-boundary-types': off
|
'@typescript-eslint/explicit-module-boundary-types': off
|
||||||
'@typescript-eslint/no-unused-vars': 'error'
|
'@typescript-eslint/no-unused-vars': 'error'
|
||||||
'@typescript-eslint/no-explicit-any': 'error'
|
'@typescript-eslint/no-explicit-any': 'error'
|
||||||
'jsx-a11y/label-has-associated-control':
|
'jsx-a11y/label-has-associated-control': ['error', { 'assert': 'either', controlComponents: ['Input', 'Checkbox'] }]
|
||||||
- error
|
|
||||||
- assert: either
|
|
||||||
controlComponents:
|
|
||||||
- Input
|
|
||||||
- Checkbox
|
|
||||||
'jsx-a11y/control-has-associated-label': off
|
|
||||||
'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }]
|
'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }]
|
||||||
'react/jsx-no-bind': off
|
'react/jsx-no-bind': off
|
||||||
'no-await-in-loop': 'off'
|
'no-await-in-loop': 'off'
|
||||||
@@ -139,24 +132,17 @@ overrides:
|
|||||||
'react/jsx-props-no-spreading': off
|
'react/jsx-props-no-spreading': off
|
||||||
- files:
|
- files:
|
||||||
- app/**/*.test.*
|
- app/**/*.test.*
|
||||||
plugins:
|
|
||||||
- '@vitest'
|
|
||||||
extends:
|
extends:
|
||||||
- 'plugin:@vitest/legacy-recommended'
|
- 'plugin:vitest/recommended'
|
||||||
env:
|
env:
|
||||||
'@vitest/env': true
|
'vitest/env': true
|
||||||
rules:
|
rules:
|
||||||
'react/jsx-no-constructed-context-values': off
|
'react/jsx-no-constructed-context-values': off
|
||||||
'@typescript-eslint/no-restricted-imports': off
|
'@typescript-eslint/no-restricted-imports': off
|
||||||
no-restricted-imports: off
|
no-restricted-imports: off
|
||||||
'react/jsx-props-no-spreading': off
|
|
||||||
'@vitest/no-conditional-expect': warn
|
|
||||||
'max-classes-per-file': off
|
|
||||||
- files:
|
- files:
|
||||||
- app/**/*.stories.*
|
- app/**/*.stories.*
|
||||||
rules:
|
rules:
|
||||||
'no-alert': off
|
'no-alert': off
|
||||||
'@typescript-eslint/no-restricted-imports': off
|
'@typescript-eslint/no-restricted-imports': off
|
||||||
no-restricted-imports: off
|
no-restricted-imports: off
|
||||||
'react/jsx-props-no-spreading': off
|
|
||||||
'storybook/no-renderer-packages': off
|
|
||||||
|
|||||||
2
.github/DISCUSSION_TEMPLATE/ideas.yaml
vendored
2
.github/DISCUSSION_TEMPLATE/ideas.yaml
vendored
@@ -6,7 +6,7 @@ body:
|
|||||||
|
|
||||||
Thanks for suggesting an idea for Portainer!
|
Thanks for suggesting an idea for Portainer!
|
||||||
|
|
||||||
Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion category](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead.
|
Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion cagetory](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead.
|
||||||
|
|
||||||
Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io) as they may point you toward a solution.
|
Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io) as they may point you toward a solution.
|
||||||
|
|
||||||
|
|||||||
70
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
70
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -2,6 +2,7 @@ name: Bug Report
|
|||||||
description: Create a report to help us improve.
|
description: Create a report to help us improve.
|
||||||
labels: kind/bug,bug/need-confirmation
|
labels: kind/bug,bug/need-confirmation
|
||||||
body:
|
body:
|
||||||
|
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
@@ -11,8 +12,6 @@ body:
|
|||||||
|
|
||||||
You can also ask for help in our [community Slack channel](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA).
|
You can also ask for help in our [community Slack channel](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA).
|
||||||
|
|
||||||
Please note that we only provide support for current versions of Portainer. You can find a list of supported versions in our [lifecycle policy](https://docs.portainer.io/start/lifecycle).
|
|
||||||
|
|
||||||
**DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**.
|
**DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
@@ -22,7 +21,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: Yes, I've searched similar issues on [GitHub](https://github.com/portainer/portainer/issues).
|
- label: Yes, I've searched similar issues on [GitHub](https://github.com/portainer/portainer/issues).
|
||||||
required: true
|
required: true
|
||||||
- label: Yes, I've checked whether this issue is covered in the Portainer [documentation](https://docs.portainer.io).
|
- label: Yes, I've checked whether this issue is covered in the Portainer [documentation](https://docs.portainer.io) or [knowledge base](https://portal.portainer.io/knowledge).
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: markdown
|
- type: markdown
|
||||||
@@ -91,58 +90,23 @@ body:
|
|||||||
- type: dropdown
|
- type: dropdown
|
||||||
attributes:
|
attributes:
|
||||||
label: Portainer version
|
label: Portainer version
|
||||||
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
description: We only provide support for the most recent version of Portainer and the previous 3 versions. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||||
multiple: false
|
multiple: false
|
||||||
options:
|
options:
|
||||||
- '2.39.0'
|
- '2.19.4'
|
||||||
- '2.38.1'
|
- '2.19.3'
|
||||||
- '2.38.0'
|
- '2.19.2'
|
||||||
- '2.37.0'
|
- '2.19.1'
|
||||||
- '2.36.0'
|
- '2.19.0'
|
||||||
- '2.35.0'
|
- '2.18.4'
|
||||||
- '2.34.0'
|
- '2.18.3'
|
||||||
- '2.33.7'
|
- '2.18.2'
|
||||||
- '2.33.6'
|
- '2.18.1'
|
||||||
- '2.33.5'
|
- '2.17.1'
|
||||||
- '2.33.4'
|
- '2.17.0'
|
||||||
- '2.33.3'
|
- '2.16.2'
|
||||||
- '2.33.2'
|
- '2.16.1'
|
||||||
- '2.33.1'
|
- '2.16.0'
|
||||||
- '2.33.0'
|
|
||||||
- '2.32.0'
|
|
||||||
- '2.31.3'
|
|
||||||
- '2.31.2'
|
|
||||||
- '2.31.1'
|
|
||||||
- '2.31.0'
|
|
||||||
- '2.30.1'
|
|
||||||
- '2.30.0'
|
|
||||||
- '2.29.2'
|
|
||||||
- '2.29.1'
|
|
||||||
- '2.29.0'
|
|
||||||
- '2.28.1'
|
|
||||||
- '2.28.0'
|
|
||||||
- '2.27.9'
|
|
||||||
- '2.27.8'
|
|
||||||
- '2.27.7'
|
|
||||||
- '2.27.6'
|
|
||||||
- '2.27.5'
|
|
||||||
- '2.27.4'
|
|
||||||
- '2.27.3'
|
|
||||||
- '2.27.2'
|
|
||||||
- '2.27.1'
|
|
||||||
- '2.27.0'
|
|
||||||
- '2.26.1'
|
|
||||||
- '2.26.0'
|
|
||||||
- '2.25.1'
|
|
||||||
- '2.25.0'
|
|
||||||
- '2.24.1'
|
|
||||||
- '2.24.0'
|
|
||||||
- '2.23.0'
|
|
||||||
- '2.22.0'
|
|
||||||
- '2.21.5'
|
|
||||||
- '2.21.4'
|
|
||||||
- '2.21.3'
|
|
||||||
- '2.21.2'
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
176
.github/workflows/ci.yaml
vendored
Normal file
176
.github/workflows/ci.yaml
vendored
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'develop'
|
||||||
|
- 'release/*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'develop'
|
||||||
|
- 'release/*'
|
||||||
|
- 'feat/*'
|
||||||
|
- 'fix/*'
|
||||||
|
- 'refactor/*'
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKER_HUB_REPO: portainerci/portainer-ce
|
||||||
|
EXTENSION_HUB_REPO: portainerci/portainer-docker-extension
|
||||||
|
GO_VERSION: 1.21.6
|
||||||
|
NODE_VERSION: 18.x
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_images:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
config:
|
||||||
|
- { platform: linux, arch: amd64, version: "" }
|
||||||
|
- { platform: linux, arch: arm64, version: "" }
|
||||||
|
- { platform: linux, arch: arm, version: "" }
|
||||||
|
- { platform: linux, arch: ppc64le, version: "" }
|
||||||
|
- { platform: linux, arch: s390x, version: "" }
|
||||||
|
- { platform: windows, arch: amd64, version: 1809 }
|
||||||
|
- { platform: windows, arch: amd64, version: ltsc2022 }
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
steps:
|
||||||
|
- name: '[preparation] checkout the current branch'
|
||||||
|
uses: actions/checkout@v4.1.1
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.inputs.branch }}
|
||||||
|
- name: '[preparation] set up golang'
|
||||||
|
uses: actions/setup-go@v5.0.0
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
- name: '[preparation] set up node.js'
|
||||||
|
uses: actions/setup-node@v4.0.1
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'yarn'
|
||||||
|
- name: '[preparation] set up qemu'
|
||||||
|
uses: docker/setup-qemu-action@v3.0.0
|
||||||
|
- name: '[preparation] set up docker context for buildx'
|
||||||
|
run: docker context create builders
|
||||||
|
- name: '[preparation] set up docker buildx'
|
||||||
|
uses: docker/setup-buildx-action@v3.0.0
|
||||||
|
with:
|
||||||
|
endpoint: builders
|
||||||
|
- name: '[preparation] docker login'
|
||||||
|
uses: docker/login-action@v3.0.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
- name: '[preparation] set the container image tag'
|
||||||
|
run: |
|
||||||
|
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||||
|
# use the release branch name as the tag for release branches
|
||||||
|
# for instance, release/2.19 becomes 2.19
|
||||||
|
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | cut -d "/" -f 2)
|
||||||
|
elif [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||||
|
# use pr${{ github.event.number }} as the tag for pull requests
|
||||||
|
# for instance, pr123
|
||||||
|
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
|
||||||
|
else
|
||||||
|
# replace / with - in the branch name
|
||||||
|
# for instance, feature/1.0.0 -> feature-1.0.0
|
||||||
|
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}${{ matrix.config.version }}-${{ matrix.config.arch }}" >> $GITHUB_ENV
|
||||||
|
- name: '[execution] build linux & windows portainer binaries'
|
||||||
|
run: |
|
||||||
|
export YARN_VERSION=$(yarn --version)
|
||||||
|
export WEBPACK_VERSION=$(yarn list webpack --depth=0 | grep webpack | awk -F@ '{print $2}')
|
||||||
|
export BUILDNUMBER=${GITHUB_RUN_NUMBER}
|
||||||
|
GIT_COMMIT_HASH_LONG=${{ github.sha }}
|
||||||
|
export GIT_COMMIT_HASH_SHORT={GIT_COMMIT_HASH_LONG:0:7}
|
||||||
|
|
||||||
|
NODE_ENV="testing"
|
||||||
|
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||||
|
NODE_ENV="production"
|
||||||
|
fi
|
||||||
|
|
||||||
|
make build-all PLATFORM=${{ matrix.config.platform }} ARCH=${{ matrix.config.arch }} ENV=${NODE_ENV}
|
||||||
|
env:
|
||||||
|
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
|
||||||
|
- name: '[execution] build and push docker images'
|
||||||
|
run: |
|
||||||
|
if [ "${{ matrix.config.platform }}" == "windows" ]; then
|
||||||
|
mv dist/portainer dist/portainer.exe
|
||||||
|
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} --build-arg OSVERSION=${{ matrix.config.version }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
|
||||||
|
else
|
||||||
|
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
|
||||||
|
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
|
||||||
|
|
||||||
|
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||||
|
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
|
||||||
|
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
|
||||||
|
build_manifests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
needs: [build_images]
|
||||||
|
steps:
|
||||||
|
- name: '[preparation] docker login'
|
||||||
|
uses: docker/login-action@v3.0.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
- name: '[preparation] set up docker context for buildx'
|
||||||
|
run: docker version && docker context create builders
|
||||||
|
- name: '[preparation] set up docker buildx'
|
||||||
|
uses: docker/setup-buildx-action@v3.0.0
|
||||||
|
with:
|
||||||
|
endpoint: builders
|
||||||
|
- name: '[execution] build and push manifests'
|
||||||
|
run: |
|
||||||
|
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||||
|
# use the release branch name as the tag for release branches
|
||||||
|
# for instance, release/2.19 becomes 2.19
|
||||||
|
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | cut -d "/" -f 2)
|
||||||
|
elif [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||||
|
# use pr${{ github.event.number }} as the tag for pull requests
|
||||||
|
# for instance, pr123
|
||||||
|
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
|
||||||
|
else
|
||||||
|
# replace / with - in the branch name
|
||||||
|
# for instance, feature/1.0.0 -> feature-1.0.0
|
||||||
|
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
|
||||||
|
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
|
||||||
|
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
|
||||||
|
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
|
||||||
|
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
|
||||||
|
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x" \
|
||||||
|
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windows1809-amd64" \
|
||||||
|
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windowsltsc2022-amd64"
|
||||||
|
|
||||||
|
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
|
||||||
|
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
|
||||||
|
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
|
||||||
|
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
|
||||||
|
|
||||||
|
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||||
|
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
|
||||||
|
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
|
||||||
|
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
|
||||||
|
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
|
||||||
|
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
|
||||||
|
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x"
|
||||||
|
|
||||||
|
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
|
||||||
|
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
|
||||||
|
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
|
||||||
|
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
|
||||||
|
fi
|
||||||
15
.github/workflows/label-conflcts.yaml
vendored
Normal file
15
.github/workflows/label-conflcts.yaml
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- 'release/**'
|
||||||
|
jobs:
|
||||||
|
triage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: mschilde/auto-label-merge-conflicts@master
|
||||||
|
with:
|
||||||
|
CONFLICT_LABEL_NAME: 'has conflicts'
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
MAX_RETRIES: 10
|
||||||
|
WAIT_MS: 60000
|
||||||
55
.github/workflows/lint.yml
vendored
Normal file
55
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
- release/*
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
- release/*
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: 1.21.6
|
||||||
|
NODE_VERSION: 18.x
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-linters:
|
||||||
|
name: Run linters
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'yarn'
|
||||||
|
- uses: actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
- run: yarn --frozen-lockfile
|
||||||
|
- name: Run linters
|
||||||
|
uses: wearerequired/lint-action@v1
|
||||||
|
with:
|
||||||
|
eslint: true
|
||||||
|
eslint_extensions: ts,tsx,js,jsx
|
||||||
|
prettier: true
|
||||||
|
prettier_dir: app/
|
||||||
|
gofmt: true
|
||||||
|
gofmt_dir: api/
|
||||||
|
- name: Typecheck
|
||||||
|
uses: icrawl/action-tsc@v1
|
||||||
|
- name: GolangCI-Lint
|
||||||
|
uses: golangci/golangci-lint-action@v3
|
||||||
|
with:
|
||||||
|
version: v1.55.2
|
||||||
|
args: --timeout=10m -c .golangci.yaml
|
||||||
252
.github/workflows/nightly-security-scan.yml
vendored
Normal file
252
.github/workflows/nightly-security-scan.yml
vendored
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
name: Nightly Code Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 20 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: 1.21.6
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
client-dependencies:
|
||||||
|
name: Client Dependency Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >- # only run for develop branch
|
||||||
|
github.ref == 'refs/heads/develop'
|
||||||
|
outputs:
|
||||||
|
js: ${{ steps.set-matrix.outputs.js_result }}
|
||||||
|
steps:
|
||||||
|
- name: checkout repository
|
||||||
|
uses: actions/checkout@master
|
||||||
|
|
||||||
|
- name: scan vulnerabilities by Snyk
|
||||||
|
uses: snyk/actions/node@master
|
||||||
|
continue-on-error: true # To make sure that artifact upload gets called
|
||||||
|
env:
|
||||||
|
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||||
|
with:
|
||||||
|
json: true
|
||||||
|
|
||||||
|
- name: upload scan result as develop artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: js-security-scan-develop-result
|
||||||
|
path: snyk.json
|
||||||
|
|
||||||
|
- name: develop scan report export to html
|
||||||
|
run: |
|
||||||
|
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=snyk --path="/data/snyk.json" --output-type=table --export --export-filename="/data/js-result")
|
||||||
|
|
||||||
|
- name: upload html file as artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: html-js-result-${{github.run_id}}
|
||||||
|
path: js-result.html
|
||||||
|
|
||||||
|
- name: analyse vulnerabilities
|
||||||
|
id: set-matrix
|
||||||
|
run: |
|
||||||
|
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=snyk --path="/data/snyk.json" --output-type=matrix)
|
||||||
|
echo "js_result=${result}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
server-dependencies:
|
||||||
|
name: Server Dependency Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >- # only run for develop branch
|
||||||
|
github.ref == 'refs/heads/develop'
|
||||||
|
outputs:
|
||||||
|
go: ${{ steps.set-matrix.outputs.go_result }}
|
||||||
|
steps:
|
||||||
|
- name: checkout repository
|
||||||
|
uses: actions/checkout@master
|
||||||
|
|
||||||
|
- name: install Go
|
||||||
|
uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
- name: download Go modules
|
||||||
|
run: cd ./api && go get -t -v -d ./...
|
||||||
|
|
||||||
|
- name: scan vulnerabilities by Snyk
|
||||||
|
continue-on-error: true # To make sure that artifact upload gets called
|
||||||
|
env:
|
||||||
|
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||||
|
run: |
|
||||||
|
yarn global add snyk
|
||||||
|
snyk test --file=./go.mod --json-file-output=snyk.json 2>/dev/null || :
|
||||||
|
|
||||||
|
- name: upload scan result as develop artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: go-security-scan-develop-result
|
||||||
|
path: snyk.json
|
||||||
|
|
||||||
|
- name: develop scan report export to html
|
||||||
|
run: |
|
||||||
|
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=snyk --path="/data/snyk.json" --output-type=table --export --export-filename="/data/go-result")
|
||||||
|
|
||||||
|
- name: upload html file as artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: html-go-result-${{github.run_id}}
|
||||||
|
path: go-result.html
|
||||||
|
|
||||||
|
- name: analyse vulnerabilities
|
||||||
|
id: set-matrix
|
||||||
|
run: |
|
||||||
|
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=snyk --path="/data/snyk.json" --output-type=matrix)
|
||||||
|
echo "go_result=${result}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
image-vulnerability:
|
||||||
|
name: Image Vulnerability Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.ref == 'refs/heads/develop'
|
||||||
|
outputs:
|
||||||
|
image-trivy: ${{ steps.set-trivy-matrix.outputs.image_trivy_result }}
|
||||||
|
image-docker-scout: ${{ steps.set-docker-scout-matrix.outputs.image_docker_scout_result }}
|
||||||
|
steps:
|
||||||
|
- name: scan vulnerabilities by Trivy
|
||||||
|
uses: docker://docker.io/aquasec/trivy:latest
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
args: image --ignore-unfixed=true --vuln-type="os,library" --exit-code=1 --format="json" --output="image-trivy.json" --no-progress portainerci/portainer:develop
|
||||||
|
|
||||||
|
- name: upload Trivy image security scan result as artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: image-security-scan-develop-result
|
||||||
|
path: image-trivy.json
|
||||||
|
|
||||||
|
- name: develop Trivy scan report export to html
|
||||||
|
run: |
|
||||||
|
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=trivy --path="/data/image-trivy.json" --output-type=table --export --export-filename="/data/image-trivy-result")
|
||||||
|
|
||||||
|
- name: upload html file as Trivy artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: html-image-result-${{github.run_id}}
|
||||||
|
path: image-trivy-result.html
|
||||||
|
|
||||||
|
- name: analyse vulnerabilities from Trivy
|
||||||
|
id: set-trivy-matrix
|
||||||
|
run: |
|
||||||
|
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=trivy --path="/data/image-trivy.json" --output-type=matrix)
|
||||||
|
echo "image_trivy_result=${result}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: scan vulnerabilities by Docker Scout
|
||||||
|
uses: docker/scout-action@v1
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
command: cves
|
||||||
|
image: portainerci/portainer:develop
|
||||||
|
sarif-file: image-docker-scout.json
|
||||||
|
dockerhub-user: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
dockerhub-password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
|
- name: upload Docker Scout image security scan result as artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: image-security-scan-develop-result
|
||||||
|
path: image-docker-scout.json
|
||||||
|
|
||||||
|
- name: develop Docker Scout scan report export to html
|
||||||
|
run: |
|
||||||
|
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=docker-scout --path="/data/image-docker-scout.json" --output-type=table --export --export-filename="/data/image-docker-scout-result")
|
||||||
|
|
||||||
|
- name: upload html file as Docker Scout artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: html-image-result-${{github.run_id}}
|
||||||
|
path: image-docker-scout-result.html
|
||||||
|
|
||||||
|
- name: analyse vulnerabilities from Docker Scout
|
||||||
|
id: set-docker-scout-matrix
|
||||||
|
run: |
|
||||||
|
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=docker-scout --path="/data/image-docker-scout.json" --output-type=matrix)
|
||||||
|
echo "image_docker_scout_result=${result}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
result-analysis:
|
||||||
|
name: Analyse Scan Results
|
||||||
|
needs: [client-dependencies, server-dependencies, image-vulnerability]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.ref == 'refs/heads/develop'
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
js: ${{fromJson(needs.client-dependencies.outputs.js)}}
|
||||||
|
go: ${{fromJson(needs.server-dependencies.outputs.go)}}
|
||||||
|
image-trivy: ${{fromJson(needs.image-vulnerability.outputs.image-trivy)}}
|
||||||
|
image-docker-scout: ${{fromJson(needs.image-vulnerability.outputs.image-docker-scout)}}
|
||||||
|
steps:
|
||||||
|
- name: display the results of js, Go, and image scan
|
||||||
|
run: |
|
||||||
|
echo "${{ matrix.js.status }}"
|
||||||
|
echo "${{ matrix.go.status }}"
|
||||||
|
echo "${{ matrix.image-trivy.status }}"
|
||||||
|
echo "${{ matrix.image-docker-scout.status }}"
|
||||||
|
echo "${{ matrix.js.summary }}"
|
||||||
|
echo "${{ matrix.go.summary }}"
|
||||||
|
echo "${{ matrix.image-trivy.summary }}"
|
||||||
|
echo "${{ matrix.image-docker-scout.summary }}"
|
||||||
|
|
||||||
|
- name: send message to Slack
|
||||||
|
if: >-
|
||||||
|
matrix.js.status == 'failure' ||
|
||||||
|
matrix.go.status == 'failure' ||
|
||||||
|
matrix.image-trivy.status == 'failure' ||
|
||||||
|
matrix.image-docker-scout.status == 'failure'
|
||||||
|
uses: slackapi/slack-github-action@v1.23.0
|
||||||
|
with:
|
||||||
|
payload: |
|
||||||
|
{
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": "Code Scanning Result (*${{ github.repository }}*)\n*<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|GitHub Actions Workflow URL>*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"color": "#FF0000",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": "*JS dependency check*: *${{ matrix.js.status }}*\n${{ matrix.js.summary }}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": "*Go dependency check*: *${{ matrix.go.status }}*\n${{ matrix.go.summary }}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": "*Image Trivy vulnerability check*: *${{ matrix.image-trivy.status }}*\n${{ matrix.image-trivy.summary }}\n"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": "*Image Docker Scout vulnerability check*: *${{ matrix.image-docker-scout.status }}*\n${{ matrix.image-docker-scout.summary }}\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
env:
|
||||||
|
SLACK_WEBHOOK_URL: ${{ secrets.SECURITY_SLACK_WEBHOOK_URL }}
|
||||||
|
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||||
298
.github/workflows/pr-security.yml
vendored
Normal file
298
.github/workflows/pr-security.yml
vendored
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
name: PR Code Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_review:
|
||||||
|
types:
|
||||||
|
- submitted
|
||||||
|
- edited
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'go.mod'
|
||||||
|
- 'build/linux/Dockerfile'
|
||||||
|
- 'build/linux/alpine.Dockerfile'
|
||||||
|
- 'build/windows/Dockerfile'
|
||||||
|
- '.github/workflows/pr-security.yml'
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: 1.21.6
|
||||||
|
NODE_VERSION: 18.x
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
client-dependencies:
|
||||||
|
name: Client Dependency Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.event.pull_request &&
|
||||||
|
github.event.review.body == '/scan' &&
|
||||||
|
github.event.pull_request.draft == false
|
||||||
|
outputs:
|
||||||
|
jsdiff: ${{ steps.set-diff-matrix.outputs.js_diff_result }}
|
||||||
|
steps:
|
||||||
|
- name: checkout repository
|
||||||
|
uses: actions/checkout@master
|
||||||
|
|
||||||
|
- name: scan vulnerabilities by Snyk
|
||||||
|
uses: snyk/actions/node@master
|
||||||
|
continue-on-error: true # To make sure that artifact upload gets called
|
||||||
|
env:
|
||||||
|
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||||
|
with:
|
||||||
|
json: true
|
||||||
|
|
||||||
|
- name: upload scan result as pull-request artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: js-security-scan-feat-result
|
||||||
|
path: snyk.json
|
||||||
|
|
||||||
|
- name: download artifacts from develop branch built by nightly scan
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
mv ./snyk.json ./js-snyk-feature.json
|
||||||
|
(gh run download -n js-security-scan-develop-result -R ${{ github.repository }} 2>&1 >/dev/null) || :
|
||||||
|
if [[ -e ./snyk.json ]]; then
|
||||||
|
mv ./snyk.json ./js-snyk-develop.json
|
||||||
|
else
|
||||||
|
echo "null" > ./js-snyk-develop.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: pr vs develop scan report comparison export to html
|
||||||
|
run: |
|
||||||
|
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=snyk --path="/data/js-snyk-feature.json" --compare-to="/data/js-snyk-develop.json" --output-type=table --export --export-filename="/data/js-result")
|
||||||
|
|
||||||
|
- name: upload html file as artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: html-js-result-compare-to-develop-${{github.run_id}}
|
||||||
|
path: js-result.html
|
||||||
|
|
||||||
|
- name: analyse different vulnerabilities against develop branch
|
||||||
|
id: set-diff-matrix
|
||||||
|
run: |
|
||||||
|
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=snyk --path="/data/js-snyk-feature.json" --compare-to="/data/js-snyk-develop.json" --output-type=matrix)
|
||||||
|
echo "js_diff_result=${result}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
server-dependencies:
|
||||||
|
name: Server Dependency Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.event.pull_request &&
|
||||||
|
github.event.review.body == '/scan' &&
|
||||||
|
github.event.pull_request.draft == false
|
||||||
|
outputs:
|
||||||
|
godiff: ${{ steps.set-diff-matrix.outputs.go_diff_result }}
|
||||||
|
steps:
|
||||||
|
- name: checkout repository
|
||||||
|
uses: actions/checkout@master
|
||||||
|
|
||||||
|
- name: install Go
|
||||||
|
uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
- name: download Go modules
|
||||||
|
run: cd ./api && go get -t -v -d ./...
|
||||||
|
|
||||||
|
- name: scan vulnerabilities by Snyk
|
||||||
|
continue-on-error: true # To make sure that artifact upload gets called
|
||||||
|
env:
|
||||||
|
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||||
|
run: |
|
||||||
|
yarn global add snyk
|
||||||
|
snyk test --file=./go.mod --json-file-output=snyk.json 2>/dev/null || :
|
||||||
|
|
||||||
|
- name: upload scan result as pull-request artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: go-security-scan-feature-result
|
||||||
|
path: snyk.json
|
||||||
|
|
||||||
|
- name: download artifacts from develop branch built by nightly scan
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
mv ./snyk.json ./go-snyk-feature.json
|
||||||
|
(gh run download -n go-security-scan-develop-result -R ${{ github.repository }} 2>&1 >/dev/null) || :
|
||||||
|
if [[ -e ./snyk.json ]]; then
|
||||||
|
mv ./snyk.json ./go-snyk-develop.json
|
||||||
|
else
|
||||||
|
echo "null" > ./go-snyk-develop.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: pr vs develop scan report comparison export to html
|
||||||
|
run: |
|
||||||
|
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=snyk --path="/data/go-snyk-feature.json" --compare-to="/data/go-snyk-develop.json" --output-type=table --export --export-filename="/data/go-result")
|
||||||
|
|
||||||
|
- name: upload html file as artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: html-go-result-compare-to-develop-${{github.run_id}}
|
||||||
|
path: go-result.html
|
||||||
|
|
||||||
|
- name: analyse different vulnerabilities against develop branch
|
||||||
|
id: set-diff-matrix
|
||||||
|
run: |
|
||||||
|
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=snyk --path="/data/go-snyk-feature.json" --compare-to="/data/go-snyk-develop.json" --output-type=matrix)
|
||||||
|
echo "go_diff_result=${result}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
image-vulnerability:
|
||||||
|
name: Image Vulnerability Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.event.pull_request &&
|
||||||
|
github.event.review.body == '/scan' &&
|
||||||
|
github.event.pull_request.draft == false
|
||||||
|
outputs:
|
||||||
|
imagediff-trivy: ${{ steps.set-diff-trivy-matrix.outputs.image_diff_trivy_result }}
|
||||||
|
imagediff-docker-scout: ${{ steps.set-diff-docker-scout-matrix.outputs.image_diff_docker_scout_result }}
|
||||||
|
steps:
|
||||||
|
- name: checkout code
|
||||||
|
uses: actions/checkout@master
|
||||||
|
|
||||||
|
- name: install Go
|
||||||
|
uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
- name: install Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
|
- name: Install packages
|
||||||
|
run: yarn --frozen-lockfile
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: make build-all
|
||||||
|
|
||||||
|
- name: set up docker buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: build and compress image
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: build/linux/Dockerfile
|
||||||
|
tags: local-portainer:${{ github.sha }}
|
||||||
|
outputs: type=docker,dest=/tmp/local-portainer-image.tar
|
||||||
|
|
||||||
|
- name: load docker image
|
||||||
|
run: |
|
||||||
|
docker load --input /tmp/local-portainer-image.tar
|
||||||
|
|
||||||
|
- name: scan vulnerabilities by Trivy
|
||||||
|
uses: docker://docker.io/aquasec/trivy:latest
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
args: image --ignore-unfixed=true --vuln-type="os,library" --exit-code=1 --format="json" --output="image-trivy.json" --no-progress local-portainer:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: upload Trivy image security scan result as artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: image-security-scan-feature-result
|
||||||
|
path: image-trivy.json
|
||||||
|
|
||||||
|
- name: download Trivy artifacts from develop branch built by nightly scan
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
mv ./image-trivy.json ./image-trivy-feature.json
|
||||||
|
(gh run download -n image-security-scan-develop-result -R ${{ github.repository }} 2>&1 >/dev/null) || :
|
||||||
|
if [[ -e ./image-trivy.json ]]; then
|
||||||
|
mv ./image-trivy.json ./image-trivy-develop.json
|
||||||
|
else
|
||||||
|
echo "null" > ./image-trivy-develop.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: pr vs develop Trivy scan report comparison export to html
|
||||||
|
run: |
|
||||||
|
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=trivy --path="/data/image-trivy-feature.json" --compare-to="/data/image-trivy-develop.json" --output-type=table --export --export-filename="/data/image-trivy-result")
|
||||||
|
|
||||||
|
- name: upload html file as Trivy artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: html-image-result-compare-to-develop-${{github.run_id}}
|
||||||
|
path: image-trivy-result.html
|
||||||
|
|
||||||
|
- name: analyse different vulnerabilities against develop branch by Trivy
|
||||||
|
id: set-diff-trivy-matrix
|
||||||
|
run: |
|
||||||
|
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=trivy --path="/data/image-trivy-feature.json" --compare-to="/data/image-trivy-develop.json" --output-type=matrix)
|
||||||
|
echo "image_diff_trivy_result=${result}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: scan vulnerabilities by Docker Scout
|
||||||
|
uses: docker/scout-action@v1
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
command: cves
|
||||||
|
image: local-portainer:${{ github.sha }}
|
||||||
|
sarif-file: image-docker-scout.json
|
||||||
|
dockerhub-user: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
dockerhub-password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
|
- name: upload Docker Scout image security scan result as artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: image-security-scan-feature-result
|
||||||
|
path: image-docker-scout.json
|
||||||
|
|
||||||
|
- name: download Docker Scout artifacts from develop branch built by nightly scan
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
mv ./image-docker-scout.json ./image-docker-scout-feature.json
|
||||||
|
(gh run download -n image-security-scan-develop-result -R ${{ github.repository }} 2>&1 >/dev/null) || :
|
||||||
|
if [[ -e ./image-docker-scout.json ]]; then
|
||||||
|
mv ./image-docker-scout.json ./image-docker-scout-develop.json
|
||||||
|
else
|
||||||
|
echo "null" > ./image-docker-scout-develop.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: pr vs develop Docker Scout scan report comparison export to html
|
||||||
|
run: |
|
||||||
|
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=docker-scout --path="/data/image-docker-scout-feature.json" --compare-to="/data/image-docker-scout-develop.json" --output-type=table --export --export-filename="/data/image-docker-scout-result")
|
||||||
|
|
||||||
|
- name: upload html file as Docker Scout artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: html-image-result-compare-to-develop-${{github.run_id}}
|
||||||
|
path: image-docker-scout-result.html
|
||||||
|
|
||||||
|
- name: analyse different vulnerabilities against develop branch by Docker Scout
|
||||||
|
id: set-diff-docker-scout-matrix
|
||||||
|
run: |
|
||||||
|
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=docker-scout --path="/data/image-docker-scout-feature.json" --compare-to="/data/image-docker-scout-develop.json" --output-type=matrix)
|
||||||
|
echo "image_diff_docker_scout_result=${result}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
result-analysis:
|
||||||
|
name: Analyse Scan Result Against develop Branch
|
||||||
|
needs: [client-dependencies, server-dependencies, image-vulnerability]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.event.pull_request &&
|
||||||
|
github.event.review.body == '/scan' &&
|
||||||
|
github.event.pull_request.draft == false
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
jsdiff: ${{fromJson(needs.client-dependencies.outputs.jsdiff)}}
|
||||||
|
godiff: ${{fromJson(needs.server-dependencies.outputs.godiff)}}
|
||||||
|
imagediff-trivy: ${{fromJson(needs.image-vulnerability.outputs.imagediff-trivy)}}
|
||||||
|
imagediff-docker-scout: ${{fromJson(needs.image-vulnerability.outputs.imagediff-docker-scout)}}
|
||||||
|
steps:
|
||||||
|
- name: check job status of diff result
|
||||||
|
if: >-
|
||||||
|
matrix.jsdiff.status == 'failure' ||
|
||||||
|
matrix.godiff.status == 'failure' ||
|
||||||
|
matrix.imagediff-trivy.status == 'failure' ||
|
||||||
|
matrix.imagediff-docker-scout.status == 'failure'
|
||||||
|
run: |
|
||||||
|
echo "${{ matrix.jsdiff.status }}"
|
||||||
|
echo "${{ matrix.godiff.status }}"
|
||||||
|
echo "${{ matrix.imagediff-trivy.status }}"
|
||||||
|
echo "${{ matrix.imagediff-docker-scout.status }}"
|
||||||
|
echo "${{ matrix.jsdiff.summary }}"
|
||||||
|
echo "${{ matrix.godiff.summary }}"
|
||||||
|
echo "${{ matrix.imagediff-trivy.summary }}"
|
||||||
|
echo "${{ matrix.imagediff-docker-scout.summary }}"
|
||||||
|
exit 1
|
||||||
19
.github/workflows/rebase.yml
vendored
Normal file
19
.github/workflows/rebase.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
name: Automatic Rebase
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
jobs:
|
||||||
|
rebase:
|
||||||
|
name: Rebase
|
||||||
|
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout the latest code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
fetch-depth: 0 # otherwise, you will fail to push refs to dest repo
|
||||||
|
- name: Automatic Rebase
|
||||||
|
uses: cirrus-actions/rebase@1.4
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
28
.github/workflows/stale.yml
vendored
Normal file
28
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
name: Close Stale Issues
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 12 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v8
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Issue Config
|
||||||
|
days-before-issue-stale: 60
|
||||||
|
days-before-issue-close: 7
|
||||||
|
stale-issue-label: 'status/stale'
|
||||||
|
exempt-all-issue-milestones: true # Do not stale issues in a milestone
|
||||||
|
exempt-issue-labels: kind/enhancement, kind/style, kind/workaround, kind/refactor, bug/need-confirmation, bug/confirmed, status/discuss
|
||||||
|
stale-issue-message: 'This issue has been marked as stale as it has not had recent activity, it will be closed if no further activity occurs in the next 7 days. If you believe that it has been incorrectly labelled as stale, leave a comment and the label will be removed.'
|
||||||
|
close-issue-message: 'Since no further activity has appeared on this issue it will be closed. If you believe that it has been incorrectly closed, leave a comment mentioning `portainer/support` and one of our staff will then review the issue. Note - If it is an old bug report, make sure that it is reproduceable in the latest version of Portainer as it may have already been fixed.'
|
||||||
|
|
||||||
|
# Pull Request Config
|
||||||
|
days-before-pr-stale: -1 # Do not stale pull request
|
||||||
|
days-before-pr-close: -1 # Do not close pull request
|
||||||
56
.github/workflows/test.yaml
vendored
Normal file
56
.github/workflows/test.yaml
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: 1.21.6
|
||||||
|
NODE_VERSION: 18.x
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
- release/*
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
- release/*
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-client:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'yarn'
|
||||||
|
- run: yarn --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: make test-client ARGS="--maxWorkers=2 --minWorkers=1"
|
||||||
|
test-server:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
config:
|
||||||
|
- { platform: linux, arch: amd64 }
|
||||||
|
- { platform: linux, arch: arm64 }
|
||||||
|
- { platform: windows, arch: amd64, version: 1809 }
|
||||||
|
- { platform: windows, arch: amd64, version: ltsc2022 }
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
- name: Run tests
|
||||||
|
run: make test-server
|
||||||
39
.github/workflows/validate-openapi-spec.yaml
vendored
Normal file
39
.github/workflows/validate-openapi-spec.yaml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Validate OpenAPI specs
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
- 'release/*'
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: 1.21.6
|
||||||
|
NODE_VERSION: 18.x
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
openapi-spec:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
- name: Download golang modules
|
||||||
|
run: cd ./api && go get -t -v -d ./...
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'yarn'
|
||||||
|
- run: yarn --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Validate OpenAPI Spec
|
||||||
|
run: make docs-validate
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,5 +18,3 @@ api/docs
|
|||||||
.env
|
.env
|
||||||
go.work.sum
|
go.work.sum
|
||||||
|
|
||||||
.vitest
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
version: "2"
|
|
||||||
linters:
|
|
||||||
default: none
|
|
||||||
enable:
|
|
||||||
- forbidigo
|
|
||||||
settings:
|
|
||||||
forbidigo:
|
|
||||||
forbid:
|
|
||||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$
|
|
||||||
msg: Use a transaction instead
|
|
||||||
analyze-types: true
|
|
||||||
exclusions:
|
|
||||||
rules:
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- forbidigo
|
|
||||||
124
.golangci.yaml
124
.golangci.yaml
@@ -1,108 +1,40 @@
|
|||||||
version: "2"
|
|
||||||
|
|
||||||
run:
|
|
||||||
allow-parallel-runners: true
|
|
||||||
linters:
|
linters:
|
||||||
default: none
|
# Disable all linters, the defaults don't pass on our code yet
|
||||||
|
disable-all: true
|
||||||
|
|
||||||
|
# Enable these for now
|
||||||
enable:
|
enable:
|
||||||
- bodyclose
|
- unused
|
||||||
- copyloopvar
|
|
||||||
- depguard
|
- depguard
|
||||||
- errcheck
|
- gosimple
|
||||||
- errorlint
|
|
||||||
- forbidigo
|
|
||||||
- govet
|
- govet
|
||||||
- ineffassign
|
|
||||||
- intrange
|
|
||||||
- perfsprint
|
|
||||||
- staticcheck
|
|
||||||
- unused
|
|
||||||
- mirror
|
|
||||||
- durationcheck
|
|
||||||
- errorlint
|
- errorlint
|
||||||
- govet
|
- exportloopref
|
||||||
- usetesting
|
|
||||||
- zerologlint
|
linters-settings:
|
||||||
- testifylint
|
|
||||||
- modernize
|
|
||||||
- unconvert
|
|
||||||
- unused
|
|
||||||
- zerologlint
|
|
||||||
- exptostd
|
|
||||||
settings:
|
|
||||||
staticcheck:
|
|
||||||
checks: ["all", "-ST1003", "-ST1005", "-ST1016", "-SA1019", "-QF1003"]
|
|
||||||
depguard:
|
depguard:
|
||||||
rules:
|
rules:
|
||||||
main:
|
main:
|
||||||
|
deny:
|
||||||
|
- pkg: 'encoding/json'
|
||||||
|
desc: 'use github.com/segmentio/encoding/json'
|
||||||
|
- pkg: 'github.com/sirupsen/logrus'
|
||||||
|
desc: 'logging is allowed only by github.com/rs/zerolog'
|
||||||
|
- pkg: 'golang.org/x/exp'
|
||||||
|
desc: 'exp is not allowed'
|
||||||
|
- pkg: 'github.com/portainer/libcrypto'
|
||||||
|
desc: 'use github.com/portainer/portainer/pkg/libcrypto'
|
||||||
|
- pkg: 'github.com/portainer/libhttp'
|
||||||
|
desc: 'use github.com/portainer/portainer/pkg/libhttp'
|
||||||
files:
|
files:
|
||||||
- '!**/*_test.go'
|
- '!**/*_test.go'
|
||||||
- '!**/base.go'
|
- '!**/base.go'
|
||||||
- '!**/base_tx.go'
|
- '!**/base_tx.go'
|
||||||
deny:
|
|
||||||
- pkg: encoding/json
|
# errorlint is causing a typecheck error for some reason. The go compiler will report these
|
||||||
desc: use github.com/segmentio/encoding/json
|
# anyway, so ignore them from the linter
|
||||||
- pkg: golang.org/x/exp
|
issues:
|
||||||
desc: exp is not allowed
|
exclude-rules:
|
||||||
- pkg: github.com/portainer/libcrypto
|
- path: ./
|
||||||
desc: use github.com/portainer/portainer/pkg/libcrypto
|
linters:
|
||||||
- pkg: github.com/portainer/libhttp
|
- typecheck
|
||||||
desc: use github.com/portainer/portainer/pkg/libhttp
|
|
||||||
- pkg: golang.org/x/crypto
|
|
||||||
desc: golang.org/x/crypto is not allowed because of FIPS mode
|
|
||||||
- pkg: github.com/ProtonMail/go-crypto/openpgp
|
|
||||||
desc: github.com/ProtonMail/go-crypto/openpgp is not allowed because of FIPS mode
|
|
||||||
- pkg: github.com/cosi-project/runtime
|
|
||||||
desc: github.com/cosi-project/runtime is not allowed because of FIPS mode
|
|
||||||
- pkg: gopkg.in/yaml.v2
|
|
||||||
desc: use go.yaml.in/yaml/v3 instead
|
|
||||||
- pkg: gopkg.in/yaml.v3
|
|
||||||
desc: use go.yaml.in/yaml/v3 instead
|
|
||||||
- pkg: github.com/golang-jwt/jwt/v4
|
|
||||||
desc: use github.com/golang-jwt/jwt/v5 instead
|
|
||||||
- pkg: github.com/mitchellh/mapstructure
|
|
||||||
desc: use github.com/go-viper/mapstructure/v2 instead
|
|
||||||
- pkg: gopkg.in/alecthomas/kingpin.v2
|
|
||||||
desc: use github.com/alecthomas/kingpin/v2 instead
|
|
||||||
- pkg: github.com/jcmturner/gokrb5$
|
|
||||||
desc: use github.com/jcmturner/gokrb5/v8 instead
|
|
||||||
- pkg: github.com/gofrs/uuid
|
|
||||||
desc: use github.com/google/uuid
|
|
||||||
- pkg: github.com/Masterminds/semver$
|
|
||||||
desc: use github.com/Masterminds/semver/v3
|
|
||||||
- pkg: github.com/blang/semver
|
|
||||||
desc: use github.com/Masterminds/semver/v3
|
|
||||||
- pkg: github.com/coreos/go-semver
|
|
||||||
desc: use github.com/Masterminds/semver/v3
|
|
||||||
- pkg: github.com/hashicorp/go-version
|
|
||||||
desc: use github.com/Masterminds/semver/v3
|
|
||||||
forbidigo:
|
|
||||||
forbid:
|
|
||||||
- pattern: ^tls\.Config$
|
|
||||||
msg: Use crypto.CreateTLSConfiguration() instead
|
|
||||||
- pattern: ^tls\.Config\.(InsecureSkipVerify|MinVersion|MaxVersion|CipherSuites|CurvePreferences)$
|
|
||||||
msg: Do not set this field directly, use crypto.CreateTLSConfiguration() instead
|
|
||||||
- pattern: ^object\.(Commit|Tag)\.Verify$
|
|
||||||
msg: "Not allowed because of FIPS mode"
|
|
||||||
- pattern: ^(types\.SystemContext\.)?(DockerDaemonInsecureSkipTLSVerify|DockerInsecureSkipTLSVerify|OCIInsecureSkipTLSVerify)$
|
|
||||||
msg: "Not allowed because of FIPS mode"
|
|
||||||
analyze-types: true
|
|
||||||
exclusions:
|
|
||||||
generated: lax
|
|
||||||
presets:
|
|
||||||
- comments
|
|
||||||
- common-false-positives
|
|
||||||
- legacy
|
|
||||||
paths:
|
|
||||||
- third_party$
|
|
||||||
- builtin$
|
|
||||||
- examples$
|
|
||||||
formatters:
|
|
||||||
enable:
|
|
||||||
- gofmt
|
|
||||||
exclusions:
|
|
||||||
generated: lax
|
|
||||||
paths:
|
|
||||||
- third_party$
|
|
||||||
- builtin$
|
|
||||||
- examples$
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
cd $(dirname -- "$0") && pnpm lint-staged
|
yarn lint-staged
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
dist
|
dist
|
||||||
api/datastore/test_data
|
api/datastore/test_data
|
||||||
coverage
|
|
||||||
@@ -9,21 +9,10 @@ const config: StorybookConfig = {
|
|||||||
addons: [
|
addons: [
|
||||||
'@storybook/addon-links',
|
'@storybook/addon-links',
|
||||||
'@storybook/addon-essentials',
|
'@storybook/addon-essentials',
|
||||||
'@storybook/addon-webpack5-compiler-swc',
|
|
||||||
'@chromatic-com/storybook',
|
|
||||||
{
|
{
|
||||||
name: '@storybook/addon-styling-webpack',
|
name: '@storybook/addon-styling',
|
||||||
|
|
||||||
options: {
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
test: /\.css$/,
|
|
||||||
sideEffects: true,
|
|
||||||
use: [
|
|
||||||
require.resolve('style-loader'),
|
|
||||||
{
|
|
||||||
loader: require.resolve('css-loader'),
|
|
||||||
options: {
|
options: {
|
||||||
|
cssLoaderOptions: {
|
||||||
importLoaders: 1,
|
importLoaders: 1,
|
||||||
modules: {
|
modules: {
|
||||||
localIdentName: '[path][name]__[local]',
|
localIdentName: '[path][name]__[local]',
|
||||||
@@ -31,17 +20,10 @@ const config: StorybookConfig = {
|
|||||||
exportLocalsConvention: 'camelCaseOnly',
|
exportLocalsConvention: 'camelCaseOnly',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
postCss: {
|
||||||
{
|
|
||||||
loader: require.resolve('postcss-loader'),
|
|
||||||
options: {
|
|
||||||
implementation: postcss,
|
implementation: postcss,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
webpackFinal: (config) => {
|
webpackFinal: (config) => {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import '../app/assets/css';
|
import '../app/assets/css';
|
||||||
|
import React from 'react';
|
||||||
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
|
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
|
||||||
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
|
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
|
||||||
import { handlers } from '../app/setup-tests/server-handlers';
|
import { handlers } from '../app/setup-tests/server-handlers';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||||
import { Preview } from '@storybook/react';
|
|
||||||
|
|
||||||
initMSW(
|
initMSW(
|
||||||
{
|
{
|
||||||
onUnhandledRequest: ({ method, url }) => {
|
onUnhandledRequest: ({ method, url }) => {
|
||||||
|
console.log(method, url);
|
||||||
if (url.startsWith('/api')) {
|
if (url.startsWith('/api')) {
|
||||||
console.error(`Unhandled ${method} request to ${url}.
|
console.error(`Unhandled ${method} request to ${url}.
|
||||||
|
|
||||||
@@ -21,20 +22,8 @@ initMSW(
|
|||||||
handlers
|
handlers
|
||||||
);
|
);
|
||||||
|
|
||||||
const testQueryClient = new QueryClient({
|
export const parameters = {
|
||||||
defaultOptions: { queries: { retry: false } },
|
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||||
});
|
|
||||||
|
|
||||||
const preview: Preview = {
|
|
||||||
decorators: (Story) => (
|
|
||||||
<QueryClientProvider client={testQueryClient}>
|
|
||||||
<UIRouter plugins={[pushStateLocationPlugin]}>
|
|
||||||
<Story />
|
|
||||||
</UIRouter>
|
|
||||||
</QueryClientProvider>
|
|
||||||
),
|
|
||||||
loaders: [mswLoader],
|
|
||||||
parameters: {
|
|
||||||
controls: {
|
controls: {
|
||||||
matchers: {
|
matchers: {
|
||||||
color: /(background|color)$/i,
|
color: /(background|color)$/i,
|
||||||
@@ -44,7 +33,20 @@ const preview: Preview = {
|
|||||||
msw: {
|
msw: {
|
||||||
handlers,
|
handlers,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default preview;
|
const testQueryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const decorators = [
|
||||||
|
(Story) => (
|
||||||
|
<QueryClientProvider client={testQueryClient}>
|
||||||
|
<UIRouter plugins={[pushStateLocationPlugin]}>
|
||||||
|
<Story />
|
||||||
|
</UIRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const loaders = [mswLoader];
|
||||||
|
|||||||
19
.vscode.example/launch.json
Normal file
19
.vscode.example/launch.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Launch",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "debug",
|
||||||
|
"program": "${workspaceRoot}/api/cmd/portainer",
|
||||||
|
"cwd": "${workspaceRoot}",
|
||||||
|
"env": {},
|
||||||
|
"showLog": true,
|
||||||
|
"args": ["--data", "${env:HOME}/portainer-data", "--assets", "${workspaceRoot}/dist"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
191
.vscode.example/portainer.code-snippets
Normal file
191
.vscode.example/portainer.code-snippets
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
{
|
||||||
|
// Place your portainer workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
|
||||||
|
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
|
||||||
|
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
|
||||||
|
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
|
||||||
|
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
|
||||||
|
// Placeholders with the same ids are connected.
|
||||||
|
// Example:
|
||||||
|
// "Print to console": {
|
||||||
|
// "scope": "javascript,typescript",
|
||||||
|
// "prefix": "log",
|
||||||
|
// "body": [
|
||||||
|
// "console.log('$1');",
|
||||||
|
// "$2"
|
||||||
|
// ],
|
||||||
|
// "description": "Log output to console"
|
||||||
|
// }
|
||||||
|
"React Named Export Component": {
|
||||||
|
"prefix": "rnec",
|
||||||
|
"body": [
|
||||||
|
"export function $TM_FILENAME_BASE() {",
|
||||||
|
" return <div>$TM_FILENAME_BASE</div>;",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"description": "React Named Export Component"
|
||||||
|
},
|
||||||
|
"Component": {
|
||||||
|
"scope": "javascript",
|
||||||
|
"prefix": "mycomponent",
|
||||||
|
"description": "Dummy Angularjs Component",
|
||||||
|
"body": [
|
||||||
|
"import angular from 'angular';",
|
||||||
|
"import controller from './${TM_FILENAME_BASE}Controller'",
|
||||||
|
"",
|
||||||
|
"angular.module('portainer.${TM_DIRECTORY/.*\\/app\\/([^\\/]*)(\\/.*)?$/$1/}').component('$TM_FILENAME_BASE', {",
|
||||||
|
" templateUrl: './$TM_FILENAME_BASE.html',",
|
||||||
|
" controller,",
|
||||||
|
"});",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Controller": {
|
||||||
|
"scope": "javascript",
|
||||||
|
"prefix": "mycontroller",
|
||||||
|
"body": [
|
||||||
|
"class ${TM_FILENAME_BASE/(.*)/${1:/capitalize}/} {",
|
||||||
|
"\t/* @ngInject */",
|
||||||
|
"\tconstructor($0) {",
|
||||||
|
"\t}",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"export default ${TM_FILENAME_BASE/(.*)/${1:/capitalize}/};"
|
||||||
|
],
|
||||||
|
"description": "Dummy ES6+ controller"
|
||||||
|
},
|
||||||
|
"Service": {
|
||||||
|
"scope": "javascript",
|
||||||
|
"prefix": "myservice",
|
||||||
|
"description": "Dummy ES6+ service",
|
||||||
|
"body": [
|
||||||
|
"import angular from 'angular';",
|
||||||
|
"import PortainerError from 'Portainer/error';",
|
||||||
|
"",
|
||||||
|
"class $1 {",
|
||||||
|
" /* @ngInject */",
|
||||||
|
" constructor(\\$async, $0) {",
|
||||||
|
" this.\\$async = \\$async;",
|
||||||
|
"",
|
||||||
|
" this.getAsync = this.getAsync.bind(this);",
|
||||||
|
" this.getAllAsync = this.getAllAsync.bind(this);",
|
||||||
|
" this.createAsync = this.createAsync.bind(this);",
|
||||||
|
" this.updateAsync = this.updateAsync.bind(this);",
|
||||||
|
" this.deleteAsync = this.deleteAsync.bind(this);",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
" /**",
|
||||||
|
" * GET",
|
||||||
|
" */",
|
||||||
|
" async getAsync() {",
|
||||||
|
" try {",
|
||||||
|
"",
|
||||||
|
" } catch (err) {",
|
||||||
|
" throw new PortainerError('', err);",
|
||||||
|
" }",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
" async getAllAsync() {",
|
||||||
|
" try {",
|
||||||
|
"",
|
||||||
|
" } catch (err) {",
|
||||||
|
" throw new PortainerError('', err);",
|
||||||
|
" }",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
" get() {",
|
||||||
|
" if () {",
|
||||||
|
" return this.\\$async(this.getAsync);",
|
||||||
|
" }",
|
||||||
|
" return this.\\$async(this.getAllAsync);",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
" /**",
|
||||||
|
" * CREATE",
|
||||||
|
" */",
|
||||||
|
" async createAsync() {",
|
||||||
|
" try {",
|
||||||
|
"",
|
||||||
|
" } catch (err) {",
|
||||||
|
" throw new PortainerError('', err);",
|
||||||
|
" }",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
" create() {",
|
||||||
|
" return this.\\$async(this.createAsync);",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
" /**",
|
||||||
|
" * UPDATE",
|
||||||
|
" */",
|
||||||
|
" async updateAsync() {",
|
||||||
|
" try {",
|
||||||
|
"",
|
||||||
|
" } catch (err) {",
|
||||||
|
" throw new PortainerError('', err);",
|
||||||
|
" }",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
" update() {",
|
||||||
|
" return this.\\$async(this.updateAsync);",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
" /**",
|
||||||
|
" * DELETE",
|
||||||
|
" */",
|
||||||
|
" async deleteAsync() {",
|
||||||
|
" try {",
|
||||||
|
"",
|
||||||
|
" } catch (err) {",
|
||||||
|
" throw new PortainerError('', err);",
|
||||||
|
" }",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
" delete() {",
|
||||||
|
" return this.\\$async(this.deleteAsync);",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"export default $1;",
|
||||||
|
"angular.module('portainer.${TM_DIRECTORY/.*\\/app\\/([^\\/]*)(\\/.*)?$/$1/}').service('$1', $1);"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"swagger-api-doc": {
|
||||||
|
"prefix": "swapi",
|
||||||
|
"scope": "go",
|
||||||
|
"description": "Snippet for a api doc",
|
||||||
|
"body": [
|
||||||
|
"// @id ",
|
||||||
|
"// @summary ",
|
||||||
|
"// @description ",
|
||||||
|
"// @description **Access policy**: ",
|
||||||
|
"// @tags ",
|
||||||
|
"// @security ApiKeyAuth",
|
||||||
|
"// @security jwt",
|
||||||
|
"// @accept json",
|
||||||
|
"// @produce json",
|
||||||
|
"// @param id path int true \"identifier\"",
|
||||||
|
"// @param body body Object true \"details\"",
|
||||||
|
"// @success 200 {object} portainer. \"Success\"",
|
||||||
|
"// @success 204 \"Success\"",
|
||||||
|
"// @failure 400 \"Invalid request\"",
|
||||||
|
"// @failure 403 \"Permission denied\"",
|
||||||
|
"// @failure 404 \" not found\"",
|
||||||
|
"// @failure 500 \"Server error\"",
|
||||||
|
"// @router /{id} [get]"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"prefix": "nlt",
|
||||||
|
"body": ["analytics-on", "analytics-category=\"$1\"", "analytics-event=\"$2\""],
|
||||||
|
"description": "analytics"
|
||||||
|
},
|
||||||
|
"analytics-if": {
|
||||||
|
"prefix": "nltf",
|
||||||
|
"body": ["analytics-if=\"$1\""],
|
||||||
|
"description": "analytics"
|
||||||
|
},
|
||||||
|
"analytics-metadata": {
|
||||||
|
"prefix": "nltm",
|
||||||
|
"body": "analytics-properties=\"{ metadata: { $1 } }\""
|
||||||
|
}
|
||||||
|
}
|
||||||
8
.vscode.example/settings.json
Normal file
8
.vscode.example/settings.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"go.lintTool": "golangci-lint",
|
||||||
|
"go.lintFlags": ["--fast", "-E", "exportloopref"],
|
||||||
|
"gopls": {
|
||||||
|
"build.expandWorkspaceToModule": false
|
||||||
|
},
|
||||||
|
"gitlens.advanced.blame.customArguments": ["--ignore-revs-file", ".git-blame-ignore-revs"]
|
||||||
|
}
|
||||||
44
CLAUDE.md
44
CLAUDE.md
@@ -1,44 +0,0 @@
|
|||||||
# Portainer Community Edition
|
|
||||||
|
|
||||||
Open-source container management platform with full Docker and Kubernetes support.
|
|
||||||
|
|
||||||
see also:
|
|
||||||
|
|
||||||
- docs/guidelines/server-architecture.md
|
|
||||||
- docs/guidelines/go-conventions.md
|
|
||||||
- docs/guidelines/typescript-conventions.md
|
|
||||||
|
|
||||||
## Package Manager
|
|
||||||
|
|
||||||
- **PNPM** 10+ (for frontend)
|
|
||||||
- **Go** 1.25.7 (for backend)
|
|
||||||
|
|
||||||
## Build Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Full build
|
|
||||||
make build # Build both client and server
|
|
||||||
make build-client # Build React/AngularJS frontend
|
|
||||||
make build-server # Build Go binary
|
|
||||||
make build-image # Build Docker image
|
|
||||||
|
|
||||||
# Development
|
|
||||||
make dev # Run both in dev mode
|
|
||||||
make dev-client # Start webpack-dev-server (port 8999)
|
|
||||||
make dev-server # Run containerized Go server
|
|
||||||
|
|
||||||
pnpm run dev # Webpack dev server
|
|
||||||
pnpm run build # Build frontend with webpack
|
|
||||||
pnpm run test # Run frontend tests
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
make test # All tests (backend + frontend)
|
|
||||||
make test-server # Backend tests only
|
|
||||||
make lint # Lint all code
|
|
||||||
make format # Format code
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Servers
|
|
||||||
|
|
||||||
- Frontend: http://localhost:8999
|
|
||||||
- Backend: http://localhost:9000 (HTTP) / https://localhost:9443 (HTTPS)
|
|
||||||
@@ -77,7 +77,7 @@ The feature request process is similar to the bug report process but has an extr
|
|||||||
|
|
||||||
## Build and run Portainer locally
|
## Build and run Portainer locally
|
||||||
|
|
||||||
Ensure you have Docker, Node.js, pnpm, and Golang installed in the correct versions.
|
Ensure you have Docker, Node.js, yarn, and Golang installed in the correct versions.
|
||||||
|
|
||||||
Install dependencies:
|
Install dependencies:
|
||||||
|
|
||||||
|
|||||||
50
Makefile
50
Makefile
@@ -1,3 +1,9 @@
|
|||||||
|
# See: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63
|
||||||
|
# For a list of valid GOOS and GOARCH values
|
||||||
|
# Note: these can be overriden on the command line e.g. `make PLATFORM=<platform> ARCH=<arch>`
|
||||||
|
PLATFORM=$(shell go env GOOS)
|
||||||
|
ARCH=$(shell go env GOARCH)
|
||||||
|
|
||||||
# build target, can be one of "production", "testing", "development"
|
# build target, can be one of "production", "testing", "development"
|
||||||
ENV=development
|
ENV=development
|
||||||
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
|
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
|
||||||
@@ -11,25 +17,27 @@ GOTESTSUM=go run gotest.tools/gotestsum@latest
|
|||||||
|
|
||||||
|
|
||||||
##@ Building
|
##@ Building
|
||||||
.PHONY: all init-dist build-storybook build build-client build-server build-image devops
|
.PHONY: init-dist build-storybook build build-client build-server build-image devops
|
||||||
init-dist:
|
init-dist:
|
||||||
@mkdir -p dist
|
@mkdir -p dist
|
||||||
|
|
||||||
all: tidy deps build-server build-client ## Build the client, server and download external dependancies (doesn't build an image)
|
build-all: deps build-server build-client ## Build the client, server and download external dependancies (doesn't build an image)
|
||||||
|
|
||||||
build-all: all ## Alias for the 'all' target (used by CI)
|
|
||||||
|
|
||||||
build-client: init-dist ## Build the client
|
build-client: init-dist ## Build the client
|
||||||
export NODE_ENV=$(ENV) && pnpm run build --config $(WEBPACK_CONFIG)
|
export NODE_ENV=$(ENV) && yarn build --config $(WEBPACK_CONFIG)
|
||||||
|
|
||||||
build-server: init-dist ## Build the server binary
|
build-server: init-dist ## Build the server binary
|
||||||
./build/build_binary.sh "$(PLATFORM)" "$(ARCH)"
|
./build/build_binary.sh "$(PLATFORM)" "$(ARCH)"
|
||||||
|
|
||||||
build-image: build-all ## Build the Portainer image locally
|
build-image: build-all ## Build the Portainer image locally
|
||||||
docker buildx build --load -t portainerci/portainer-ce:$(TAG) -f build/linux/Dockerfile .
|
docker buildx build --load -t portainerci/portainer:$(TAG) -f build/linux/Dockerfile .
|
||||||
|
|
||||||
build-storybook: ## Build and serve the storybook files
|
build-storybook: ## Build and serve the storybook files
|
||||||
pnpm run storybook:build
|
yarn storybook:build
|
||||||
|
|
||||||
|
devops: clean deps build-client ## Build the everything target specifically for CI
|
||||||
|
echo "Building the devops binary..."
|
||||||
|
@./build/build_binary_azuredevops.sh "$(PLATFORM)" "$(ARCH)"
|
||||||
|
|
||||||
##@ Build dependencies
|
##@ Build dependencies
|
||||||
.PHONY: deps server-deps client-deps tidy
|
.PHONY: deps server-deps client-deps tidy
|
||||||
@@ -39,10 +47,11 @@ server-deps: init-dist ## Download dependant server binaries
|
|||||||
@./build/download_binaries.sh $(PLATFORM) $(ARCH)
|
@./build/download_binaries.sh $(PLATFORM) $(ARCH)
|
||||||
|
|
||||||
client-deps: ## Install client dependencies
|
client-deps: ## Install client dependencies
|
||||||
pnpm install
|
yarn
|
||||||
|
|
||||||
tidy: ## Tidy up the go.mod file
|
tidy: ## Tidy up the go.mod file
|
||||||
@go mod tidy
|
cd api && go mod tidy
|
||||||
|
|
||||||
|
|
||||||
##@ Cleanup
|
##@ Cleanup
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
@@ -50,15 +59,16 @@ clean: ## Remove all build and download artifacts
|
|||||||
@echo "Clearing the dist directory..."
|
@echo "Clearing the dist directory..."
|
||||||
@rm -rf dist/*
|
@rm -rf dist/*
|
||||||
|
|
||||||
|
|
||||||
##@ Testing
|
##@ Testing
|
||||||
.PHONY: test test-client test-server
|
.PHONY: test test-client test-server
|
||||||
test: test-server test-client ## Run all tests
|
test: test-server test-client ## Run all tests
|
||||||
|
|
||||||
test-client: ## Run client tests
|
test-client: ## Run client tests
|
||||||
pnpm run test $(ARGS) --coverage
|
yarn test $(ARGS)
|
||||||
|
|
||||||
test-server: ## Run server tests
|
test-server: ## Run server tests
|
||||||
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
|
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover ./...
|
||||||
|
|
||||||
##@ Dev
|
##@ Dev
|
||||||
.PHONY: dev dev-client dev-server
|
.PHONY: dev dev-client dev-server
|
||||||
@@ -67,13 +77,11 @@ dev: ## Run both the client and server in development mode
|
|||||||
make dev-client
|
make dev-client
|
||||||
|
|
||||||
dev-client: ## Run the client in development mode
|
dev-client: ## Run the client in development mode
|
||||||
pnpm install && pnpm run dev
|
yarn dev
|
||||||
|
|
||||||
dev-server: build-server ## Run the server in development mode
|
dev-server: build-server ## Run the server in development mode
|
||||||
@./dev/run_container.sh
|
@./dev/run_container.sh
|
||||||
|
|
||||||
dev-server-podman: build-server ## Run the server in development mode
|
|
||||||
@./dev/run_container_podman.sh
|
|
||||||
|
|
||||||
##@ Format
|
##@ Format
|
||||||
.PHONY: format format-client format-server
|
.PHONY: format format-client format-server
|
||||||
@@ -81,7 +89,7 @@ dev-server-podman: build-server ## Run the server in development mode
|
|||||||
format: format-client format-server ## Format all code
|
format: format-client format-server ## Format all code
|
||||||
|
|
||||||
format-client: ## Format client code
|
format-client: ## Format client code
|
||||||
pnpm run format
|
yarn format
|
||||||
|
|
||||||
format-server: ## Format server code
|
format-server: ## Format server code
|
||||||
go fmt ./...
|
go fmt ./...
|
||||||
@@ -91,26 +99,26 @@ format-server: ## Format server code
|
|||||||
lint: lint-client lint-server ## Lint all code
|
lint: lint-client lint-server ## Lint all code
|
||||||
|
|
||||||
lint-client: ## Lint client code
|
lint-client: ## Lint client code
|
||||||
pnpm run lint
|
yarn lint
|
||||||
|
|
||||||
lint-server: tidy ## Lint server code
|
lint-server: ## Lint server code
|
||||||
golangci-lint run --timeout=10m -c .golangci.yaml
|
golangci-lint run --timeout=10m -c .golangci.yaml
|
||||||
golangci-lint run --timeout=10m --new-from-rev=HEAD~ -c .golangci-forward.yaml
|
|
||||||
|
|
||||||
##@ Extension
|
##@ Extension
|
||||||
.PHONY: dev-extension
|
.PHONY: dev-extension
|
||||||
dev-extension: build-server build-client ## Run the extension in development mode
|
dev-extension: build-server build-client ## Run the extension in development mode
|
||||||
make local -f build/docker-extension/Makefile
|
make local -f build/docker-extension/Makefile
|
||||||
|
|
||||||
|
|
||||||
##@ Docs
|
##@ Docs
|
||||||
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
|
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
|
||||||
docs-build: init-dist ## Build docs
|
docs-build: init-dist ## Build docs
|
||||||
go mod download -x
|
|
||||||
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
|
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
|
||||||
|
|
||||||
docs-validate: docs-build ## Validate docs
|
docs-validate: docs-build ## Validate docs
|
||||||
pnpm swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
|
yarn swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
|
||||||
pnpm swagger-cli validate dist/docs/openapi.yaml
|
yarn swagger-cli validate dist/docs/openapi.yaml
|
||||||
|
|
||||||
##@ Helpers
|
##@ Helpers
|
||||||
.PHONY: help
|
.PHONY: help
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -8,9 +8,9 @@ Portainer consists of a single container that can run on any cluster. It can be
|
|||||||
|
|
||||||
**Portainer Business Edition** builds on the open-source base and includes a range of advanced features and functions (like RBAC and Support) that are specific to the needs of business users.
|
**Portainer Business Edition** builds on the open-source base and includes a range of advanced features and functions (like RBAC and Support) that are specific to the needs of business users.
|
||||||
|
|
||||||
- [Compare Portainer CE and Compare Portainer BE](https://www.portainer.io/features)
|
- [Compare Portainer CE and Compare Portainer BE](https://portainer.io/products)
|
||||||
- [Take3 – get 3 free nodes of Portainer Business for as long as you want them](https://www.portainer.io/take-3)
|
- [Take3 – get 3 free nodes of Portainer Business for as long as you want them](https://www.portainer.io/take-3)
|
||||||
- [Portainer BE install guide](https://academy.portainer.io/install/)
|
- [Portainer BE install guide](https://install.portainer.io)
|
||||||
|
|
||||||
## Latest Version
|
## Latest Version
|
||||||
|
|
||||||
@@ -20,19 +20,22 @@ Portainer CE is updated regularly. We aim to do an update release every couple o
|
|||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
- [Deploy Portainer](https://docs.portainer.io/start/install-ce)
|
- [Deploy Portainer](https://docs.portainer.io/start/install)
|
||||||
- [Documentation](https://docs.portainer.io)
|
- [Documentation](https://docs.portainer.io)
|
||||||
- [Contribute to the project](https://docs.portainer.io/contribute/contribute)
|
- [Contribute to the project](https://docs.portainer.io/contribute/contribute)
|
||||||
|
|
||||||
## Features & Functions
|
## Features & Functions
|
||||||
|
|
||||||
View [this](https://www.portainer.io/features) table to see all of the Portainer CE functionality and compare to Portainer Business.
|
View [this](https://www.portainer.io/products) table to see all of the Portainer CE functionality and compare to Portainer Business.
|
||||||
|
|
||||||
|
- [Portainer CE for Docker / Docker Swarm](https://www.portainer.io/solutions/docker)
|
||||||
|
- [Portainer CE for Kubernetes](https://www.portainer.io/solutions/kubernetes-ui)
|
||||||
|
|
||||||
## Getting help
|
## Getting help
|
||||||
|
|
||||||
Portainer CE is an open source project and is supported by the community. You can buy a supported version of Portainer at portainer.io
|
Portainer CE is an open source project and is supported by the community. You can buy a supported version of Portainer at portainer.io
|
||||||
|
|
||||||
Learn more about Portainer's community support channels [here.](https://www.portainer.io/resources/get-help/get-support)
|
Learn more about Portainer's community support channels [here.](https://www.portainer.io/get-support-for-portainer)
|
||||||
|
|
||||||
- Issues: https://github.com/portainer/portainer/issues
|
- Issues: https://github.com/portainer/portainer/issues
|
||||||
- Slack (chat): [https://portainer.io/slack](https://portainer.io/slack)
|
- Slack (chat): [https://portainer.io/slack](https://portainer.io/slack)
|
||||||
@@ -46,17 +49,17 @@ You can join the Portainer Community by visiting [https://www.portainer.io/join-
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
For information about reporting security vulnerabilities, please see our [Security Policy](SECURITY.md).
|
- Here at Portainer, we believe in [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) of security issues. If you have found a security issue, please report it to <security@portainer.io>.
|
||||||
|
|
||||||
## Work for us
|
## Work for us
|
||||||
|
|
||||||
If you are a developer, and our code in this repo makes sense to you, we would love to hear from you. We are always on the hunt for awesome devs, either freelance or employed. Drop us a line to success@portainer.io with your details and/or visit our [careers page](https://apply.workable.com/portainer/).
|
If you are a developer, and our code in this repo makes sense to you, we would love to hear from you. We are always on the hunt for awesome devs, either freelance or employed. Drop us a line to info@portainer.io with your details and/or visit our [careers page](https://portainer.io/careers).
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
**To make sure we focus our development effort in the right places we need to know which features get used most often. To give us this information we use [Matomo Analytics](https://matomo.org/), which is hosted in Germany and is fully GDPR compliant.**
|
**To make sure we focus our development effort in the right places we need to know which features get used most often. To give us this information we use [Matomo Analytics](https://matomo.org/), which is hosted in Germany and is fully GDPR compliant.**
|
||||||
|
|
||||||
When Portainer first starts, you are given the option to DISABLE analytics. If you **don't** choose to disable it, we collect anonymous usage as per [our privacy policy](https://www.portainer.io/legal/privacy-policy). **Please note**, there is no personally identifiable information sent or stored at any time and we only use the data to help us improve Portainer.
|
When Portainer first starts, you are given the option to DISABLE analytics. If you **don't** choose to disable it, we collect anonymous usage as per [our privacy policy](https://www.portainer.io/privacy-policy). **Please note**, there is no personally identifiable information sent or stored at any time and we only use the data to help us improve Portainer.
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
|
|||||||
61
SECURITY.md
61
SECURITY.md
@@ -1,61 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
## Supported Versions
|
|
||||||
|
|
||||||
Portainer maintains both Short-Term Support (STS) and Long-Term Support (LTS) versions in accordance with our official [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle).
|
|
||||||
|
|
||||||
| Version Type | Support Status |
|
|
||||||
| --- | --- |
|
|
||||||
| LTS (Long-Term Support) | Supported for critical security fixes |
|
|
||||||
| STS (Short-Term Support) | Supported until the next STS or LTS release |
|
|
||||||
| Legacy / EOL | Not supported |
|
|
||||||
|
|
||||||
For a detailed breakdown of current versions and their specific End of Life (EOL) dates,
|
|
||||||
please refer to the [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle).
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
|
||||||
|
|
||||||
The Portainer team takes the security of our products seriously. If you believe you have found a security vulnerability in any Portainer-owned repository, please report it to us responsibly.
|
|
||||||
|
|
||||||
**Please do not report security vulnerabilities via public GitHub issues.**
|
|
||||||
|
|
||||||
### Disclosure Process
|
|
||||||
|
|
||||||
1. **Report**: You can report in one of two ways:
|
|
||||||
|
|
||||||
- **GitHub**: Use the **Report a vulnerability** button on the **Security** tab of this repository.
|
|
||||||
|
|
||||||
- **Email**: Send your findings to security@portainer.io.
|
|
||||||
|
|
||||||
2. **Details**: To help us verify the issue, please include:
|
|
||||||
|
|
||||||
- A description of the vulnerability and its potential impact.
|
|
||||||
|
|
||||||
- Step-by-step instructions to reproduce the issue (e.g. proof-of-concept code, scripts, or screenshots).
|
|
||||||
|
|
||||||
- The version of the software and the environment in which it was found.
|
|
||||||
|
|
||||||
3. **Acknowledge**: We will acknowledge receipt of your report and provide an initial assessment.
|
|
||||||
|
|
||||||
4. **Resolution**: We will work to resolve the issue as quickly as possible. We request that you do not disclose the vulnerability publicly until we have released a fix and notified affected users.
|
|
||||||
|
|
||||||
## Our Commitment
|
|
||||||
|
|
||||||
If you follow the responsible disclosure process, we will:
|
|
||||||
|
|
||||||
- Respond to your report in a timely manner.
|
|
||||||
|
|
||||||
- Provide an estimated timeline for remediation.
|
|
||||||
|
|
||||||
- Notify you when the vulnerability has been patched.
|
|
||||||
|
|
||||||
- Give credit for the discovery (if desired) once the fix is public.
|
|
||||||
|
|
||||||
|
|
||||||
We will make every effort to promptly address any security weaknesses. Security advisories and fixes will be published through GitHub Security Advisories and other channels as needed.
|
|
||||||
|
|
||||||
Thank you for helping keep Portainer and our community secure.
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- [Contributing to Portainer](https://docs.portainer.io/contribute/contribute#contributing-to-the-portainer-ce-codebase)
|
|
||||||
@@ -10,19 +10,21 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/url"
|
"github.com/portainer/portainer/api/internal/url"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetAgentVersionAndPlatform returns the agent version and platform
|
// GetAgentVersionAndPlatform returns the agent version and platform
|
||||||
//
|
//
|
||||||
// it sends a ping to the agent and parses the version and platform from the headers
|
// it sends a ping to the agent and parses the version and platform from the headers
|
||||||
func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) { //nolint:forbidigo
|
func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) {
|
||||||
httpCli := &http.Client{Timeout: 3 * time.Second}
|
httpCli := &http.Client{
|
||||||
|
Timeout: 3 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
if tlsConfig != nil {
|
if tlsConfig != nil {
|
||||||
httpCli.Transport = &http.Transport{TLSClientConfig: tlsConfig}
|
httpCli.Transport = &http.Transport{
|
||||||
|
TLSClientConfig: tlsConfig,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedURL, err := url.ParseURL(endpointUrl + "/ping")
|
parsedURL, err := url.ParseURL(endpointUrl + "/ping")
|
||||||
@@ -42,10 +44,8 @@ func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (port
|
|||||||
return 0, "", err
|
return 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
io.Copy(io.Discard, resp.Body)
|
||||||
if err := resp.Body.Close(); err != nil {
|
resp.Body.Close()
|
||||||
log.Warn().Err(err).Msg("failed to close response body")
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusNoContent {
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
return 0, "", fmt.Errorf("Failed request with status %d", resp.StatusCode)
|
return 0, "", fmt.Errorf("Failed request with status %d", resp.StatusCode)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package apikey
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/api/internal/securecookie"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,41 +12,39 @@ func Test_generateRandomKey(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
wantLength int
|
wantLenth int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Generate a random key of length 16",
|
name: "Generate a random key of length 16",
|
||||||
wantLength: 16,
|
wantLenth: 16,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Generate a random key of length 32",
|
name: "Generate a random key of length 32",
|
||||||
wantLength: 32,
|
wantLenth: 32,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Generate a random key of length 64",
|
name: "Generate a random key of length 64",
|
||||||
wantLength: 64,
|
wantLenth: 64,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Generate a random key of length 128",
|
name: "Generate a random key of length 128",
|
||||||
wantLength: 128,
|
wantLenth: 128,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := GenerateRandomKey(tt.wantLength)
|
got := securecookie.GenerateRandomKey(tt.wantLenth)
|
||||||
is.Len(got, tt.wantLength)
|
is.Equal(tt.wantLenth, len(got))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("Generated keys are unique", func(t *testing.T) {
|
t.Run("Generated keys are unique", func(t *testing.T) {
|
||||||
keys := make(map[string]bool)
|
keys := make(map[string]bool)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
for range 100 {
|
key := securecookie.GenerateRandomKey(8)
|
||||||
key := GenerateRandomKey(8)
|
|
||||||
_, ok := keys[string(key)]
|
_, ok := keys[string(key)]
|
||||||
is.False(ok)
|
is.False(ok)
|
||||||
|
|
||||||
keys[string(key)] = true
|
keys[string(key)] = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,79 +1,69 @@
|
|||||||
package apikey
|
package apikey
|
||||||
|
|
||||||
import (
|
import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
|
|
||||||
lru "github.com/hashicorp/golang-lru"
|
lru "github.com/hashicorp/golang-lru"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultAPIKeyCacheSize = 1024
|
const defaultAPIKeyCacheSize = 1024
|
||||||
|
|
||||||
// entry is a tuple containing the user and API key associated to an API key digest
|
// entry is a tuple containing the user and API key associated to an API key digest
|
||||||
type entry[T any] struct {
|
type entry struct {
|
||||||
user T
|
user portainer.User
|
||||||
apiKey portainer.APIKey
|
apiKey portainer.APIKey
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserCompareFn[T any] func(T, portainer.UserID) bool
|
// apiKeyCache is a concurrency-safe, in-memory cache which primarily exists for to reduce database roundtrips.
|
||||||
|
|
||||||
// ApiKeyCache is a concurrency-safe, in-memory cache which primarily exists for to reduce database roundtrips.
|
|
||||||
// We store the api-key digest (keys) and the associated user and key-data (values) in the cache.
|
// We store the api-key digest (keys) and the associated user and key-data (values) in the cache.
|
||||||
// This is required because HTTP requests will contain only the api-key digest in the x-api-key request header;
|
// This is required because HTTP requests will contain only the api-key digest in the x-api-key request header;
|
||||||
// digest value must be mapped to a portainer user (and respective key data) for validation.
|
// digest value must be mapped to a portainer user (and respective key data) for validation.
|
||||||
// This cache is used to avoid multiple database queries to retrieve these user/key associated to the digest.
|
// This cache is used to avoid multiple database queries to retrieve these user/key associated to the digest.
|
||||||
type ApiKeyCache[T any] struct {
|
type apiKeyCache struct {
|
||||||
// cache type [string]entry cache (key: string(digest), value: user/key entry)
|
// cache type [string]entry cache (key: string(digest), value: user/key entry)
|
||||||
// note: []byte keys are not supported by golang-lru Cache
|
// note: []byte keys are not supported by golang-lru Cache
|
||||||
cache *lru.Cache
|
cache *lru.Cache
|
||||||
userCmpFn UserCompareFn[T]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPIKeyCache creates a new cache for API keys
|
// NewAPIKeyCache creates a new cache for API keys
|
||||||
func NewAPIKeyCache[T any](cacheSize int, userCompareFn UserCompareFn[T]) *ApiKeyCache[T] {
|
func NewAPIKeyCache(cacheSize int) *apiKeyCache {
|
||||||
cache, _ := lru.New(cacheSize)
|
cache, _ := lru.New(cacheSize)
|
||||||
|
return &apiKeyCache{cache: cache}
|
||||||
return &ApiKeyCache[T]{cache: cache, userCmpFn: userCompareFn}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns the user/key associated to an api-key's digest
|
// Get returns the user/key associated to an api-key's digest
|
||||||
// This is required because HTTP requests will contain the digest of the API key in header,
|
// This is required because HTTP requests will contain the digest of the API key in header,
|
||||||
// the digest value must be mapped to a portainer user.
|
// the digest value must be mapped to a portainer user.
|
||||||
func (c *ApiKeyCache[T]) Get(digest string) (T, portainer.APIKey, bool) {
|
func (c *apiKeyCache) Get(digest string) (portainer.User, portainer.APIKey, bool) {
|
||||||
val, ok := c.cache.Get(digest)
|
val, ok := c.cache.Get(digest)
|
||||||
if !ok {
|
if !ok {
|
||||||
var t T
|
return portainer.User{}, portainer.APIKey{}, false
|
||||||
|
|
||||||
return t, portainer.APIKey{}, false
|
|
||||||
}
|
}
|
||||||
|
tuple := val.(entry)
|
||||||
tuple := val.(entry[T])
|
|
||||||
|
|
||||||
return tuple.user, tuple.apiKey, true
|
return tuple.user, tuple.apiKey, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set persists a user/key entry to the cache
|
// Set persists a user/key entry to the cache
|
||||||
func (c *ApiKeyCache[T]) Set(digest string, user T, apiKey portainer.APIKey) {
|
func (c *apiKeyCache) Set(digest string, user portainer.User, apiKey portainer.APIKey) {
|
||||||
c.cache.Add(digest, entry[T]{
|
c.cache.Add(digest, entry{
|
||||||
user: user,
|
user: user,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete evicts a digest's user/key entry key from the cache
|
// Delete evicts a digest's user/key entry key from the cache
|
||||||
func (c *ApiKeyCache[T]) Delete(digest string) {
|
func (c *apiKeyCache) Delete(digest string) {
|
||||||
c.cache.Remove(digest)
|
c.cache.Remove(digest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateUserKeyCache loops through all the api-keys associated to a user and removes them from the cache
|
// InvalidateUserKeyCache loops through all the api-keys associated to a user and removes them from the cache
|
||||||
func (c *ApiKeyCache[T]) InvalidateUserKeyCache(userId portainer.UserID) bool {
|
func (c *apiKeyCache) InvalidateUserKeyCache(userId portainer.UserID) bool {
|
||||||
present := false
|
present := false
|
||||||
|
|
||||||
for _, k := range c.cache.Keys() {
|
for _, k := range c.cache.Keys() {
|
||||||
user, _, _ := c.Get(k.(string))
|
user, _, _ := c.Get(k.(string))
|
||||||
if c.userCmpFn(user, userId) {
|
if user.ID == userId {
|
||||||
present = c.cache.Remove(k)
|
present = c.cache.Remove(k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return present
|
return present
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
func Test_apiKeyCacheGet(t *testing.T) {
|
func Test_apiKeyCacheGet(t *testing.T) {
|
||||||
is := assert.New(t)
|
is := assert.New(t)
|
||||||
|
|
||||||
keyCache := NewAPIKeyCache(10, compareUser)
|
keyCache := NewAPIKeyCache(10)
|
||||||
|
|
||||||
// pre-populate cache
|
// pre-populate cache
|
||||||
keyCache.cache.Add(string("foo"), entry[portainer.User]{user: portainer.User{}, apiKey: portainer.APIKey{}})
|
keyCache.cache.Add(string("foo"), entry{user: portainer.User{}, apiKey: portainer.APIKey{}})
|
||||||
keyCache.cache.Add(string(""), entry[portainer.User]{user: portainer.User{}, apiKey: portainer.APIKey{}})
|
keyCache.cache.Add(string(""), entry{user: portainer.User{}, apiKey: portainer.APIKey{}})
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
digest string
|
digest string
|
||||||
@@ -35,7 +35,7 @@ func Test_apiKeyCacheGet(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.digest, func(t *testing.T) {
|
t.Run(string(test.digest), func(t *testing.T) {
|
||||||
_, _, found := keyCache.Get(test.digest)
|
_, _, found := keyCache.Get(test.digest)
|
||||||
is.Equal(test.found, found)
|
is.Equal(test.found, found)
|
||||||
})
|
})
|
||||||
@@ -45,7 +45,7 @@ func Test_apiKeyCacheGet(t *testing.T) {
|
|||||||
func Test_apiKeyCacheSet(t *testing.T) {
|
func Test_apiKeyCacheSet(t *testing.T) {
|
||||||
is := assert.New(t)
|
is := assert.New(t)
|
||||||
|
|
||||||
keyCache := NewAPIKeyCache(10, compareUser)
|
keyCache := NewAPIKeyCache(10)
|
||||||
|
|
||||||
// pre-populate cache
|
// pre-populate cache
|
||||||
keyCache.Set("bar", portainer.User{ID: 2}, portainer.APIKey{})
|
keyCache.Set("bar", portainer.User{ID: 2}, portainer.APIKey{})
|
||||||
@@ -57,23 +57,23 @@ func Test_apiKeyCacheSet(t *testing.T) {
|
|||||||
val, ok := keyCache.cache.Get(string("bar"))
|
val, ok := keyCache.cache.Get(string("bar"))
|
||||||
is.True(ok)
|
is.True(ok)
|
||||||
|
|
||||||
tuple := val.(entry[portainer.User])
|
tuple := val.(entry)
|
||||||
is.Equal(portainer.User{ID: 2}, tuple.user)
|
is.Equal(portainer.User{ID: 2}, tuple.user)
|
||||||
|
|
||||||
val, ok = keyCache.cache.Get(string("foo"))
|
val, ok = keyCache.cache.Get(string("foo"))
|
||||||
is.True(ok)
|
is.True(ok)
|
||||||
|
|
||||||
tuple = val.(entry[portainer.User])
|
tuple = val.(entry)
|
||||||
is.Equal(portainer.User{ID: 3}, tuple.user)
|
is.Equal(portainer.User{ID: 3}, tuple.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_apiKeyCacheDelete(t *testing.T) {
|
func Test_apiKeyCacheDelete(t *testing.T) {
|
||||||
is := assert.New(t)
|
is := assert.New(t)
|
||||||
|
|
||||||
keyCache := NewAPIKeyCache(10, compareUser)
|
keyCache := NewAPIKeyCache(10)
|
||||||
|
|
||||||
t.Run("Delete an existing entry", func(t *testing.T) {
|
t.Run("Delete an existing entry", func(t *testing.T) {
|
||||||
keyCache.cache.Add(string("foo"), entry[portainer.User]{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}})
|
keyCache.cache.Add(string("foo"), entry{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}})
|
||||||
keyCache.Delete("foo")
|
keyCache.Delete("foo")
|
||||||
|
|
||||||
_, ok := keyCache.cache.Get(string("foo"))
|
_, ok := keyCache.cache.Get(string("foo"))
|
||||||
@@ -128,7 +128,7 @@ func Test_apiKeyCacheLRU(t *testing.T) {
|
|||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
keyCache := NewAPIKeyCache(test.cacheLen, compareUser)
|
keyCache := NewAPIKeyCache(test.cacheLen)
|
||||||
|
|
||||||
for _, key := range test.key {
|
for _, key := range test.key {
|
||||||
keyCache.Set(key, portainer.User{ID: 1}, portainer.APIKey{})
|
keyCache.Set(key, portainer.User{ID: 1}, portainer.APIKey{})
|
||||||
@@ -150,10 +150,10 @@ func Test_apiKeyCacheLRU(t *testing.T) {
|
|||||||
func Test_apiKeyCacheInvalidateUserKeyCache(t *testing.T) {
|
func Test_apiKeyCacheInvalidateUserKeyCache(t *testing.T) {
|
||||||
is := assert.New(t)
|
is := assert.New(t)
|
||||||
|
|
||||||
keyCache := NewAPIKeyCache(10, compareUser)
|
keyCache := NewAPIKeyCache(10)
|
||||||
|
|
||||||
t.Run("Removes users keys from cache", func(t *testing.T) {
|
t.Run("Removes users keys from cache", func(t *testing.T) {
|
||||||
keyCache.cache.Add(string("foo"), entry[portainer.User]{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}})
|
keyCache.cache.Add(string("foo"), entry{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}})
|
||||||
|
|
||||||
ok := keyCache.InvalidateUserKeyCache(1)
|
ok := keyCache.InvalidateUserKeyCache(1)
|
||||||
is.True(ok)
|
is.True(ok)
|
||||||
@@ -163,8 +163,8 @@ func Test_apiKeyCacheInvalidateUserKeyCache(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Does not affect other keys", func(t *testing.T) {
|
t.Run("Does not affect other keys", func(t *testing.T) {
|
||||||
keyCache.cache.Add(string("foo"), entry[portainer.User]{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}})
|
keyCache.cache.Add(string("foo"), entry{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}})
|
||||||
keyCache.cache.Add(string("bar"), entry[portainer.User]{user: portainer.User{ID: 2}, apiKey: portainer.APIKey{}})
|
keyCache.cache.Add(string("bar"), entry{user: portainer.User{ID: 2}, apiKey: portainer.APIKey{}})
|
||||||
|
|
||||||
ok := keyCache.InvalidateUserKeyCache(1)
|
ok := keyCache.InvalidateUserKeyCache(1)
|
||||||
is.True(ok)
|
is.True(ok)
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
package apikey
|
package apikey
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/internal/securecookie"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
@@ -21,45 +20,30 @@ var ErrInvalidAPIKey = errors.New("Invalid API key")
|
|||||||
type apiKeyService struct {
|
type apiKeyService struct {
|
||||||
apiKeyRepository dataservices.APIKeyRepository
|
apiKeyRepository dataservices.APIKeyRepository
|
||||||
userRepository dataservices.UserService
|
userRepository dataservices.UserService
|
||||||
cache *ApiKeyCache[portainer.User]
|
cache *apiKeyCache
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateRandomKey generates a random key of specified length
|
|
||||||
// source: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L515
|
|
||||||
func GenerateRandomKey(length int) []byte {
|
|
||||||
k := make([]byte, length)
|
|
||||||
if _, err := io.ReadFull(rand.Reader, k); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return k
|
|
||||||
}
|
|
||||||
|
|
||||||
func compareUser(u portainer.User, id portainer.UserID) bool {
|
|
||||||
return u.ID == id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAPIKeyService(apiKeyRepository dataservices.APIKeyRepository, userRepository dataservices.UserService) *apiKeyService {
|
func NewAPIKeyService(apiKeyRepository dataservices.APIKeyRepository, userRepository dataservices.UserService) *apiKeyService {
|
||||||
return &apiKeyService{
|
return &apiKeyService{
|
||||||
apiKeyRepository: apiKeyRepository,
|
apiKeyRepository: apiKeyRepository,
|
||||||
userRepository: userRepository,
|
userRepository: userRepository,
|
||||||
cache: NewAPIKeyCache(DefaultAPIKeyCacheSize, compareUser),
|
cache: NewAPIKeyCache(defaultAPIKeyCacheSize),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashRaw computes a hash digest of provided raw API key.
|
// HashRaw computes a hash digest of provided raw API key.
|
||||||
func (a *apiKeyService) HashRaw(rawKey string) string {
|
func (a *apiKeyService) HashRaw(rawKey string) string {
|
||||||
hashDigest := sha256.Sum256([]byte(rawKey))
|
hashDigest := sha256.Sum256([]byte(rawKey))
|
||||||
|
|
||||||
return base64.StdEncoding.EncodeToString(hashDigest[:])
|
return base64.StdEncoding.EncodeToString(hashDigest[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateApiKey generates a raw API key for a user (for one-time display).
|
// GenerateApiKey generates a raw API key for a user (for one-time display).
|
||||||
// The generated API key is stored in the cache and database.
|
// The generated API key is stored in the cache and database.
|
||||||
func (a *apiKeyService) GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error) {
|
func (a *apiKeyService) GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error) {
|
||||||
randKey := GenerateRandomKey(32)
|
randKey := securecookie.GenerateRandomKey(32)
|
||||||
encodedRawAPIKey := base64.StdEncoding.EncodeToString(randKey)
|
encodedRawAPIKey := base64.StdEncoding.EncodeToString(randKey)
|
||||||
prefixedAPIKey := portainerAPIKeyPrefix + encodedRawAPIKey
|
prefixedAPIKey := portainerAPIKeyPrefix + encodedRawAPIKey
|
||||||
|
|
||||||
hashDigest := a.HashRaw(prefixedAPIKey)
|
hashDigest := a.HashRaw(prefixedAPIKey)
|
||||||
|
|
||||||
apiKey := &portainer.APIKey{
|
apiKey := &portainer.APIKey{
|
||||||
@@ -70,7 +54,8 @@ func (a *apiKeyService) GenerateApiKey(user portainer.User, description string)
|
|||||||
Digest: hashDigest,
|
Digest: hashDigest,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := a.apiKeyRepository.Create(apiKey); err != nil {
|
err := a.apiKeyRepository.Create(apiKey)
|
||||||
|
if err != nil {
|
||||||
return "", nil, errors.Wrap(err, "Unable to create API key")
|
return "", nil, errors.Wrap(err, "Unable to create API key")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +78,7 @@ func (a *apiKeyService) GetAPIKeys(userID portainer.UserID) ([]portainer.APIKey,
|
|||||||
// GetDigestUserAndKey returns the user and api-key associated to a specified hash digest.
|
// GetDigestUserAndKey returns the user and api-key associated to a specified hash digest.
|
||||||
// A cache lookup is performed first; if the user/api-key is not found in the cache, respective database lookups are performed.
|
// A cache lookup is performed first; if the user/api-key is not found in the cache, respective database lookups are performed.
|
||||||
func (a *apiKeyService) GetDigestUserAndKey(digest string) (portainer.User, portainer.APIKey, error) {
|
func (a *apiKeyService) GetDigestUserAndKey(digest string) (portainer.User, portainer.APIKey, error) {
|
||||||
|
// get api key from cache if possible
|
||||||
cachedUser, cachedKey, ok := a.cache.Get(digest)
|
cachedUser, cachedKey, ok := a.cache.Get(digest)
|
||||||
if ok {
|
if ok {
|
||||||
return cachedUser, cachedKey, nil
|
return cachedUser, cachedKey, nil
|
||||||
@@ -120,21 +106,20 @@ func (a *apiKeyService) UpdateAPIKey(apiKey *portainer.APIKey) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "Unable to retrieve API key")
|
return errors.Wrap(err, "Unable to retrieve API key")
|
||||||
}
|
}
|
||||||
|
|
||||||
a.cache.Set(apiKey.Digest, user, *apiKey)
|
a.cache.Set(apiKey.Digest, user, *apiKey)
|
||||||
|
|
||||||
return a.apiKeyRepository.Update(apiKey.ID, apiKey)
|
return a.apiKeyRepository.Update(apiKey.ID, apiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAPIKey deletes an API key and removes the digest/api-key entry from the cache.
|
// DeleteAPIKey deletes an API key and removes the digest/api-key entry from the cache.
|
||||||
func (a *apiKeyService) DeleteAPIKey(apiKeyID portainer.APIKeyID) error {
|
func (a *apiKeyService) DeleteAPIKey(apiKeyID portainer.APIKeyID) error {
|
||||||
|
// get api-key digest to remove from cache
|
||||||
apiKey, err := a.apiKeyRepository.Read(apiKeyID)
|
apiKey, err := a.apiKeyRepository.Read(apiKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, fmt.Sprintf("Unable to retrieve API key: %d", apiKeyID))
|
return errors.Wrap(err, fmt.Sprintf("Unable to retrieve API key: %d", apiKeyID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delete the user/api-key from cache
|
||||||
a.cache.Delete(apiKey.Digest)
|
a.cache.Delete(apiKey.Digest)
|
||||||
|
|
||||||
return a.apiKeyRepository.Delete(apiKeyID)
|
return a.apiKeyRepository.Delete(apiKeyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ import (
|
|||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/datastore"
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_SatisfiesAPIKeyServiceInterface(t *testing.T) {
|
func Test_SatisfiesAPIKeyServiceInterface(t *testing.T) {
|
||||||
@@ -31,7 +30,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
|||||||
t.Run("Successfully generates API key", func(t *testing.T) {
|
t.Run("Successfully generates API key", func(t *testing.T) {
|
||||||
desc := "test-1"
|
desc := "test-1"
|
||||||
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, desc)
|
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, desc)
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
is.NotEmpty(rawKey)
|
is.NotEmpty(rawKey)
|
||||||
is.NotEmpty(apiKey)
|
is.NotEmpty(apiKey)
|
||||||
is.Equal(desc, apiKey.Description)
|
is.Equal(desc, apiKey.Description)
|
||||||
@@ -39,7 +38,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Api key prefix is 7 chars", func(t *testing.T) {
|
t.Run("Api key prefix is 7 chars", func(t *testing.T) {
|
||||||
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-2")
|
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-2")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
is.Equal(rawKey[:7], apiKey.Prefix)
|
is.Equal(rawKey[:7], apiKey.Prefix)
|
||||||
is.Len(apiKey.Prefix, 7)
|
is.Len(apiKey.Prefix, 7)
|
||||||
@@ -47,7 +46,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Api key has 'ptr_' as prefix", func(t *testing.T) {
|
t.Run("Api key has 'ptr_' as prefix", func(t *testing.T) {
|
||||||
rawKey, _, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x")
|
rawKey, _, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
is.Equal(portainerAPIKeyPrefix, "ptr_")
|
is.Equal(portainerAPIKeyPrefix, "ptr_")
|
||||||
is.True(strings.HasPrefix(rawKey, "ptr_"))
|
is.True(strings.HasPrefix(rawKey, "ptr_"))
|
||||||
@@ -56,7 +55,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
|||||||
t.Run("Successfully caches API key", func(t *testing.T) {
|
t.Run("Successfully caches API key", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, apiKey, err := service.GenerateApiKey(user, "test-3")
|
_, apiKey, err := service.GenerateApiKey(user, "test-3")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
userFromCache, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
userFromCache, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||||
is.True(ok)
|
is.True(ok)
|
||||||
@@ -66,7 +65,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Decoded raw api-key digest matches generated digest", func(t *testing.T) {
|
t.Run("Decoded raw api-key digest matches generated digest", func(t *testing.T) {
|
||||||
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-4")
|
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-4")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
generatedDigest := sha256.Sum256([]byte(rawKey))
|
generatedDigest := sha256.Sum256([]byte(rawKey))
|
||||||
|
|
||||||
@@ -84,10 +83,10 @@ func Test_GetAPIKey(t *testing.T) {
|
|||||||
t.Run("Successfully returns all API keys", func(t *testing.T) {
|
t.Run("Successfully returns all API keys", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
apiKeyGot, err := service.GetAPIKey(apiKey.ID)
|
apiKeyGot, err := service.GetAPIKey(apiKey.ID)
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
is.Equal(apiKey, apiKeyGot)
|
is.Equal(apiKey, apiKeyGot)
|
||||||
})
|
})
|
||||||
@@ -103,12 +102,12 @@ func Test_GetAPIKeys(t *testing.T) {
|
|||||||
t.Run("Successfully returns all API keys", func(t *testing.T) {
|
t.Run("Successfully returns all API keys", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, _, err := service.GenerateApiKey(user, "test-1")
|
_, _, err := service.GenerateApiKey(user, "test-1")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
_, _, err = service.GenerateApiKey(user, "test-2")
|
_, _, err = service.GenerateApiKey(user, "test-2")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
keys, err := service.GetAPIKeys(user.ID)
|
keys, err := service.GetAPIKeys(user.ID)
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
is.Len(keys, 2)
|
is.Len(keys, 2)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -123,10 +122,10 @@ func Test_GetDigestUserAndKey(t *testing.T) {
|
|||||||
t.Run("Successfully returns user and api key associated to digest", func(t *testing.T) {
|
t.Run("Successfully returns user and api key associated to digest", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
is.Equal(user, userGot)
|
is.Equal(user, userGot)
|
||||||
is.Equal(*apiKey, apiKeyGot)
|
is.Equal(*apiKey, apiKeyGot)
|
||||||
})
|
})
|
||||||
@@ -134,10 +133,10 @@ func Test_GetDigestUserAndKey(t *testing.T) {
|
|||||||
t.Run("Successfully caches user and api key associated to digest", func(t *testing.T) {
|
t.Run("Successfully caches user and api key associated to digest", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
is.Equal(user, userGot)
|
is.Equal(user, userGot)
|
||||||
is.Equal(*apiKey, apiKeyGot)
|
is.Equal(*apiKey, apiKeyGot)
|
||||||
|
|
||||||
@@ -157,19 +156,16 @@ func Test_UpdateAPIKey(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Successfully updates the api-key LastUsed time", func(t *testing.T) {
|
t.Run("Successfully updates the api-key LastUsed time", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
|
store.User().Create(&user)
|
||||||
err := store.User().Create(&user)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, apiKey, err := service.GenerateApiKey(user, "test-x")
|
_, apiKey, err := service.GenerateApiKey(user, "test-x")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
apiKey.LastUsed = time.Now().UTC().Unix()
|
apiKey.LastUsed = time.Now().UTC().Unix()
|
||||||
err = service.UpdateAPIKey(apiKey)
|
err = service.UpdateAPIKey(apiKey)
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
log.Debug().Str("wanted", fmt.Sprintf("%+v", apiKey)).Str("got", fmt.Sprintf("%+v", apiKeyGot)).Msg("")
|
log.Debug().Str("wanted", fmt.Sprintf("%+v", apiKey)).Str("got", fmt.Sprintf("%+v", apiKeyGot)).Msg("")
|
||||||
|
|
||||||
@@ -178,7 +174,7 @@ func Test_UpdateAPIKey(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Successfully updates api-key in cache upon api-key update", func(t *testing.T) {
|
t.Run("Successfully updates api-key in cache upon api-key update", func(t *testing.T) {
|
||||||
_, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x2")
|
_, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x2")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||||
is.True(ok)
|
is.True(ok)
|
||||||
@@ -188,7 +184,7 @@ func Test_UpdateAPIKey(t *testing.T) {
|
|||||||
is.NotEqual(*apiKey, apiKeyFromCache)
|
is.NotEqual(*apiKey, apiKeyFromCache)
|
||||||
|
|
||||||
err = service.UpdateAPIKey(apiKey)
|
err = service.UpdateAPIKey(apiKey)
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
_, updatedAPIKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
_, updatedAPIKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||||
is.True(ok)
|
is.True(ok)
|
||||||
@@ -206,30 +202,30 @@ func Test_DeleteAPIKey(t *testing.T) {
|
|||||||
t.Run("Successfully updates the api-key", func(t *testing.T) {
|
t.Run("Successfully updates the api-key", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
is.Equal(*apiKey, apiKeyGot)
|
is.Equal(*apiKey, apiKeyGot)
|
||||||
|
|
||||||
err = service.DeleteAPIKey(apiKey.ID)
|
err = service.DeleteAPIKey(apiKey.ID)
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
_, _, err = service.GetDigestUserAndKey(apiKey.Digest)
|
_, _, err = service.GetDigestUserAndKey(apiKey.Digest)
|
||||||
require.Error(t, err)
|
is.Error(err)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Successfully removes api-key from cache upon deletion", func(t *testing.T) {
|
t.Run("Successfully removes api-key from cache upon deletion", func(t *testing.T) {
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||||
is.True(ok)
|
is.True(ok)
|
||||||
is.Equal(*apiKey, apiKeyFromCache)
|
is.Equal(*apiKey, apiKeyFromCache)
|
||||||
|
|
||||||
err = service.DeleteAPIKey(apiKey.ID)
|
err = service.DeleteAPIKey(apiKey.ID)
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
_, _, ok = service.cache.Get(apiKey.Digest)
|
_, _, ok = service.cache.Get(apiKey.Digest)
|
||||||
is.False(ok)
|
is.False(ok)
|
||||||
@@ -247,10 +243,10 @@ func Test_InvalidateUserKeyCache(t *testing.T) {
|
|||||||
// generate api keys
|
// generate api keys
|
||||||
user := portainer.User{ID: 1}
|
user := portainer.User{ID: 1}
|
||||||
_, apiKey1, err := service.GenerateApiKey(user, "test-1")
|
_, apiKey1, err := service.GenerateApiKey(user, "test-1")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
_, apiKey2, err := service.GenerateApiKey(user, "test-2")
|
_, apiKey2, err := service.GenerateApiKey(user, "test-2")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
// verify api keys are present in cache
|
// verify api keys are present in cache
|
||||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)
|
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)
|
||||||
@@ -277,11 +273,11 @@ func Test_InvalidateUserKeyCache(t *testing.T) {
|
|||||||
// generate keys for 2 users
|
// generate keys for 2 users
|
||||||
user1 := portainer.User{ID: 1}
|
user1 := portainer.User{ID: 1}
|
||||||
_, apiKey1, err := service.GenerateApiKey(user1, "test-1")
|
_, apiKey1, err := service.GenerateApiKey(user1, "test-1")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
user2 := portainer.User{ID: 2}
|
user2 := portainer.User{ID: 2}
|
||||||
_, apiKey2, err := service.GenerateApiKey(user2, "test-2")
|
_, apiKey2, err := service.GenerateApiKey(user2, "test-2")
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
// verify keys in cache
|
// verify keys in cache
|
||||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)
|
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)
|
||||||
|
|||||||
@@ -17,15 +17,18 @@ func TarFileInBuffer(fileContent []byte, fileName string, mode int64) ([]byte, e
|
|||||||
Size: int64(len(fileContent)),
|
Size: int64(len(fileContent)),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tarWriter.WriteHeader(header); err != nil {
|
err := tarWriter.WriteHeader(header)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tarWriter.Write(fileContent); err != nil {
|
_, err = tarWriter.Write(fileContent)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tarWriter.Close(); err != nil {
|
err = tarWriter.Close()
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +43,10 @@ type tarFileInBuffer struct {
|
|||||||
|
|
||||||
func NewTarFileInBuffer() *tarFileInBuffer {
|
func NewTarFileInBuffer() *tarFileInBuffer {
|
||||||
var b bytes.Buffer
|
var b bytes.Buffer
|
||||||
return &tarFileInBuffer{b: &b, w: tar.NewWriter(&b)}
|
return &tarFileInBuffer{
|
||||||
|
b: &b,
|
||||||
|
w: tar.NewWriter(&b),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put puts a single file to tar archive buffer.
|
// Put puts a single file to tar archive buffer.
|
||||||
@@ -55,9 +61,11 @@ func (t *tarFileInBuffer) Put(fileContent []byte, fileName string, mode int64) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := t.w.Write(fileContent)
|
if _, err := t.w.Write(fileContent); err != nil {
|
||||||
|
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bytes returns the archive as a byte array.
|
// Bytes returns the archive as a byte array.
|
||||||
|
|||||||
@@ -9,27 +9,23 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
|
||||||
"github.com/portainer/portainer/api/logs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TarGzDir creates a tar.gz archive and returns it's path.
|
// TarGzDir creates a tar.gz archive and returns it's path.
|
||||||
// abosolutePath should be an absolute path to a directory.
|
// abosolutePath should be an absolute path to a directory.
|
||||||
// Archive name will be <directoryName>.tar.gz and will be placed next to the directory.
|
// Archive name will be <directoryName>.tar.gz and will be placed next to the directory.
|
||||||
func TarGzDir(absolutePath string) (string, error) {
|
func TarGzDir(absolutePath string) (string, error) {
|
||||||
targzPath := filepath.Join(absolutePath, filepath.Base(absolutePath)+".tar.gz")
|
targzPath := filepath.Join(absolutePath, fmt.Sprintf("%s.tar.gz", filepath.Base(absolutePath)))
|
||||||
outFile, err := os.Create(targzPath)
|
outFile, err := os.Create(targzPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer logs.CloseAndLogErr(outFile)
|
defer outFile.Close()
|
||||||
|
|
||||||
zipWriter := gzip.NewWriter(outFile)
|
zipWriter := gzip.NewWriter(outFile)
|
||||||
defer logs.CloseAndLogErr(zipWriter)
|
defer zipWriter.Close()
|
||||||
|
|
||||||
tarWriter := tar.NewWriter(zipWriter)
|
tarWriter := tar.NewWriter(zipWriter)
|
||||||
defer logs.CloseAndLogErr(tarWriter)
|
defer tarWriter.Close()
|
||||||
|
|
||||||
err = filepath.Walk(absolutePath, func(path string, info os.FileInfo, err error) error {
|
err = filepath.Walk(absolutePath, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -90,7 +86,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer logs.CloseAndLogErr(zipReader)
|
defer zipReader.Close()
|
||||||
|
|
||||||
tarReader := tar.NewReader(zipReader)
|
tarReader := tar.NewReader(zipReader)
|
||||||
|
|
||||||
@@ -109,7 +105,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
|||||||
case tar.TypeDir:
|
case tar.TypeDir:
|
||||||
// skip, dir will be created with a file
|
// skip, dir will be created with a file
|
||||||
case tar.TypeReg:
|
case tar.TypeReg:
|
||||||
p := filesystem.JoinPaths(outputDirPath, header.Name)
|
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
|
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
|
||||||
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
|
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
|
||||||
}
|
}
|
||||||
@@ -120,7 +116,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
|||||||
if _, err := io.Copy(outFile, tarReader); err != nil {
|
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||||||
return fmt.Errorf("Failed to extract file %s", header.Name)
|
return fmt.Errorf("Failed to extract file %s", header.Name)
|
||||||
}
|
}
|
||||||
logs.CloseAndLogErr(outFile)
|
outFile.Close()
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("tar: unknown type: %v in %s",
|
return fmt.Errorf("tar: unknown type: %v in %s",
|
||||||
header.Typeflag,
|
header.Typeflag,
|
||||||
|
|||||||
@@ -1,101 +1,45 @@
|
|||||||
package archive
|
package archive
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"fmt"
|
||||||
"compress/gzip"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func listFiles(dir string) []string {
|
func listFiles(dir string) []string {
|
||||||
items := make([]string, 0)
|
items := make([]string, 0)
|
||||||
|
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
|
||||||
if path == dir {
|
if path == dir {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
items = append(items, path)
|
items = append(items, path)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}); err != nil {
|
})
|
||||||
log.Warn().Err(err).Msg("failed to list files in directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_shouldCreateArchive(t *testing.T) {
|
func Test_shouldCreateArhive(t *testing.T) {
|
||||||
tmpdir := t.TempDir()
|
tmpdir := t.TempDir()
|
||||||
content := []byte("content")
|
content := []byte("content")
|
||||||
|
os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
||||||
err := os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
||||||
require.NoError(t, err)
|
os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||||
|
os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
||||||
err = os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
gzPath, err := TarGzDir(tmpdir)
|
gzPath, err := TarGzDir(tmpdir)
|
||||||
require.NoError(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
assert.Equal(t, filepath.Join(tmpdir, fmt.Sprintf("%s.tar.gz", filepath.Base(tmpdir))), gzPath)
|
||||||
|
|
||||||
extractionDir := t.TempDir()
|
extractionDir := t.TempDir()
|
||||||
cmd := exec.Command("tar", "-xzf", gzPath, "-C", extractionDir)
|
cmd := exec.Command("tar", "-xzf", gzPath, "-C", extractionDir)
|
||||||
if err := cmd.Run(); err != nil {
|
err = cmd.Run()
|
||||||
t.Fatal("Failed to extract archive: ", err)
|
if err != nil {
|
||||||
}
|
|
||||||
extractedFiles := listFiles(extractionDir)
|
|
||||||
|
|
||||||
wasExtracted := func(p string) {
|
|
||||||
fullpath := path.Join(extractionDir, p)
|
|
||||||
assert.Contains(t, extractedFiles, fullpath)
|
|
||||||
copyContent, err := os.ReadFile(fullpath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, content, copyContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
wasExtracted("outer")
|
|
||||||
wasExtracted("dir/inner")
|
|
||||||
wasExtracted("dir/.dotfile")
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_shouldCreateArchive2(t *testing.T) {
|
|
||||||
tmpdir := t.TempDir()
|
|
||||||
content := []byte("content")
|
|
||||||
|
|
||||||
err := os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
gzPath, err := TarGzDir(tmpdir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
|
||||||
|
|
||||||
extractionDir := t.TempDir()
|
|
||||||
r, _ := os.Open(gzPath)
|
|
||||||
if err := ExtractTarGz(r, extractionDir); err != nil {
|
|
||||||
t.Fatal("Failed to extract archive: ", err)
|
t.Fatal("Failed to extract archive: ", err)
|
||||||
}
|
}
|
||||||
extractedFiles := listFiles(extractionDir)
|
extractedFiles := listFiles(extractionDir)
|
||||||
@@ -112,55 +56,34 @@ func Test_shouldCreateArchive2(t *testing.T) {
|
|||||||
wasExtracted("dir/.dotfile")
|
wasExtracted("dir/.dotfile")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractTarGzPathTraversal(t *testing.T) {
|
func Test_shouldCreateArhiveXXXXX(t *testing.T) {
|
||||||
testDir := t.TempDir()
|
tmpdir := t.TempDir()
|
||||||
|
content := []byte("content")
|
||||||
|
os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
||||||
|
os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
||||||
|
os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||||
|
os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
||||||
|
|
||||||
// Create an evil file with a path traversal attempt
|
gzPath, err := TarGzDir(tmpdir)
|
||||||
tarPath := filesystem.JoinPaths(testDir, "evil.tar.gz")
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, filepath.Join(tmpdir, fmt.Sprintf("%s.tar.gz", filepath.Base(tmpdir))), gzPath)
|
||||||
|
|
||||||
evilFile, err := os.Create(tarPath)
|
extractionDir := t.TempDir()
|
||||||
require.NoError(t, err)
|
r, _ := os.Open(gzPath)
|
||||||
|
ExtractTarGz(r, extractionDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("Failed to extract archive: ", err)
|
||||||
|
}
|
||||||
|
extractedFiles := listFiles(extractionDir)
|
||||||
|
|
||||||
gzWriter := gzip.NewWriter(evilFile)
|
wasExtracted := func(p string) {
|
||||||
tarWriter := tar.NewWriter(gzWriter)
|
fullpath := path.Join(extractionDir, p)
|
||||||
|
assert.Contains(t, extractedFiles, fullpath)
|
||||||
content := []byte("evil content")
|
copyContent, _ := os.ReadFile(fullpath)
|
||||||
|
assert.Equal(t, content, copyContent)
|
||||||
header := &tar.Header{
|
|
||||||
Name: "../evil.txt",
|
|
||||||
Mode: 0600,
|
|
||||||
Size: int64(len(content)),
|
|
||||||
Typeflag: tar.TypeReg,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tarWriter.WriteHeader(header)
|
wasExtracted("outer")
|
||||||
require.NoError(t, err)
|
wasExtracted("dir/inner")
|
||||||
|
wasExtracted("dir/.dotfile")
|
||||||
_, err = tarWriter.Write(content)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = tarWriter.Close()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = gzWriter.Close()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = evilFile.Close()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Attempt to extract the evil file
|
|
||||||
extractionDir := filesystem.JoinPaths(testDir, "extraction")
|
|
||||||
err = os.Mkdir(extractionDir, 0700)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
tarFile, err := os.Open(tarPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Check that the file didn't escape
|
|
||||||
err = ExtractTarGz(tarFile, extractionDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoFileExists(t, filesystem.JoinPaths(testDir, "evil.txt"))
|
|
||||||
|
|
||||||
err = tarFile.Close()
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,60 @@ package archive
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/logs"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// UnzipArchive will unzip an archive from bytes into the dest destination folder on disk
|
||||||
|
func UnzipArchive(archiveData []byte, dest string) error {
|
||||||
|
zipReader, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, zipFile := range zipReader.File {
|
||||||
|
err := extractFileFromArchive(zipFile, dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFileFromArchive(file *zip.File, dest string) error {
|
||||||
|
f, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fpath := filepath.Join(dest, file.Name)
|
||||||
|
|
||||||
|
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(outFile, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return outFile.Close()
|
||||||
|
}
|
||||||
|
|
||||||
// UnzipFile will decompress a zip archive, moving all files and folders
|
// UnzipFile will decompress a zip archive, moving all files and folders
|
||||||
// within the zip file (parameter 1) to an output directory (parameter 2).
|
// within the zip file (parameter 1) to an output directory (parameter 2).
|
||||||
func UnzipFile(src string, dest string) error {
|
func UnzipFile(src string, dest string) error {
|
||||||
@@ -20,7 +63,7 @@ func UnzipFile(src string, dest string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer logs.CloseAndLogErr(r)
|
defer r.Close()
|
||||||
|
|
||||||
for _, f := range r.File {
|
for _, f := range r.File {
|
||||||
p := filepath.Join(dest, f.Name)
|
p := filepath.Join(dest, f.Name)
|
||||||
@@ -32,14 +75,12 @@ func UnzipFile(src string, dest string) error {
|
|||||||
|
|
||||||
if f.FileInfo().IsDir() {
|
if f.FileInfo().IsDir() {
|
||||||
// Make Folder
|
// Make Folder
|
||||||
if err := os.MkdirAll(p, os.ModePerm); err != nil {
|
os.MkdirAll(p, os.ModePerm)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := unzipFile(f, p); err != nil {
|
err = unzipFile(f, p)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,20 +93,20 @@ func unzipFile(f *zip.File, p string) error {
|
|||||||
if err := os.MkdirAll(filepath.Dir(p), os.ModePerm); err != nil {
|
if err := os.MkdirAll(filepath.Dir(p), os.ModePerm); err != nil {
|
||||||
return errors.Wrapf(err, "unzipFile: can't make a path %s", p)
|
return errors.Wrapf(err, "unzipFile: can't make a path %s", p)
|
||||||
}
|
}
|
||||||
|
|
||||||
outFile, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
outFile, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrapf(err, "unzipFile: can't create file %s", p)
|
return errors.Wrapf(err, "unzipFile: can't create file %s", p)
|
||||||
}
|
}
|
||||||
defer logs.CloseAndLogErr(outFile)
|
defer outFile.Close()
|
||||||
|
|
||||||
rc, err := f.Open()
|
rc, err := f.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrapf(err, "unzipFile: can't open zip file %s in the archive", f.Name)
|
return errors.Wrapf(err, "unzipFile: can't open zip file %s in the archive", f.Name)
|
||||||
}
|
}
|
||||||
defer logs.CloseAndLogErr(rc)
|
defer rc.Close()
|
||||||
|
|
||||||
if _, err = io.Copy(outFile, rc); err != nil {
|
_, err = io.Copy(outFile, rc)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
return errors.Wrapf(err, "unzipFile: can't copy an archived file content")
|
return errors.Wrapf(err, "unzipFile: can't copy an archived file content")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUnzipFile(t *testing.T) {
|
func TestUnzipFile(t *testing.T) {
|
||||||
@@ -21,7 +20,7 @@ func TestUnzipFile(t *testing.T) {
|
|||||||
|
|
||||||
err := UnzipFile("./testdata/sample_archive.zip", dir)
|
err := UnzipFile("./testdata/sample_archive.zip", dir)
|
||||||
|
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
archiveDir := dir + "/sample_archive"
|
archiveDir := dir + "/sample_archive"
|
||||||
assert.FileExists(t, filepath.Join(archiveDir, "0.txt"))
|
assert.FileExists(t, filepath.Join(archiveDir, "0.txt"))
|
||||||
assert.FileExists(t, filepath.Join(archiveDir, "0", "1.txt"))
|
assert.FileExists(t, filepath.Join(archiveDir, "0", "1.txt"))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package ecr
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -15,7 +15,7 @@ func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Ti
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(getAuthorizationTokenOutput.AuthorizationData) == 0 {
|
if len(getAuthorizationTokenOutput.AuthorizationData) == 0 {
|
||||||
err = errors.New("AuthorizationData is empty")
|
err = fmt.Errorf("AuthorizationData is empty")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ func (s *Service) ParseAuthorizationToken(token string) (username string, passwo
|
|||||||
|
|
||||||
splitToken := strings.Split(token, ":")
|
splitToken := strings.Split(token, ":")
|
||||||
if len(splitToken) < 2 {
|
if len(splitToken) < 2 {
|
||||||
err = errors.New("invalid ECR authorization token")
|
err = fmt.Errorf("invalid ECR authorization token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,6 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go-v2/service/ecr"
|
"github.com/aws/aws-sdk-go-v2/service/ecr"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Registry represents an ECR registry endpoint information.
|
|
||||||
// This struct is used to parse and validate ECR endpoint URLs.
|
|
||||||
type Registry struct {
|
|
||||||
ID string // AWS account ID (empty for accountless endpoints like "ecr-fips.us-west-1.amazonaws.com")
|
|
||||||
FIPS bool // Whether this is a FIPS endpoint (contains "-fips" in the URL)
|
|
||||||
Region string // AWS region (e.g., "us-east-1", "us-gov-west-1")
|
|
||||||
Public bool // Whether this is ecr-public.aws.com
|
|
||||||
}
|
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Service struct {
|
Service struct {
|
||||||
accessKey string
|
accessKey string
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
package ecr
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ecrEndpointPattern matches all valid ECR endpoints including account-prefixed and accountless formats.
|
|
||||||
// Based on AWS ECR credential helper regex but extended to support accountless endpoints.
|
|
||||||
//
|
|
||||||
// Supported formats:
|
|
||||||
// - Account-prefixed: 123456789012.dkr.ecr-fips.us-east-1.amazonaws.com
|
|
||||||
// - Account-prefixed (hyphen): 123456789012.dkr-ecr-fips.us-west-1.on.aws
|
|
||||||
// - Accountless service: ecr-fips.us-west-1.amazonaws.com
|
|
||||||
// - Accountless API: ecr-fips.us-east-1.api.aws
|
|
||||||
// - Non-FIPS variants: All formats above without "-fips"
|
|
||||||
//
|
|
||||||
// Regex groups:
|
|
||||||
// - Group 1: Full account prefix (optional) - e.g., "123456789012.dkr." or "123456789012.dkr-"
|
|
||||||
// - Group 2: Account ID (optional) - e.g., "123456789012"
|
|
||||||
// - Group 3: FIPS flag (optional) - either "-fips" or empty string
|
|
||||||
// - Group 4: Region - e.g., "us-east-1", "us-gov-west-1"
|
|
||||||
// - Group 5: Domain suffix - e.g., "amazonaws.com", "api.aws"
|
|
||||||
var ecrEndpointPattern = regexp.MustCompile(
|
|
||||||
`^((\d{12})\.dkr[\.\-])?ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.(amazonaws\.(?:com(?:\.cn)?|eu)|api\.aws|on\.(?:aws|amazonwebservices\.com\.cn)|sc2s\.sgov\.gov|c2s\.ic\.gov|cloud\.adc-e\.uk|csp\.hci\.ic\.gov)$`,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ParseECREndpoint parses an ECR registry URL and extracts registry information.
|
|
||||||
|
|
||||||
// This function replaces the AWS ECR credential helper library's ExtractRegistry function,
|
|
||||||
// which only supports account-prefixed endpoints.
|
|
||||||
//
|
|
||||||
// Reference: https://docs.aws.amazon.com/general/latest/gr/ecr.html
|
|
||||||
func ParseECREndpoint(urlStr string) (*Registry, error) {
|
|
||||||
// Normalize URL by adding https:// prefix if not present
|
|
||||||
if !strings.HasPrefix(urlStr, "https://") && !strings.HasPrefix(urlStr, "http://") {
|
|
||||||
urlStr = "https://" + urlStr
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := url.Parse(urlStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hostname := u.Hostname()
|
|
||||||
|
|
||||||
// Special case: ECR Public
|
|
||||||
// ECR Public uses a different domain and doesn't have FIPS variant
|
|
||||||
if hostname == "ecr-public.aws.com" {
|
|
||||||
return &Registry{
|
|
||||||
FIPS: false,
|
|
||||||
Public: true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse standard ECR endpoints using regex
|
|
||||||
matches := ecrEndpointPattern.FindStringSubmatch(hostname)
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return nil, fmt.Errorf("not a valid ECR endpoint: %s", hostname)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Registry{
|
|
||||||
ID: matches[2], // Account ID (may be empty for accountless endpoints)
|
|
||||||
FIPS: matches[3] == "-fips", // Check if "-fips" is present
|
|
||||||
Region: matches[4], // AWS region
|
|
||||||
Public: false,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
package ecr
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseECREndpoint(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
url string
|
|
||||||
want *Registry
|
|
||||||
wantError bool
|
|
||||||
}{
|
|
||||||
// Standard AWS Commercial - Account-prefixed FIPS
|
|
||||||
{
|
|
||||||
name: "account-prefixed FIPS us-east-1",
|
|
||||||
url: "123456789012.dkr.ecr-fips.us-east-1.amazonaws.com",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "123456789012",
|
|
||||||
FIPS: true,
|
|
||||||
Region: "us-east-1",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "account-prefixed FIPS us-west-2",
|
|
||||||
url: "123456789012.dkr.ecr-fips.us-west-2.amazonaws.com",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "123456789012",
|
|
||||||
FIPS: true,
|
|
||||||
Region: "us-west-2",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Accountless FIPS service endpoints
|
|
||||||
{
|
|
||||||
name: "accountless FIPS us-west-1",
|
|
||||||
url: "ecr-fips.us-west-1.amazonaws.com",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "",
|
|
||||||
FIPS: true,
|
|
||||||
Region: "us-west-1",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "accountless FIPS us-east-2",
|
|
||||||
url: "ecr-fips.us-east-2.amazonaws.com",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "",
|
|
||||||
FIPS: true,
|
|
||||||
Region: "us-east-2",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Accountless FIPS API endpoints
|
|
||||||
{
|
|
||||||
name: "accountless FIPS API us-west-1",
|
|
||||||
url: "ecr-fips.us-west-1.api.aws",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "",
|
|
||||||
FIPS: true,
|
|
||||||
Region: "us-west-1",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "accountless FIPS API us-east-1",
|
|
||||||
url: "ecr-fips.us-east-1.api.aws",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "",
|
|
||||||
FIPS: true,
|
|
||||||
Region: "us-east-1",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// on.aws domain with hyphen separator
|
|
||||||
{
|
|
||||||
name: "account-prefixed FIPS hyphen us-west-1",
|
|
||||||
url: "123456789012.dkr-ecr-fips.us-west-1.on.aws",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "123456789012",
|
|
||||||
FIPS: true,
|
|
||||||
Region: "us-west-1",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "account-prefixed FIPS hyphen us-east-2",
|
|
||||||
url: "123456789012.dkr-ecr-fips.us-east-2.on.aws",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "123456789012",
|
|
||||||
FIPS: true,
|
|
||||||
Region: "us-east-2",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// AWS GovCloud
|
|
||||||
{
|
|
||||||
name: "account-prefixed FIPS us-gov-east-1",
|
|
||||||
url: "123456789012.dkr.ecr-fips.us-gov-east-1.amazonaws.com",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "123456789012",
|
|
||||||
FIPS: true,
|
|
||||||
Region: "us-gov-east-1",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "account-prefixed FIPS us-gov-west-1",
|
|
||||||
url: "123456789012.dkr.ecr-fips.us-gov-west-1.amazonaws.com",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "123456789012",
|
|
||||||
FIPS: true,
|
|
||||||
Region: "us-gov-west-1",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "accountless FIPS us-gov-west-1",
|
|
||||||
url: "ecr-fips.us-gov-west-1.amazonaws.com",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "",
|
|
||||||
FIPS: true,
|
|
||||||
Region: "us-gov-west-1",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "accountless FIPS API us-gov-east-1",
|
|
||||||
url: "ecr-fips.us-gov-east-1.api.aws",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "",
|
|
||||||
FIPS: true,
|
|
||||||
Region: "us-gov-east-1",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ECR Public
|
|
||||||
{
|
|
||||||
name: "ecr-public",
|
|
||||||
url: "ecr-public.aws.com",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "",
|
|
||||||
FIPS: false,
|
|
||||||
Region: "",
|
|
||||||
Public: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Non-FIPS endpoints (valid ECR but FIPS=false)
|
|
||||||
{
|
|
||||||
name: "account-prefixed non-FIPS us-east-1",
|
|
||||||
url: "123456789012.dkr.ecr.us-east-1.amazonaws.com",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "123456789012",
|
|
||||||
FIPS: false,
|
|
||||||
Region: "us-east-1",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "accountless non-FIPS us-west-1",
|
|
||||||
url: "ecr.us-west-1.amazonaws.com",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "",
|
|
||||||
FIPS: false,
|
|
||||||
Region: "us-west-1",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "accountless non-FIPS API us-east-2",
|
|
||||||
url: "ecr.us-east-2.api.aws",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "",
|
|
||||||
FIPS: false,
|
|
||||||
Region: "us-east-2",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// URLs with https:// prefix
|
|
||||||
{
|
|
||||||
name: "with https prefix",
|
|
||||||
url: "https://ecr-fips.us-west-1.amazonaws.com",
|
|
||||||
want: &Registry{
|
|
||||||
ID: "",
|
|
||||||
FIPS: true,
|
|
||||||
Region: "us-west-1",
|
|
||||||
Public: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Invalid endpoints
|
|
||||||
{
|
|
||||||
name: "not an ECR URL",
|
|
||||||
url: "not-an-ecr-url.com",
|
|
||||||
wantError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid account ID length",
|
|
||||||
url: "123.dkr.ecr-fips.us-east-1.amazonaws.com",
|
|
||||||
wantError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty string",
|
|
||||||
url: "",
|
|
||||||
wantError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "docker hub",
|
|
||||||
url: "docker.io",
|
|
||||||
wantError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, err := ParseECREndpoint(tt.url)
|
|
||||||
|
|
||||||
if tt.wantError {
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("ParseECREndpoint() expected error but got none")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("ParseECREndpoint() unexpected error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.ID != tt.want.ID {
|
|
||||||
t.Errorf("ParseECREndpoint() ID = %v, want %v", got.ID, tt.want.ID)
|
|
||||||
}
|
|
||||||
if got.FIPS != tt.want.FIPS {
|
|
||||||
t.Errorf("ParseECREndpoint() FIPS = %v, want %v", got.FIPS, tt.want.FIPS)
|
|
||||||
}
|
|
||||||
if got.Region != tt.want.Region {
|
|
||||||
t.Errorf("ParseECREndpoint() Region = %v, want %v", got.Region, tt.want.Region)
|
|
||||||
}
|
|
||||||
if got.Public != tt.want.Public {
|
|
||||||
t.Errorf("ParseECREndpoint() Public = %v, want %v", got.Public, tt.want.Public)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
"github.com/portainer/portainer/api/http/offlinegate"
|
"github.com/portainer/portainer/api/http/offlinegate"
|
||||||
"github.com/portainer/portainer/api/logs"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
@@ -22,7 +21,6 @@ const rwxr__r__ os.FileMode = 0o744
|
|||||||
|
|
||||||
var filesToBackup = []string{
|
var filesToBackup = []string{
|
||||||
"certs",
|
"certs",
|
||||||
"chisel",
|
|
||||||
"compose",
|
"compose",
|
||||||
"config.json",
|
"config.json",
|
||||||
"custom_templates",
|
"custom_templates",
|
||||||
@@ -32,13 +30,40 @@ var filesToBackup = []string{
|
|||||||
"portainer.key",
|
"portainer.key",
|
||||||
"portainer.pub",
|
"portainer.pub",
|
||||||
"tls",
|
"tls",
|
||||||
|
"chisel",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file.
|
// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file.
|
||||||
func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) {
|
func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) {
|
||||||
backupDirPath, err := backupDatabaseAndFilesystem(gate, datastore, filestorePath)
|
unlock := gate.Lock()
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05"))
|
||||||
|
if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil {
|
||||||
|
return "", errors.Wrap(err, "Failed to create backup dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// new export
|
||||||
|
exportFilename := path.Join(backupDirPath, fmt.Sprintf("export-%d.json", time.Now().Unix()))
|
||||||
|
|
||||||
|
err := datastore.Export(exportFilename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
log.Error().Err(err).Str("filename", exportFilename).Msg("failed to export")
|
||||||
|
} else {
|
||||||
|
log.Debug().Str("filename", exportFilename).Msg("file exported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := backupDb(backupDirPath, datastore); err != nil {
|
||||||
|
return "", errors.Wrap(err, "Failed to backup database")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, filename := range filesToBackup {
|
||||||
|
err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "Failed to create backup file")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
archivePath, err := archive.TarGzDir(backupDirPath)
|
archivePath, err := archive.TarGzDir(backupDirPath)
|
||||||
@@ -56,40 +81,8 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
|
|||||||
return archivePath, nil
|
return archivePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func backupDatabaseAndFilesystem(gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) {
|
|
||||||
unlock := gate.Lock()
|
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05"))
|
|
||||||
if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil {
|
|
||||||
return "", errors.Wrap(err, "Failed to create backup dir")
|
|
||||||
}
|
|
||||||
|
|
||||||
// new export
|
|
||||||
exportFilename := path.Join(backupDirPath, fmt.Sprintf("export-%d.json", time.Now().Unix()))
|
|
||||||
|
|
||||||
if err := datastore.Export(exportFilename); err != nil {
|
|
||||||
log.Error().Err(err).Str("filename", exportFilename).Msg("failed to export")
|
|
||||||
} else {
|
|
||||||
log.Debug().Str("filename", exportFilename).Msg("file exported")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := backupDb(backupDirPath, datastore); err != nil {
|
|
||||||
return "", errors.Wrap(err, "Failed to backup database")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, filename := range filesToBackup {
|
|
||||||
if err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath); err != nil {
|
|
||||||
return "", errors.Wrap(err, "Failed to create backup file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return backupDirPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
|
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
|
||||||
dbFileName := datastore.Connection().GetDatabaseFileName()
|
_, err := datastore.Backup(filepath.Join(backupDirPath, "portainer.db"))
|
||||||
_, err := datastore.Backup(filepath.Join(backupDirPath, dbFileName))
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,13 +91,15 @@ func encrypt(path string, passphrase string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer logs.CloseAndLogErr(in)
|
defer in.Close()
|
||||||
|
|
||||||
outFileName := path + ".encrypted"
|
outFileName := fmt.Sprintf("%s.encrypted", path)
|
||||||
out, err := os.Create(outFileName)
|
out, err := os.Create(outFileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return outFileName, crypto.AesEncrypt(in, out, []byte(passphrase))
|
err = crypto.AesEncrypt(in, out, []byte(passphrase))
|
||||||
|
|
||||||
|
return outFileName, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import (
|
|||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
"github.com/portainer/portainer/api/http/offlinegate"
|
"github.com/portainer/portainer/api/http/offlinegate"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var filesToRestore = append(filesToBackup, "portainer.db")
|
var filesToRestore = append(filesToBackup, "portainer.db")
|
||||||
@@ -28,25 +26,22 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
|
|||||||
if password != "" {
|
if password != "" {
|
||||||
archive, err = decrypt(archive, password)
|
archive, err = decrypt(archive, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "failed to decrypt the archive. Please ensure the password is correct and try again")
|
return errors.Wrap(err, "failed to decrypt the archive")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
restorePath := filepath.Join(filestorePath, "restore", time.Now().Format("20060102150405"))
|
restorePath := filepath.Join(filestorePath, "restore", time.Now().Format("20060102150405"))
|
||||||
defer func() {
|
defer os.RemoveAll(filepath.Dir(restorePath))
|
||||||
if err := os.RemoveAll(filepath.Dir(restorePath)); err != nil {
|
|
||||||
log.Warn().Err(err).Msg("failed to clean up restore files")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := extractArchive(archive, restorePath); err != nil {
|
err = extractArchive(archive, restorePath)
|
||||||
|
if err != nil {
|
||||||
return errors.Wrap(err, "cannot extract files from the archive. Please ensure the password is correct and try again")
|
return errors.Wrap(err, "cannot extract files from the archive. Please ensure the password is correct and try again")
|
||||||
}
|
}
|
||||||
|
|
||||||
unlock := gate.Lock()
|
unlock := gate.Lock()
|
||||||
defer unlock()
|
defer unlock()
|
||||||
|
|
||||||
if err := datastore.Close(); err != nil {
|
if err = datastore.Close(); err != nil {
|
||||||
return errors.Wrap(err, "Failed to stop db")
|
return errors.Wrap(err, "Failed to stop db")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +51,7 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
|
|||||||
return errors.Wrap(err, "failed to restore from backup. Portainer database missing from backup file")
|
return errors.Wrap(err, "failed to restore from backup. Portainer database missing from backup file")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := restoreFiles(restorePath, filestorePath); err != nil {
|
if err = restoreFiles(restorePath, filestorePath); err != nil {
|
||||||
return errors.Wrap(err, "failed to restore the system state")
|
return errors.Wrap(err, "failed to restore the system state")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +89,8 @@ func getRestoreSourcePath(dir string) (string, error) {
|
|||||||
|
|
||||||
func restoreFiles(srcDir string, destinationDir string) error {
|
func restoreFiles(srcDir string, destinationDir string) error {
|
||||||
for _, filename := range filesToRestore {
|
for _, filename := range filesToRestore {
|
||||||
if err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir); err != nil {
|
err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,18 +98,14 @@ func restoreFiles(srcDir string, destinationDir string) error {
|
|||||||
// TODO: This is very boltdb module specific once again due to the filename. Move to bolt module? Refactor for another day
|
// TODO: This is very boltdb module specific once again due to the filename. Move to bolt module? Refactor for another day
|
||||||
|
|
||||||
// Prevent the possibility of having both databases. Remove any default new instance
|
// Prevent the possibility of having both databases. Remove any default new instance
|
||||||
if err := os.Remove(filepath.Join(destinationDir, boltdb.DatabaseFileName)); err != nil && !os.IsNotExist(err) {
|
os.Remove(filepath.Join(destinationDir, boltdb.DatabaseFileName))
|
||||||
return err
|
os.Remove(filepath.Join(destinationDir, boltdb.EncryptedDatabaseFileName))
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Remove(filepath.Join(destinationDir, boltdb.EncryptedDatabaseFileName)); err != nil && !os.IsNotExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now copy the database. It'll be either portainer.db or portainer.edb
|
// Now copy the database. It'll be either portainer.db or portainer.edb
|
||||||
|
|
||||||
// Note: CopyPath does not return an error if the source file doesn't exist
|
// Note: CopyPath does not return an error if the source file doesn't exist
|
||||||
if err := filesystem.CopyPath(filepath.Join(srcDir, boltdb.EncryptedDatabaseFileName), destinationDir); err != nil {
|
err := filesystem.CopyPath(filepath.Join(srcDir, boltdb.EncryptedDatabaseFileName), destinationDir)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
api/build/variables.go
Normal file
12
api/build/variables.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package build
|
||||||
|
|
||||||
|
import "runtime"
|
||||||
|
|
||||||
|
// Variables to be set during the build time
|
||||||
|
var BuildNumber string
|
||||||
|
var ImageTag string
|
||||||
|
var NodejsVersion string
|
||||||
|
var YarnVersion string
|
||||||
|
var WebpackVersion string
|
||||||
|
var GoVersion string = runtime.Version()
|
||||||
|
var GitCommit string
|
||||||
@@ -54,8 +54,8 @@ func ecdsaGenerateKey(c elliptic.Curve, rand io.Reader) (*ecdsa.PrivateKey, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
priv := new(ecdsa.PrivateKey)
|
priv := new(ecdsa.PrivateKey)
|
||||||
priv.Curve = c
|
priv.PublicKey.Curve = c
|
||||||
priv.D = k
|
priv.D = k
|
||||||
priv.X, priv.Y = c.ScalarBaseMult(k.Bytes())
|
priv.PublicKey.X, priv.PublicKey.Y = c.ScalarBaseMult(k.Bytes())
|
||||||
return priv, nil
|
return priv, nil
|
||||||
}
|
}
|
||||||
|
|||||||
75
api/chisel/schedules.go
Normal file
75
api/chisel/schedules.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package chisel
|
||||||
|
|
||||||
|
import (
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddEdgeJob register an EdgeJob inside the tunnel details associated to an environment(endpoint).
|
||||||
|
func (service *Service) AddEdgeJob(endpoint *portainer.Endpoint, edgeJob *portainer.EdgeJob) {
|
||||||
|
if endpoint.Edge.AsyncMode {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
service.mu.Lock()
|
||||||
|
tunnel := service.getTunnelDetails(endpoint.ID)
|
||||||
|
|
||||||
|
existingJobIndex := -1
|
||||||
|
for idx, existingJob := range tunnel.Jobs {
|
||||||
|
if existingJob.ID == edgeJob.ID {
|
||||||
|
existingJobIndex = idx
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingJobIndex == -1 {
|
||||||
|
tunnel.Jobs = append(tunnel.Jobs, *edgeJob)
|
||||||
|
} else {
|
||||||
|
tunnel.Jobs[existingJobIndex] = *edgeJob
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.Del(endpoint.ID)
|
||||||
|
|
||||||
|
service.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveEdgeJob will remove the specified Edge job from each tunnel it was registered with.
|
||||||
|
func (service *Service) RemoveEdgeJob(edgeJobID portainer.EdgeJobID) {
|
||||||
|
service.mu.Lock()
|
||||||
|
|
||||||
|
for endpointID, tunnel := range service.tunnelDetailsMap {
|
||||||
|
n := 0
|
||||||
|
for _, edgeJob := range tunnel.Jobs {
|
||||||
|
if edgeJob.ID != edgeJobID {
|
||||||
|
tunnel.Jobs[n] = edgeJob
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnel.Jobs = tunnel.Jobs[:n]
|
||||||
|
|
||||||
|
cache.Del(endpointID)
|
||||||
|
}
|
||||||
|
|
||||||
|
service.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) RemoveEdgeJobFromEndpoint(endpointID portainer.EndpointID, edgeJobID portainer.EdgeJobID) {
|
||||||
|
service.mu.Lock()
|
||||||
|
tunnel := service.getTunnelDetails(endpointID)
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
for _, edgeJob := range tunnel.Jobs {
|
||||||
|
if edgeJob.ID != edgeJobID {
|
||||||
|
tunnel.Jobs[n] = edgeJob
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnel.Jobs = tunnel.Jobs[:n]
|
||||||
|
|
||||||
|
cache.Del(endpointID)
|
||||||
|
|
||||||
|
service.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
tunnelCleanupInterval = 10 * time.Second
|
tunnelCleanupInterval = 10 * time.Second
|
||||||
|
requiredTimeout = 15 * time.Second
|
||||||
activeTimeout = 4*time.Minute + 30*time.Second
|
activeTimeout = 4*time.Minute + 30*time.Second
|
||||||
pingTimeout = 3 * time.Second
|
pingTimeout = 3 * time.Second
|
||||||
)
|
)
|
||||||
@@ -29,52 +30,30 @@ const (
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
serverFingerprint string
|
serverFingerprint string
|
||||||
serverPort string
|
serverPort string
|
||||||
activeTunnels map[portainer.EndpointID]*portainer.TunnelDetails
|
tunnelDetailsMap map[portainer.EndpointID]*portainer.TunnelDetails
|
||||||
edgeJobs map[portainer.EndpointID][]portainer.EdgeJob
|
|
||||||
dataStore dataservices.DataStore
|
dataStore dataservices.DataStore
|
||||||
snapshotService portainer.SnapshotService
|
snapshotService portainer.SnapshotService
|
||||||
chiselServer *chserver.Server
|
chiselServer *chserver.Server
|
||||||
shutdownCtx context.Context
|
shutdownCtx context.Context
|
||||||
ProxyManager *proxy.Manager
|
ProxyManager *proxy.Manager
|
||||||
mu sync.RWMutex
|
mu sync.Mutex
|
||||||
fileService portainer.FileService
|
fileService portainer.FileService
|
||||||
defaultCheckinInterval int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService returns a pointer to a new instance of Service
|
// NewService returns a pointer to a new instance of Service
|
||||||
func NewService(dataStore dataservices.DataStore, shutdownCtx context.Context, fileService portainer.FileService) *Service {
|
func NewService(dataStore dataservices.DataStore, shutdownCtx context.Context, fileService portainer.FileService) *Service {
|
||||||
defaultCheckinInterval := portainer.DefaultEdgeAgentCheckinIntervalInSeconds
|
|
||||||
|
|
||||||
settings, err := dataStore.Settings().Settings()
|
|
||||||
if err == nil {
|
|
||||||
defaultCheckinInterval = settings.EdgeAgentCheckinInterval
|
|
||||||
} else {
|
|
||||||
log.Error().Err(err).Msg("unable to retrieve the settings from the database")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
activeTunnels: make(map[portainer.EndpointID]*portainer.TunnelDetails),
|
tunnelDetailsMap: make(map[portainer.EndpointID]*portainer.TunnelDetails),
|
||||||
edgeJobs: make(map[portainer.EndpointID][]portainer.EdgeJob),
|
|
||||||
dataStore: dataStore,
|
dataStore: dataStore,
|
||||||
shutdownCtx: shutdownCtx,
|
shutdownCtx: shutdownCtx,
|
||||||
fileService: fileService,
|
fileService: fileService,
|
||||||
defaultCheckinInterval: defaultCheckinInterval,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pingAgent ping the given agent so that the agent can keep the tunnel alive
|
// pingAgent ping the given agent so that the agent can keep the tunnel alive
|
||||||
func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
|
func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
|
||||||
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
|
tunnel := service.GetTunnelDetails(endpointID)
|
||||||
if err != nil {
|
requestURL := fmt.Sprintf("http://127.0.0.1:%d/ping", tunnel.Port)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
tunnelAddr, err := service.TunnelAddr(endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
requestURL := fmt.Sprintf("http://%s/ping", tunnelAddr)
|
|
||||||
req, err := http.NewRequest(http.MethodHead, requestURL, nil)
|
req, err := http.NewRequest(http.MethodHead, requestURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -89,16 +68,15 @@ func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
io.Copy(io.Discard, resp.Body)
|
||||||
return resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done
|
// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done
|
||||||
func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxAlive time.Duration) {
|
func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxAlive time.Duration) {
|
||||||
go service.keepTunnelAlive(endpointID, ctx, maxAlive)
|
go func() {
|
||||||
}
|
|
||||||
|
|
||||||
func (service *Service) keepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxAlive time.Duration) {
|
|
||||||
log.Debug().
|
log.Debug().
|
||||||
Int("endpoint_id", int(endpointID)).
|
Int("endpoint_id", int(endpointID)).
|
||||||
Float64("max_alive_minutes", maxAlive.Minutes()).
|
Float64("max_alive_minutes", maxAlive.Minutes()).
|
||||||
@@ -113,9 +91,9 @@ func (service *Service) keepTunnelAlive(endpointID portainer.EndpointID, ctx con
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-pingTicker.C:
|
case <-pingTicker.C:
|
||||||
service.UpdateLastActivity(endpointID)
|
service.SetTunnelStatusToActive(endpointID)
|
||||||
|
err := service.pingAgent(endpointID)
|
||||||
if err := service.pingAgent(endpointID); err != nil {
|
if err != nil {
|
||||||
log.Debug().
|
log.Debug().
|
||||||
Int("endpoint_id", int(endpointID)).
|
Int("endpoint_id", int(endpointID)).
|
||||||
Err(err).
|
Err(err).
|
||||||
@@ -138,6 +116,7 @@ func (service *Service) keepTunnelAlive(endpointID portainer.EndpointID, ctx con
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartTunnelServer starts a tunnel server on the specified addr and port.
|
// StartTunnelServer starts a tunnel server on the specified addr and port.
|
||||||
@@ -147,6 +126,7 @@ func (service *Service) keepTunnelAlive(endpointID portainer.EndpointID, ctx con
|
|||||||
// The snapshotter is used in the tunnel status verification process.
|
// The snapshotter is used in the tunnel status verification process.
|
||||||
func (service *Service) StartTunnelServer(addr, port string, snapshotService portainer.SnapshotService) error {
|
func (service *Service) StartTunnelServer(addr, port string, snapshotService portainer.SnapshotService) error {
|
||||||
privateKeyFile, err := service.retrievePrivateKeyFile()
|
privateKeyFile, err := service.retrievePrivateKeyFile()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -164,21 +144,21 @@ func (service *Service) StartTunnelServer(addr, port string, snapshotService por
|
|||||||
service.serverFingerprint = chiselServer.GetFingerprint()
|
service.serverFingerprint = chiselServer.GetFingerprint()
|
||||||
service.serverPort = port
|
service.serverPort = port
|
||||||
|
|
||||||
if err := chiselServer.Start(addr, port); err != nil {
|
err = chiselServer.Start(addr, port)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
service.chiselServer = chiselServer
|
service.chiselServer = chiselServer
|
||||||
|
|
||||||
// TODO: work-around Chisel default behavior.
|
// TODO: work-around Chisel default behavior.
|
||||||
// By default, Chisel will allow anyone to connect if no user exists.
|
// By default, Chisel will allow anyone to connect if no user exists.
|
||||||
username, password := generateRandomCredentials()
|
username, password := generateRandomCredentials()
|
||||||
if err = service.chiselServer.AddUser(username, password, "127.0.0.1"); err != nil {
|
err = service.chiselServer.AddUser(username, password, "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
service.snapshotService = snapshotService
|
service.snapshotService = snapshotService
|
||||||
|
|
||||||
go service.startTunnelVerificationLoop()
|
go service.startTunnelVerificationLoop()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -192,38 +172,36 @@ func (service *Service) StopTunnelServer() error {
|
|||||||
func (service *Service) retrievePrivateKeyFile() (string, error) {
|
func (service *Service) retrievePrivateKeyFile() (string, error) {
|
||||||
privateKeyFile := service.fileService.GetDefaultChiselPrivateKeyPath()
|
privateKeyFile := service.fileService.GetDefaultChiselPrivateKeyPath()
|
||||||
|
|
||||||
if exists, _ := service.fileService.FileExists(privateKeyFile); exists {
|
exist, _ := service.fileService.FileExists(privateKeyFile)
|
||||||
log.Info().
|
if !exist {
|
||||||
Str("private-key", privateKeyFile).
|
|
||||||
Msg("found Chisel private key file on disk")
|
|
||||||
|
|
||||||
return privateKeyFile, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug().
|
log.Debug().
|
||||||
Str("private-key", privateKeyFile).
|
Str("private-key", privateKeyFile).
|
||||||
Msg("chisel private key file does not exist")
|
Msg("Chisel private key file does not exist")
|
||||||
|
|
||||||
privateKey, err := ccrypto.GenerateKey("")
|
privateKey, err := ccrypto.GenerateKey("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Err(err).
|
Err(err).
|
||||||
Msg("failed to generate chisel private key")
|
Msg("Failed to generate chisel private key")
|
||||||
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = service.fileService.StoreChiselPrivateKey(privateKey); err != nil {
|
err = service.fileService.StoreChiselPrivateKey(privateKey)
|
||||||
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Err(err).
|
Err(err).
|
||||||
Msg("failed to save Chisel private key to disk")
|
Msg("Failed to save Chisel private key to disk")
|
||||||
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
} else {
|
||||||
|
|
||||||
log.Info().
|
log.Info().
|
||||||
Str("private-key", privateKeyFile).
|
Str("private-key", privateKeyFile).
|
||||||
Msg("generated a new Chisel private key file")
|
Msg("Generated a new Chisel private key file")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Info().
|
||||||
|
Str("private-key", privateKeyFile).
|
||||||
|
Msg("Found Chisel private key file on disk")
|
||||||
|
}
|
||||||
|
|
||||||
return privateKeyFile, nil
|
return privateKeyFile, nil
|
||||||
}
|
}
|
||||||
@@ -252,45 +230,63 @@ func (service *Service) startTunnelVerificationLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkTunnels finds the first tunnel that has not had any activity recently
|
|
||||||
// and attempts to take a snapshot, then closes it and returns
|
|
||||||
func (service *Service) checkTunnels() {
|
func (service *Service) checkTunnels() {
|
||||||
service.mu.RLock()
|
tunnels := make(map[portainer.EndpointID]portainer.TunnelDetails)
|
||||||
|
|
||||||
for endpointID, tunnel := range service.activeTunnels {
|
service.mu.Lock()
|
||||||
elapsed := time.Since(tunnel.LastActivity)
|
for key, tunnel := range service.tunnelDetailsMap {
|
||||||
log.Debug().
|
if tunnel.LastActivity.IsZero() || tunnel.Status == portainer.EdgeAgentIdle {
|
||||||
Int("endpoint_id", int(endpointID)).
|
|
||||||
Float64("last_activity_seconds", elapsed.Seconds()).
|
|
||||||
Msg("environment tunnel monitoring")
|
|
||||||
|
|
||||||
if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed < activeTimeout {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
tunnelPort := tunnel.Port
|
if tunnel.Status == portainer.EdgeAgentManagementRequired && time.Since(tunnel.LastActivity) < requiredTimeout {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
service.mu.RUnlock()
|
if tunnel.Status == portainer.EdgeAgentActive && time.Since(tunnel.LastActivity) < activeTimeout {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnels[key] = *tunnel
|
||||||
|
}
|
||||||
|
service.mu.Unlock()
|
||||||
|
|
||||||
|
for endpointID, tunnel := range tunnels {
|
||||||
|
elapsed := time.Since(tunnel.LastActivity)
|
||||||
log.Debug().
|
log.Debug().
|
||||||
Int("endpoint_id", int(endpointID)).
|
Int("endpoint_id", int(endpointID)).
|
||||||
Float64("last_activity_seconds", elapsed.Seconds()).
|
Str("status", tunnel.Status).
|
||||||
Float64("timeout_seconds", activeTimeout.Seconds()).
|
Float64("status_time_seconds", elapsed.Seconds()).
|
||||||
Msg("last activity timeout exceeded")
|
Msg("environment tunnel monitoring")
|
||||||
|
|
||||||
if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil {
|
if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed > requiredTimeout {
|
||||||
|
log.Debug().
|
||||||
|
Int("endpoint_id", int(endpointID)).
|
||||||
|
Str("status", tunnel.Status).
|
||||||
|
Float64("status_time_seconds", elapsed.Seconds()).
|
||||||
|
Float64("timeout_seconds", requiredTimeout.Seconds()).
|
||||||
|
Msg("REQUIRED state timeout exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tunnel.Status == portainer.EdgeAgentActive && elapsed > activeTimeout {
|
||||||
|
log.Debug().
|
||||||
|
Int("endpoint_id", int(endpointID)).
|
||||||
|
Str("status", tunnel.Status).
|
||||||
|
Float64("status_time_seconds", elapsed.Seconds()).
|
||||||
|
Float64("timeout_seconds", activeTimeout.Seconds()).
|
||||||
|
Msg("ACTIVE state timeout exceeded")
|
||||||
|
|
||||||
|
err := service.snapshotEnvironment(endpointID, tunnel.Port)
|
||||||
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Int("endpoint_id", int(endpointID)).
|
Int("endpoint_id", int(endpointID)).
|
||||||
Err(err).
|
Err(err).
|
||||||
Msg("unable to snapshot Edge environment")
|
Msg("unable to snapshot Edge environment")
|
||||||
}
|
}
|
||||||
|
|
||||||
service.close(endpointID)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
service.mu.RUnlock()
|
service.SetTunnelStatusToIdle(portainer.EndpointID(endpointID))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tunnelPort int) error {
|
func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tunnelPort int) error {
|
||||||
|
|||||||
@@ -1,34 +1,20 @@
|
|||||||
package chisel
|
package chisel
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/datastore"
|
|
||||||
"github.com/portainer/portainer/pkg/fips"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
|
||||||
fips.InitFIPS(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPingAgentPanic(t *testing.T) {
|
func TestPingAgentPanic(t *testing.T) {
|
||||||
endpoint := &portainer.Endpoint{
|
endpointID := portainer.EndpointID(1)
|
||||||
ID: 1,
|
|
||||||
EdgeID: "test-edge-id",
|
|
||||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
|
||||||
UserTrusted: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, store := datastore.MustNewTestStore(t, true, true)
|
s := NewService(nil, nil, nil)
|
||||||
|
|
||||||
s := NewService(store, nil, nil)
|
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
require.Nil(t, recover())
|
require.Nil(t, recover())
|
||||||
@@ -42,18 +28,12 @@ func TestPingAgentPanic(t *testing.T) {
|
|||||||
ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
srv := &http.Server{Handler: mux}
|
|
||||||
|
|
||||||
errCh := make(chan error)
|
|
||||||
go func() {
|
go func() {
|
||||||
errCh <- srv.Serve(ln)
|
require.NoError(t, http.Serve(ln, mux))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
err = s.Open(endpoint)
|
s.getTunnelDetails(endpointID)
|
||||||
require.NoError(t, err)
|
s.tunnelDetailsMap[endpointID].Port = ln.Addr().(*net.TCPAddr).Port
|
||||||
s.activeTunnels[endpoint.ID].Port = ln.Addr().(*net.TCPAddr).Port
|
|
||||||
|
|
||||||
require.Error(t, s.pingAgent(endpoint.ID))
|
require.Error(t, s.pingAgent(endpointID))
|
||||||
require.NoError(t, srv.Shutdown(context.Background()))
|
|
||||||
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,15 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/internal/edge"
|
|
||||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
|
||||||
"github.com/portainer/portainer/pkg/libcrypto"
|
"github.com/portainer/portainer/pkg/libcrypto"
|
||||||
"github.com/portainer/portainer/pkg/librand"
|
|
||||||
|
|
||||||
"github.com/dchest/uniuri"
|
"github.com/dchest/uniuri"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -24,47 +20,154 @@ const (
|
|||||||
maxAvailablePort = 65535
|
maxAvailablePort = 65535
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// NOTE: it needs to be called with the lock acquired
|
||||||
ErrNonEdgeEnv = errors.New("cannot open a tunnel for non-edge environments")
|
// getUnusedPort is used to generate an unused random port in the dynamic port range.
|
||||||
ErrAsyncEnv = errors.New("cannot open a tunnel for async edge environments")
|
// Dynamic ports (also called private ports) are 49152 to 65535.
|
||||||
ErrInvalidEnv = errors.New("cannot open a tunnel for an invalid environment")
|
func (service *Service) getUnusedPort() int {
|
||||||
)
|
port := randomInt(minAvailablePort, maxAvailablePort)
|
||||||
|
|
||||||
// Open will mark the tunnel as REQUIRED so the agent opens it
|
for _, tunnel := range service.tunnelDetailsMap {
|
||||||
func (s *Service) Open(endpoint *portainer.Endpoint) error {
|
if tunnel.Port == port {
|
||||||
if !endpointutils.IsEdgeEndpoint(endpoint) {
|
return service.getUnusedPort()
|
||||||
return ErrNonEdgeEnv
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomInt(min, max int) int {
|
||||||
|
return min + rand.Intn(max-min)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: it needs to be called with the lock acquired
|
||||||
|
func (service *Service) getTunnelDetails(endpointID portainer.EndpointID) *portainer.TunnelDetails {
|
||||||
|
|
||||||
|
if tunnel, ok := service.tunnelDetailsMap[endpointID]; ok {
|
||||||
|
return tunnel
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnel := &portainer.TunnelDetails{
|
||||||
|
Status: portainer.EdgeAgentIdle,
|
||||||
|
}
|
||||||
|
|
||||||
|
service.tunnelDetailsMap[endpointID] = tunnel
|
||||||
|
|
||||||
|
cache.Del(endpointID)
|
||||||
|
|
||||||
|
return tunnel
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTunnelDetails returns information about the tunnel associated to an environment(endpoint).
|
||||||
|
func (service *Service) GetTunnelDetails(endpointID portainer.EndpointID) portainer.TunnelDetails {
|
||||||
|
service.mu.Lock()
|
||||||
|
defer service.mu.Unlock()
|
||||||
|
|
||||||
|
return *service.getTunnelDetails(endpointID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveTunnel retrieves an active tunnel which allows communicating with edge agent
|
||||||
|
func (service *Service) GetActiveTunnel(endpoint *portainer.Endpoint) (portainer.TunnelDetails, error) {
|
||||||
if endpoint.Edge.AsyncMode {
|
if endpoint.Edge.AsyncMode {
|
||||||
return ErrAsyncEnv
|
return portainer.TunnelDetails{}, errors.New("cannot open tunnel on async endpoint")
|
||||||
}
|
}
|
||||||
|
|
||||||
if endpoint.ID == 0 || endpoint.EdgeID == "" || !endpoint.UserTrusted {
|
tunnel := service.GetTunnelDetails(endpoint.ID)
|
||||||
return ErrInvalidEnv
|
|
||||||
|
if tunnel.Status == portainer.EdgeAgentActive {
|
||||||
|
// update the LastActivity
|
||||||
|
service.SetTunnelStatusToActive(endpoint.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
if tunnel.Status == portainer.EdgeAgentIdle || tunnel.Status == portainer.EdgeAgentManagementRequired {
|
||||||
defer s.mu.Unlock()
|
err := service.SetTunnelStatusToRequired(endpoint.ID)
|
||||||
|
if err != nil {
|
||||||
if _, ok := s.activeTunnels[endpoint.ID]; ok {
|
return portainer.TunnelDetails{}, fmt.Errorf("failed opening tunnel to endpoint: %w", err)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defer cache.Del(endpoint.ID)
|
if endpoint.EdgeCheckinInterval == 0 {
|
||||||
|
settings, err := service.dataStore.Settings().Settings()
|
||||||
tun := &portainer.TunnelDetails{
|
if err != nil {
|
||||||
Status: portainer.EdgeAgentManagementRequired,
|
return portainer.TunnelDetails{}, fmt.Errorf("failed fetching settings from db: %w", err)
|
||||||
Port: s.getUnusedPort(),
|
|
||||||
LastActivity: time.Now(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endpoint.EdgeCheckinInterval = settings.EdgeAgentCheckinInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(2 * time.Duration(endpoint.EdgeCheckinInterval) * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
return service.GetTunnelDetails(endpoint.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTunnelStatusToActive update the status of the tunnel associated to the specified environment(endpoint).
|
||||||
|
// It sets the status to ACTIVE.
|
||||||
|
func (service *Service) SetTunnelStatusToActive(endpointID portainer.EndpointID) {
|
||||||
|
service.mu.Lock()
|
||||||
|
tunnel := service.getTunnelDetails(endpointID)
|
||||||
|
tunnel.Status = portainer.EdgeAgentActive
|
||||||
|
tunnel.Credentials = ""
|
||||||
|
tunnel.LastActivity = time.Now()
|
||||||
|
service.mu.Unlock()
|
||||||
|
|
||||||
|
cache.Del(endpointID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTunnelStatusToIdle update the status of the tunnel associated to the specified environment(endpoint).
|
||||||
|
// It sets the status to IDLE.
|
||||||
|
// It removes any existing credentials associated to the tunnel.
|
||||||
|
func (service *Service) SetTunnelStatusToIdle(endpointID portainer.EndpointID) {
|
||||||
|
service.mu.Lock()
|
||||||
|
|
||||||
|
tunnel := service.getTunnelDetails(endpointID)
|
||||||
|
tunnel.Status = portainer.EdgeAgentIdle
|
||||||
|
tunnel.Port = 0
|
||||||
|
tunnel.LastActivity = time.Now()
|
||||||
|
|
||||||
|
credentials := tunnel.Credentials
|
||||||
|
if credentials != "" {
|
||||||
|
tunnel.Credentials = ""
|
||||||
|
|
||||||
|
if service.chiselServer != nil {
|
||||||
|
service.chiselServer.DeleteUser(strings.Split(credentials, ":")[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service.ProxyManager.DeleteEndpointProxy(endpointID)
|
||||||
|
|
||||||
|
service.mu.Unlock()
|
||||||
|
|
||||||
|
cache.Del(endpointID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTunnelStatusToRequired update the status of the tunnel associated to the specified environment(endpoint).
|
||||||
|
// It sets the status to REQUIRED.
|
||||||
|
// If no port is currently associated to the tunnel, it will associate a random unused port to the tunnel
|
||||||
|
// and generate temporary credentials that can be used to establish a reverse tunnel on that port.
|
||||||
|
// Credentials are encrypted using the Edge ID associated to the environment(endpoint).
|
||||||
|
func (service *Service) SetTunnelStatusToRequired(endpointID portainer.EndpointID) error {
|
||||||
|
defer cache.Del(endpointID)
|
||||||
|
|
||||||
|
tunnel := service.getTunnelDetails(endpointID)
|
||||||
|
|
||||||
|
service.mu.Lock()
|
||||||
|
defer service.mu.Unlock()
|
||||||
|
|
||||||
|
if tunnel.Port == 0 {
|
||||||
|
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnel.Status = portainer.EdgeAgentManagementRequired
|
||||||
|
tunnel.Port = service.getUnusedPort()
|
||||||
|
tunnel.LastActivity = time.Now()
|
||||||
|
|
||||||
username, password := generateRandomCredentials()
|
username, password := generateRandomCredentials()
|
||||||
|
authorizedRemote := fmt.Sprintf("^R:0.0.0.0:%d$", tunnel.Port)
|
||||||
|
|
||||||
if s.chiselServer != nil {
|
if service.chiselServer != nil {
|
||||||
authorizedRemote := fmt.Sprintf("^R:0.0.0.0:%d$", tun.Port)
|
err = service.chiselServer.AddUser(username, password, authorizedRemote)
|
||||||
|
if err != nil {
|
||||||
if err := s.chiselServer.AddUser(username, password, authorizedRemote); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,157 +176,15 @@ func (s *Service) Open(endpoint *portainer.Endpoint) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
tunnel.Credentials = credentials
|
||||||
tun.Credentials = credentials
|
}
|
||||||
|
|
||||||
s.activeTunnels[endpoint.ID] = tun
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// close removes the tunnel from the map so the agent will close it
|
|
||||||
func (s *Service) close(endpointID portainer.EndpointID) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
tun, ok := s.activeTunnels[endpointID]
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tun.Credentials) > 0 && s.chiselServer != nil {
|
|
||||||
user, _, _ := strings.Cut(tun.Credentials, ":")
|
|
||||||
s.chiselServer.DeleteUser(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.ProxyManager != nil {
|
|
||||||
s.ProxyManager.DeleteEndpointProxy(endpointID)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(s.activeTunnels, endpointID)
|
|
||||||
|
|
||||||
cache.Del(endpointID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config returns the tunnel details needed for the agent to connect
|
|
||||||
func (s *Service) Config(endpointID portainer.EndpointID) portainer.TunnelDetails {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
if tun, ok := s.activeTunnels[endpointID]; ok {
|
|
||||||
return *tun
|
|
||||||
}
|
|
||||||
|
|
||||||
return portainer.TunnelDetails{Status: portainer.EdgeAgentIdle}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TunnelAddr returns the address of the local tunnel, including the port, it
|
|
||||||
// will block until the tunnel is ready
|
|
||||||
func (s *Service) TunnelAddr(endpoint *portainer.Endpoint) (string, error) {
|
|
||||||
if err := s.Open(endpoint); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
tun := s.Config(endpoint.ID)
|
|
||||||
checkinInterval := time.Duration(s.tryEffectiveCheckinInterval(endpoint)) * time.Second
|
|
||||||
|
|
||||||
for t0 := time.Now(); ; {
|
|
||||||
if time.Since(t0) > 2*checkinInterval {
|
|
||||||
s.close(endpoint.ID)
|
|
||||||
|
|
||||||
return "", errors.New("unable to open the tunnel")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the tunnel is established
|
|
||||||
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: tun.Port})
|
|
||||||
if err != nil {
|
|
||||||
time.Sleep(checkinInterval / 100)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := conn.Close(); err != nil {
|
|
||||||
log.Warn().Err(err).Msg("failed to close tcp connection")
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
s.UpdateLastActivity(endpoint.ID)
|
|
||||||
|
|
||||||
return fmt.Sprintf("127.0.0.1:%d", tun.Port), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// tryEffectiveCheckinInterval avoids a potential deadlock by returning a
|
|
||||||
// previous known value after a timeout
|
|
||||||
func (s *Service) tryEffectiveCheckinInterval(endpoint *portainer.Endpoint) int {
|
|
||||||
ch := make(chan int, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
ch <- edge.EffectiveCheckinInterval(s.dataStore, endpoint)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(50 * time.Millisecond):
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
return s.defaultCheckinInterval
|
|
||||||
case i := <-ch:
|
|
||||||
s.mu.Lock()
|
|
||||||
s.defaultCheckinInterval = i
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateLastActivity sets the current timestamp to avoid the tunnel timeout
|
|
||||||
func (s *Service) UpdateLastActivity(endpointID portainer.EndpointID) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
if tun, ok := s.activeTunnels[endpointID]; ok {
|
|
||||||
tun.LastActivity = time.Now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: it needs to be called with the lock acquired
|
|
||||||
// getUnusedPort is used to generate an unused random port in the dynamic port range.
|
|
||||||
// Dynamic ports (also called private ports) are 49152 to 65535.
|
|
||||||
func (service *Service) getUnusedPort() int {
|
|
||||||
port := randomInt(minAvailablePort, maxAvailablePort)
|
|
||||||
|
|
||||||
for _, tunnel := range service.activeTunnels {
|
|
||||||
if tunnel.Port == port {
|
|
||||||
return service.getUnusedPort()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
|
|
||||||
if err == nil {
|
|
||||||
if err := conn.Close(); err != nil {
|
|
||||||
log.Warn().Msg("failed to close tcp connection that checks if port is free")
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug().
|
|
||||||
Int("port", port).
|
|
||||||
Msg("selected port is in use, trying a different one")
|
|
||||||
|
|
||||||
return service.getUnusedPort()
|
|
||||||
}
|
|
||||||
|
|
||||||
return port
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomInt(min, max int) int {
|
|
||||||
return min + librand.Intn(max-min)
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateRandomCredentials() (string, string) {
|
func generateRandomCredentials() (string, string) {
|
||||||
username := uniuri.NewLen(8)
|
username := uniuri.NewLen(8)
|
||||||
password := uniuri.NewLen(8)
|
password := uniuri.NewLen(8)
|
||||||
|
|
||||||
return username, password
|
return username, password
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
package chisel
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testSettingsService struct {
|
|
||||||
dataservices.SettingsService
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *testSettingsService) Settings() (*portainer.Settings, error) {
|
|
||||||
return &portainer.Settings{
|
|
||||||
EdgeAgentCheckinInterval: 1,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type testStore struct {
|
|
||||||
dataservices.DataStore
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *testStore) Settings() dataservices.SettingsService {
|
|
||||||
return &testSettingsService{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetUnusedPort(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
existingTunnels map[portainer.EndpointID]*portainer.TunnelDetails
|
|
||||||
expectedError error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "simple case",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "existing tunnels",
|
|
||||||
existingTunnels: map[portainer.EndpointID]*portainer.TunnelDetails{
|
|
||||||
portainer.EndpointID(1): {
|
|
||||||
Port: 53072,
|
|
||||||
},
|
|
||||||
portainer.EndpointID(2): {
|
|
||||||
Port: 63072,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
store := &testStore{}
|
|
||||||
s := NewService(store, nil, nil)
|
|
||||||
s.activeTunnels = tc.existingTunnels
|
|
||||||
port := s.getUnusedPort()
|
|
||||||
|
|
||||||
if port < 49152 || port > 65535 {
|
|
||||||
t.Fatalf("Expected port to be inbetween 49152 and 65535 but got %d", port)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tun := range tc.existingTunnels {
|
|
||||||
if tun.Port == port {
|
|
||||||
t.Fatalf("returned port %d already has an existing tunnel", port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
|
|
||||||
if err == nil {
|
|
||||||
// Ignore error
|
|
||||||
_ = conn.Close()
|
|
||||||
t.Fatalf("expected port %d to be unused", port)
|
|
||||||
} else if !strings.Contains(err.Error(), "connection refused") {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
131
api/cli/cli.go
131
api/cli/cli.go
@@ -9,35 +9,46 @@ import (
|
|||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
|
||||||
"github.com/alecthomas/kingpin/v2"
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
"gopkg.in/alecthomas/kingpin.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service implements the CLIService interface
|
// Service implements the CLIService interface
|
||||||
type Service struct{}
|
type Service struct{}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrInvalidEndpointProtocol = errors.New("Invalid environment protocol: Portainer only supports unix://, npipe:// or tcp://")
|
errInvalidEndpointProtocol = errors.New("Invalid environment protocol: Portainer only supports unix://, npipe:// or tcp://")
|
||||||
ErrSocketOrNamedPipeNotFound = errors.New("Unable to locate Unix socket or named pipe")
|
errSocketOrNamedPipeNotFound = errors.New("Unable to locate Unix socket or named pipe")
|
||||||
ErrInvalidSnapshotInterval = errors.New("Invalid snapshot interval")
|
errInvalidSnapshotInterval = errors.New("Invalid snapshot interval")
|
||||||
ErrAdminPassExcludeAdminPassFile = errors.New("Cannot use --admin-password with --admin-password-file")
|
errAdminPassExcludeAdminPassFile = errors.New("Cannot use --admin-password with --admin-password-file")
|
||||||
)
|
)
|
||||||
|
|
||||||
func CLIFlags() *portainer.CLIFlags {
|
// ParseFlags parse the CLI flags and return a portainer.Flags struct
|
||||||
return &portainer.CLIFlags{
|
func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||||
|
kingpin.Version(version)
|
||||||
|
|
||||||
|
flags := &portainer.CLIFlags{
|
||||||
Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(),
|
Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(),
|
||||||
AddrHTTPS: kingpin.Flag("bind-https", "Address and port to serve Portainer via https").Default(defaultHTTPSBindAddress).String(),
|
AddrHTTPS: kingpin.Flag("bind-https", "Address and port to serve Portainer via https").Default(defaultHTTPSBindAddress).String(),
|
||||||
TunnelAddr: kingpin.Flag("tunnel-addr", "Address to serve the tunnel server").Default(defaultTunnelServerAddress).String(),
|
TunnelAddr: kingpin.Flag("tunnel-addr", "Address to serve the tunnel server").Default(defaultTunnelServerAddress).String(),
|
||||||
TunnelPort: kingpin.Flag("tunnel-port", "Port to serve the tunnel server").Default(defaultTunnelServerPort).String(),
|
TunnelPort: kingpin.Flag("tunnel-port", "Port to serve the tunnel server").Default(defaultTunnelServerPort).String(),
|
||||||
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
|
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
|
||||||
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
|
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
|
||||||
|
DemoEnvironment: kingpin.Flag("demo", "Demo environment").Bool(),
|
||||||
EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(),
|
EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(),
|
||||||
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Envar(portainer.FeatureFlagEnvVar).Strings(),
|
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Strings(),
|
||||||
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
|
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
|
||||||
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(),
|
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(),
|
||||||
|
TLS: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).Bool(),
|
||||||
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
|
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
|
||||||
|
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(),
|
||||||
|
TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(),
|
||||||
|
TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(),
|
||||||
HTTPDisabled: kingpin.Flag("http-disabled", "Serve portainer only on https").Default(defaultHTTPDisabled).Bool(),
|
HTTPDisabled: kingpin.Flag("http-disabled", "Serve portainer only on https").Default(defaultHTTPDisabled).Bool(),
|
||||||
HTTPEnabled: kingpin.Flag("http-enabled", "Serve portainer on http").Default(defaultHTTPEnabled).Bool(),
|
HTTPEnabled: kingpin.Flag("http-enabled", "Serve portainer on http").Default(defaultHTTPEnabled).Bool(),
|
||||||
|
SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL (deprecated)").Default(defaultSSL).Bool(),
|
||||||
|
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").String(),
|
||||||
|
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").String(),
|
||||||
Rollback: kingpin.Flag("rollback", "Rollback the database to the previous backup").Bool(),
|
Rollback: kingpin.Flag("rollback", "Rollback the database to the previous backup").Bool(),
|
||||||
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").String(),
|
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").String(),
|
||||||
AdminPassword: kingpin.Flag("admin-password", "Set admin password with provided hash").String(),
|
AdminPassword: kingpin.Flag("admin-password", "Set admin password with provided hash").String(),
|
||||||
@@ -52,52 +63,7 @@ func CLIFlags() *portainer.CLIFlags {
|
|||||||
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
|
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
|
||||||
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
|
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
|
||||||
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
||||||
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
|
|
||||||
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
|
|
||||||
CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(),
|
|
||||||
CompactDB: kingpin.Flag("compact-db", "Enable database compaction on startup").Envar(portainer.CompactDBEnvVar).Default("false").Bool(),
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ParseFlags parse the CLI flags and return a portainer.Flags struct
|
|
||||||
func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
|
||||||
kingpin.Version(version)
|
|
||||||
|
|
||||||
var hasSSLFlag, hasSSLCertFlag, hasSSLKeyFlag bool
|
|
||||||
sslFlag := kingpin.Flag(
|
|
||||||
"ssl",
|
|
||||||
"Secure Portainer instance using SSL (deprecated)",
|
|
||||||
).Default(defaultSSL).IsSetByUser(&hasSSLFlag)
|
|
||||||
ssl := sslFlag.Bool()
|
|
||||||
sslCertFlag := kingpin.Flag(
|
|
||||||
"sslcert",
|
|
||||||
"Path to the SSL certificate used to secure the Portainer instance",
|
|
||||||
).IsSetByUser(&hasSSLCertFlag)
|
|
||||||
sslCert := sslCertFlag.String()
|
|
||||||
sslKeyFlag := kingpin.Flag(
|
|
||||||
"sslkey",
|
|
||||||
"Path to the SSL key used to secure the Portainer instance",
|
|
||||||
).IsSetByUser(&hasSSLKeyFlag)
|
|
||||||
sslKey := sslKeyFlag.String()
|
|
||||||
|
|
||||||
flags := CLIFlags()
|
|
||||||
|
|
||||||
var hasTLSFlag, hasTLSCertFlag, hasTLSKeyFlag bool
|
|
||||||
tlsFlag := kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).IsSetByUser(&hasTLSFlag)
|
|
||||||
flags.TLS = tlsFlag.Bool()
|
|
||||||
tlsCertFlag := kingpin.Flag(
|
|
||||||
"tlscert",
|
|
||||||
"Path to the TLS certificate file",
|
|
||||||
).Default(defaultTLSCertPath).IsSetByUser(&hasTLSCertFlag)
|
|
||||||
flags.TLSCert = tlsCertFlag.String()
|
|
||||||
tlsKeyFlag := kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).IsSetByUser(&hasTLSKeyFlag)
|
|
||||||
flags.TLSKey = tlsKeyFlag.String()
|
|
||||||
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
|
|
||||||
|
|
||||||
flags.KubectlShellImage = kingpin.Flag(
|
|
||||||
"kubectl-shell-image",
|
|
||||||
"Kubectl shell image",
|
|
||||||
).Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String()
|
|
||||||
|
|
||||||
kingpin.Parse()
|
kingpin.Parse()
|
||||||
|
|
||||||
@@ -110,58 +76,25 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
|||||||
*flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets)
|
*flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the user didn't provide a tls flag remove the defaults to match previous behaviour
|
|
||||||
if !hasTLSFlag {
|
|
||||||
if !hasTLSCertFlag {
|
|
||||||
*flags.TLSCert = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasTLSKeyFlag {
|
|
||||||
*flags.TLSKey = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasSSLFlag {
|
|
||||||
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslFlag.Model().Name, tlsFlag.Model().Name)
|
|
||||||
|
|
||||||
if !hasTLSFlag {
|
|
||||||
flags.TLS = ssl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasSSLCertFlag {
|
|
||||||
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslCertFlag.Model().Name, tlsCertFlag.Model().Name)
|
|
||||||
|
|
||||||
if !hasTLSCertFlag {
|
|
||||||
flags.TLSCert = sslCert
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasSSLKeyFlag {
|
|
||||||
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslKeyFlag.Model().Name, tlsKeyFlag.Model().Name)
|
|
||||||
|
|
||||||
if !hasTLSKeyFlag {
|
|
||||||
flags.TLSKey = sslKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return flags, nil
|
return flags, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateFlags validates the values of the flags.
|
// ValidateFlags validates the values of the flags.
|
||||||
func (Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||||
displayDeprecationWarnings(flags)
|
displayDeprecationWarnings(flags)
|
||||||
|
|
||||||
if err := validateEndpointURL(*flags.EndpointURL); err != nil {
|
err := validateEndpointURL(*flags.EndpointURL)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSnapshotInterval(*flags.SnapshotInterval); err != nil {
|
err = validateSnapshotInterval(*flags.SnapshotInterval)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if *flags.AdminPassword != "" && *flags.AdminPasswordFile != "" {
|
if *flags.AdminPassword != "" && *flags.AdminPasswordFile != "" {
|
||||||
return ErrAdminPassExcludeAdminPassFile
|
return errAdminPassExcludeAdminPassFile
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -171,6 +104,10 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) {
|
|||||||
if *flags.NoAnalytics {
|
if *flags.NoAnalytics {
|
||||||
log.Warn().Msg("the --no-analytics flag has been kept to allow migration of instances running a previous version of Portainer with this flag enabled, to version 2.0 where enabling this flag will have no effect")
|
log.Warn().Msg("the --no-analytics flag has been kept to allow migration of instances running a previous version of Portainer with this flag enabled, to version 2.0 where enabling this flag will have no effect")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *flags.SSL {
|
||||||
|
log.Warn().Msg("SSL is enabled by default and there is no need for the --ssl flag, it has been kept to allow migration of instances running a previous version of Portainer with this flag enabled")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateEndpointURL(endpointURL string) error {
|
func validateEndpointURL(endpointURL string) error {
|
||||||
@@ -179,16 +116,15 @@ func validateEndpointURL(endpointURL string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") && !strings.HasPrefix(endpointURL, "npipe://") {
|
if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") && !strings.HasPrefix(endpointURL, "npipe://") {
|
||||||
return ErrInvalidEndpointProtocol
|
return errInvalidEndpointProtocol
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(endpointURL, "unix://") || strings.HasPrefix(endpointURL, "npipe://") {
|
if strings.HasPrefix(endpointURL, "unix://") || strings.HasPrefix(endpointURL, "npipe://") {
|
||||||
socketPath := strings.TrimPrefix(endpointURL, "unix://")
|
socketPath := strings.TrimPrefix(endpointURL, "unix://")
|
||||||
socketPath = strings.TrimPrefix(socketPath, "npipe://")
|
socketPath = strings.TrimPrefix(socketPath, "npipe://")
|
||||||
|
|
||||||
if _, err := os.Stat(socketPath); err != nil {
|
if _, err := os.Stat(socketPath); err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return ErrSocketOrNamedPipeNotFound
|
return errSocketOrNamedPipeNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
@@ -203,8 +139,9 @@ func validateSnapshotInterval(snapshotInterval string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := time.ParseDuration(snapshotInterval); err != nil {
|
_, err := time.ParseDuration(snapshotInterval)
|
||||||
return ErrInvalidSnapshotInterval
|
if err != nil {
|
||||||
|
return errInvalidSnapshotInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
zerolog "github.com/rs/zerolog/log"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestOptionParser(t *testing.T) {
|
|
||||||
p := Service{}
|
|
||||||
require.NotNil(t, p)
|
|
||||||
|
|
||||||
a := os.Args
|
|
||||||
defer func() { os.Args = a }()
|
|
||||||
|
|
||||||
os.Args = []string{"portainer", "--edge-compute"}
|
|
||||||
|
|
||||||
opts, err := p.ParseFlags("2.34.5")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.False(t, *opts.HTTPDisabled)
|
|
||||||
require.True(t, *opts.EnableEdgeComputeFeatures)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTLSFlags(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
args []string
|
|
||||||
expectedTLSFlag bool
|
|
||||||
expectedTLSCertFlag string
|
|
||||||
expectedTLSKeyFlag string
|
|
||||||
expectedLogMessages []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no flags",
|
|
||||||
expectedTLSFlag: false,
|
|
||||||
expectedTLSCertFlag: "",
|
|
||||||
expectedTLSKeyFlag: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "only ssl flag",
|
|
||||||
args: []string{
|
|
||||||
"portainer",
|
|
||||||
"--ssl",
|
|
||||||
},
|
|
||||||
expectedTLSFlag: true,
|
|
||||||
expectedTLSCertFlag: "",
|
|
||||||
expectedTLSKeyFlag: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "only tls flag",
|
|
||||||
args: []string{
|
|
||||||
"portainer",
|
|
||||||
"--tlsverify",
|
|
||||||
},
|
|
||||||
expectedTLSFlag: true,
|
|
||||||
expectedTLSCertFlag: defaultTLSCertPath,
|
|
||||||
expectedTLSKeyFlag: defaultTLSKeyPath,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "partial ssl flags",
|
|
||||||
args: []string{
|
|
||||||
"portainer",
|
|
||||||
"--ssl",
|
|
||||||
"--sslcert=ssl-cert-flag-value",
|
|
||||||
},
|
|
||||||
expectedTLSFlag: true,
|
|
||||||
expectedTLSCertFlag: "ssl-cert-flag-value",
|
|
||||||
expectedTLSKeyFlag: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "partial tls flags",
|
|
||||||
args: []string{
|
|
||||||
"portainer",
|
|
||||||
"--tlsverify",
|
|
||||||
"--tlscert=tls-cert-flag-value",
|
|
||||||
},
|
|
||||||
expectedTLSFlag: true,
|
|
||||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
|
||||||
expectedTLSKeyFlag: defaultTLSKeyPath,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "partial tls and ssl flags",
|
|
||||||
args: []string{
|
|
||||||
"portainer",
|
|
||||||
"--tlsverify",
|
|
||||||
"--tlscert=tls-cert-flag-value",
|
|
||||||
"--sslkey=ssl-key-flag-value",
|
|
||||||
},
|
|
||||||
expectedTLSFlag: true,
|
|
||||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
|
||||||
expectedTLSKeyFlag: "ssl-key-flag-value",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "partial tls and ssl flags 2",
|
|
||||||
args: []string{
|
|
||||||
"portainer",
|
|
||||||
"--ssl",
|
|
||||||
"--tlscert=tls-cert-flag-value",
|
|
||||||
"--sslkey=ssl-key-flag-value",
|
|
||||||
},
|
|
||||||
expectedTLSFlag: true,
|
|
||||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
|
||||||
expectedTLSKeyFlag: "ssl-key-flag-value",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ssl flags",
|
|
||||||
args: []string{
|
|
||||||
"portainer",
|
|
||||||
"--ssl",
|
|
||||||
"--sslcert=ssl-cert-flag-value",
|
|
||||||
"--sslkey=ssl-key-flag-value",
|
|
||||||
},
|
|
||||||
expectedTLSFlag: true,
|
|
||||||
expectedTLSCertFlag: "ssl-cert-flag-value",
|
|
||||||
expectedTLSKeyFlag: "ssl-key-flag-value",
|
|
||||||
expectedLogMessages: []string{
|
|
||||||
"the \\\"ssl\\\" flag is deprecated. use \\\"tlsverify\\\" instead.",
|
|
||||||
"the \\\"sslcert\\\" flag is deprecated. use \\\"tlscert\\\" instead.",
|
|
||||||
"the \\\"sslkey\\\" flag is deprecated. use \\\"tlskey\\\" instead.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tls flags",
|
|
||||||
args: []string{
|
|
||||||
"portainer",
|
|
||||||
"--tlsverify",
|
|
||||||
"--tlscert=tls-cert-flag-value",
|
|
||||||
"--tlskey=tls-key-flag-value",
|
|
||||||
},
|
|
||||||
expectedTLSFlag: true,
|
|
||||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
|
||||||
expectedTLSKeyFlag: "tls-key-flag-value",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tls and ssl flags",
|
|
||||||
args: []string{
|
|
||||||
"portainer",
|
|
||||||
"--tlsverify",
|
|
||||||
"--tlscert=tls-cert-flag-value",
|
|
||||||
"--tlskey=tls-key-flag-value",
|
|
||||||
"--ssl",
|
|
||||||
"--sslcert=ssl-cert-flag-value",
|
|
||||||
"--sslkey=ssl-key-flag-value",
|
|
||||||
},
|
|
||||||
expectedTLSFlag: true,
|
|
||||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
|
||||||
expectedTLSKeyFlag: "tls-key-flag-value",
|
|
||||||
expectedLogMessages: []string{
|
|
||||||
"the \\\"ssl\\\" flag is deprecated. use \\\"tlsverify\\\" instead.",
|
|
||||||
"the \\\"sslcert\\\" flag is deprecated. use \\\"tlscert\\\" instead.",
|
|
||||||
"the \\\"sslkey\\\" flag is deprecated. use \\\"tlskey\\\" instead.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
var logOutput strings.Builder
|
|
||||||
setupLogOutput(t, &logOutput)
|
|
||||||
|
|
||||||
if tc.args == nil {
|
|
||||||
tc.args = []string{"portainer"}
|
|
||||||
}
|
|
||||||
setOsArgs(t, tc.args)
|
|
||||||
|
|
||||||
s := Service{}
|
|
||||||
flags, err := s.ParseFlags("test-version")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error parsing flags: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if flags.TLS == nil {
|
|
||||||
t.Fatal("TLS flag was nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Equal(t, tc.expectedTLSFlag, *flags.TLS, "tlsverify flag didn't match")
|
|
||||||
require.Equal(t, tc.expectedTLSCertFlag, *flags.TLSCert, "tlscert flag didn't match")
|
|
||||||
require.Equal(t, tc.expectedTLSKeyFlag, *flags.TLSKey, "tlskey flag didn't match")
|
|
||||||
|
|
||||||
for _, expectedLogMessage := range tc.expectedLogMessages {
|
|
||||||
require.Contains(t, logOutput.String(), expectedLogMessage, "Log didn't contain expected message")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setOsArgs(t *testing.T, args []string) {
|
|
||||||
t.Helper()
|
|
||||||
previousArgs := os.Args
|
|
||||||
os.Args = args
|
|
||||||
t.Cleanup(func() {
|
|
||||||
os.Args = previousArgs
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupLogOutput(t *testing.T, w io.Writer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
oldLogger := zerolog.Logger
|
|
||||||
zerolog.Logger = zerolog.Output(w)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
zerolog.Logger = oldLogger
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -19,5 +19,7 @@ func Confirm(message string) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
answer = strings.ReplaceAll(answer, "\n", "")
|
answer = strings.ReplaceAll(answer, "\n", "")
|
||||||
return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes"), nil
|
answer = strings.ToLower(answer)
|
||||||
|
|
||||||
|
return answer == "y" || answer == "yes", nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//go:build !windows
|
//go:build !windows
|
||||||
|
// +build !windows
|
||||||
|
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
@@ -19,5 +20,4 @@ const (
|
|||||||
defaultSSL = "false"
|
defaultSSL = "false"
|
||||||
defaultBaseURL = "/"
|
defaultBaseURL = "/"
|
||||||
defaultSecretKeyName = "portainer"
|
defaultSecretKeyName = "portainer"
|
||||||
defaultPullLimitCheckDisabled = "false"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,5 +18,4 @@ const (
|
|||||||
defaultSnapshotInterval = "5m"
|
defaultSnapshotInterval = "5m"
|
||||||
defaultBaseURL = "/"
|
defaultBaseURL = "/"
|
||||||
defaultSecretKeyName = "portainer"
|
defaultSecretKeyName = "portainer"
|
||||||
defaultPullLimitCheckDisabled = "false"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/alecthomas/kingpin/v2"
|
"gopkg.in/alecthomas/kingpin.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type pairList []portainer.Pair
|
type pairList []portainer.Pair
|
||||||
|
|||||||
45
api/cli/pairlistbool.go
Normal file
45
api/cli/pairlistbool.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
|
||||||
|
"gopkg.in/alecthomas/kingpin.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pairListBool []portainer.Pair
|
||||||
|
|
||||||
|
// Set implementation for a list of portainer.Pair
|
||||||
|
func (l *pairListBool) Set(value string) error {
|
||||||
|
p := new(portainer.Pair)
|
||||||
|
|
||||||
|
// default to true. example setting=true is equivalent to setting
|
||||||
|
parts := strings.SplitN(value, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
p.Name = parts[0]
|
||||||
|
p.Value = "true"
|
||||||
|
} else {
|
||||||
|
p.Name = parts[0]
|
||||||
|
p.Value = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
*l = append(*l, *p)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// String implementation for a list of pair
|
||||||
|
func (l *pairListBool) String() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCumulative implementation for a list of pair
|
||||||
|
func (l *pairListBool) IsCumulative() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func BoolPairs(s kingpin.Settings) (target *[]portainer.Pair) {
|
||||||
|
target = new([]portainer.Pair)
|
||||||
|
s.SetValue((*pairListBool)(target))
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
package logs
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
stdlog "log"
|
stdlog "log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
@@ -11,7 +10,7 @@ import (
|
|||||||
"github.com/rs/zerolog/pkgerrors"
|
"github.com/rs/zerolog/pkgerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ConfigureLogger() {
|
func configureLogger() {
|
||||||
zerolog.ErrorStackFieldName = "stack_trace"
|
zerolog.ErrorStackFieldName = "stack_trace"
|
||||||
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
|
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
|
||||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||||
@@ -22,7 +21,7 @@ func ConfigureLogger() {
|
|||||||
log.Logger = log.Logger.With().Caller().Stack().Logger()
|
log.Logger = log.Logger.With().Caller().Stack().Logger()
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetLoggingLevel(level string) {
|
func setLoggingLevel(level string) {
|
||||||
switch level {
|
switch level {
|
||||||
case "ERROR":
|
case "ERROR":
|
||||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||||
@@ -35,7 +34,7 @@ func SetLoggingLevel(level string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetLoggingMode(mode string) {
|
func setLoggingMode(mode string) {
|
||||||
switch mode {
|
switch mode {
|
||||||
case "PRETTY":
|
case "PRETTY":
|
||||||
log.Logger = log.Output(zerolog.ConsoleWriter{
|
log.Logger = log.Output(zerolog.ConsoleWriter{
|
||||||
@@ -55,16 +54,10 @@ func SetLoggingMode(mode string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatMessage(i any) string {
|
func formatMessage(i interface{}) string {
|
||||||
if i == nil {
|
if i == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s |", i)
|
return fmt.Sprintf("%s |", i)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CloseAndLogErr(c io.Closer) {
|
|
||||||
if err := c.Close(); err != nil {
|
|
||||||
log.Error().Err(err).Msg("failure to close resource")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"os"
|
"os"
|
||||||
@@ -10,6 +9,7 @@ import (
|
|||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/apikey"
|
"github.com/portainer/portainer/api/apikey"
|
||||||
|
"github.com/portainer/portainer/api/build"
|
||||||
"github.com/portainer/portainer/api/chisel"
|
"github.com/portainer/portainer/api/chisel"
|
||||||
"github.com/portainer/portainer/api/cli"
|
"github.com/portainer/portainer/api/cli"
|
||||||
"github.com/portainer/portainer/api/crypto"
|
"github.com/portainer/portainer/api/crypto"
|
||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/datastore"
|
"github.com/portainer/portainer/api/datastore"
|
||||||
"github.com/portainer/portainer/api/datastore/migrator"
|
"github.com/portainer/portainer/api/datastore/migrator"
|
||||||
"github.com/portainer/portainer/api/datastore/postinit"
|
"github.com/portainer/portainer/api/demo"
|
||||||
"github.com/portainer/portainer/api/docker"
|
"github.com/portainer/portainer/api/docker"
|
||||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||||
"github.com/portainer/portainer/api/exec"
|
"github.com/portainer/portainer/api/exec"
|
||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
"github.com/portainer/portainer/api/http/proxy"
|
"github.com/portainer/portainer/api/http/proxy"
|
||||||
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||||
"github.com/portainer/portainer/api/internal/authorization"
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
|
"github.com/portainer/portainer/api/internal/edge"
|
||||||
"github.com/portainer/portainer/api/internal/edge/edgestacks"
|
"github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
"github.com/portainer/portainer/api/internal/snapshot"
|
"github.com/portainer/portainer/api/internal/snapshot"
|
||||||
@@ -39,35 +40,28 @@ import (
|
|||||||
"github.com/portainer/portainer/api/kubernetes"
|
"github.com/portainer/portainer/api/kubernetes"
|
||||||
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
|
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
|
||||||
"github.com/portainer/portainer/api/ldap"
|
"github.com/portainer/portainer/api/ldap"
|
||||||
"github.com/portainer/portainer/api/logs"
|
|
||||||
"github.com/portainer/portainer/api/oauth"
|
"github.com/portainer/portainer/api/oauth"
|
||||||
"github.com/portainer/portainer/api/pendingactions"
|
"github.com/portainer/portainer/api/pendingactions"
|
||||||
"github.com/portainer/portainer/api/pendingactions/actions"
|
|
||||||
"github.com/portainer/portainer/api/pendingactions/handlers"
|
|
||||||
"github.com/portainer/portainer/api/platform"
|
|
||||||
"github.com/portainer/portainer/api/scheduler"
|
"github.com/portainer/portainer/api/scheduler"
|
||||||
"github.com/portainer/portainer/api/stacks/deployments"
|
"github.com/portainer/portainer/api/stacks/deployments"
|
||||||
"github.com/portainer/portainer/pkg/build"
|
|
||||||
"github.com/portainer/portainer/pkg/featureflags"
|
"github.com/portainer/portainer/pkg/featureflags"
|
||||||
"github.com/portainer/portainer/pkg/fips"
|
|
||||||
"github.com/portainer/portainer/pkg/libhelm"
|
"github.com/portainer/portainer/pkg/libhelm"
|
||||||
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
|
"github.com/portainer/portainer/pkg/libstack"
|
||||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||||
"github.com/portainer/portainer/pkg/validate"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/gofrs/uuid"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func initCLI() *portainer.CLIFlags {
|
func initCLI() *portainer.CLIFlags {
|
||||||
cliService := cli.Service{}
|
var cliService portainer.CLIService = &cli.Service{}
|
||||||
|
|
||||||
flags, err := cliService.ParseFlags(portainer.APIVersion)
|
flags, err := cliService.ParseFlags(portainer.APIVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed parsing flags")
|
log.Fatal().Err(err).Msg("failed parsing flags")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cliService.ValidateFlags(flags); err != nil {
|
err = cliService.ValidateFlags(flags)
|
||||||
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed validating flags")
|
log.Fatal().Err(err).Msg("failed validating flags")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +78,7 @@ func initFileService(dataStorePath string) portainer.FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService portainer.FileService, shutdownCtx context.Context) dataservices.DataStore {
|
func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService portainer.FileService, shutdownCtx context.Context) dataservices.DataStore {
|
||||||
connection, err := database.NewDatabase("boltdb", *flags.Data, secretKey, *flags.CompactDB)
|
connection, err := database.NewDatabase("boltdb", *flags.Data, secretKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed creating database connection")
|
log.Fatal().Err(err).Msg("failed creating database connection")
|
||||||
}
|
}
|
||||||
@@ -97,15 +91,15 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
|||||||
log.Fatal().Msg("failed creating database connection: expecting a boltdb database type but a different one was received")
|
log.Fatal().Msg("failed creating database connection: expecting a boltdb database type but a different one was received")
|
||||||
}
|
}
|
||||||
|
|
||||||
store := datastore.NewStore(flags, fileService, connection)
|
store := datastore.NewStore(*flags.Data, fileService, connection)
|
||||||
|
|
||||||
isNew, err := store.Open()
|
isNew, err := store.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed opening store")
|
log.Fatal().Err(err).Msg("failed opening store")
|
||||||
}
|
}
|
||||||
|
|
||||||
if *flags.Rollback {
|
if *flags.Rollback {
|
||||||
if err := store.Rollback(false); err != nil {
|
err := store.Rollback(false)
|
||||||
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed rolling back")
|
log.Fatal().Err(err).Msg("failed rolling back")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,17 +108,18 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Init sets some defaults - it's basically a migration
|
// Init sets some defaults - it's basically a migration
|
||||||
if err := store.Init(); err != nil {
|
err = store.Init()
|
||||||
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed initializing data store")
|
log.Fatal().Err(err).Msg("failed initializing data store")
|
||||||
}
|
}
|
||||||
|
|
||||||
if isNew {
|
if isNew {
|
||||||
instanceId, err := uuid.NewRandom()
|
instanceId, err := uuid.NewV4()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed generating instance id")
|
log.Fatal().Err(err).Msg("failed generating instance id")
|
||||||
}
|
}
|
||||||
|
|
||||||
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{Flags: flags})
|
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{})
|
||||||
migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion()
|
migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion()
|
||||||
|
|
||||||
// from MigrateData
|
// from MigrateData
|
||||||
@@ -134,27 +129,28 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
|||||||
InstanceID: instanceId.String(),
|
InstanceID: instanceId.String(),
|
||||||
MigratorCount: migratorCount,
|
MigratorCount: migratorCount,
|
||||||
}
|
}
|
||||||
|
store.VersionService.UpdateVersion(&v)
|
||||||
|
|
||||||
if err := store.VersionService.UpdateVersion(&v); err != nil {
|
err = updateSettingsFromFlags(store, flags)
|
||||||
log.Fatal().Err(err).Msg("failed to update version")
|
if err != nil {
|
||||||
}
|
|
||||||
|
|
||||||
if err := updateSettingsFromFlags(store, flags); err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed updating settings from flags")
|
log.Fatal().Err(err).Msg("failed updating settings from flags")
|
||||||
}
|
}
|
||||||
} else if err := store.MigrateData(); err != nil {
|
} else {
|
||||||
|
err = store.MigrateData()
|
||||||
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed migration")
|
log.Fatal().Err(err).Msg("failed migration")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := updateSettingsFromFlags(store, flags); err != nil {
|
err = updateSettingsFromFlags(store, flags)
|
||||||
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed updating settings from flags")
|
log.Fatal().Err(err).Msg("failed updating settings from flags")
|
||||||
}
|
}
|
||||||
|
|
||||||
// this is for the db restore functionality - needs more tests.
|
// this is for the db restore functionality - needs more tests.
|
||||||
go func() {
|
go func() {
|
||||||
<-shutdownCtx.Done()
|
<-shutdownCtx.Done()
|
||||||
|
defer connection.Close()
|
||||||
defer logs.CloseAndLogErr(connection)
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return store
|
return store
|
||||||
@@ -170,12 +166,32 @@ func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersi
|
|||||||
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
|
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
|
||||||
}
|
}
|
||||||
|
|
||||||
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager) portainer.KubernetesDeployer {
|
func initComposeStackManager(composeDeployer libstack.Deployer, proxyManager *proxy.Manager) portainer.ComposeStackManager {
|
||||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
|
composeWrapper, err := exec.NewComposeStackManager(composeDeployer, proxyManager)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed creating compose manager")
|
||||||
|
}
|
||||||
|
|
||||||
|
return composeWrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
func initHelmPackageManager() (libhelmtypes.HelmPackageManager, error) {
|
func initSwarmStackManager(
|
||||||
return libhelm.NewHelmPackageManager()
|
assetsPath string,
|
||||||
|
configPath string,
|
||||||
|
signatureService portainer.DigitalSignatureService,
|
||||||
|
fileService portainer.FileService,
|
||||||
|
reverseTunnelService portainer.ReverseTunnelService,
|
||||||
|
dataStore dataservices.DataStore,
|
||||||
|
) (portainer.SwarmStackManager, error) {
|
||||||
|
return exec.NewSwarmStackManager(assetsPath, configPath, signatureService, fileService, reverseTunnelService, dataStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, assetsPath string) portainer.KubernetesDeployer {
|
||||||
|
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initHelmPackageManager(assetsPath string) (libhelm.HelmPackageManager, error) {
|
||||||
|
return libhelm.NewHelmPackageManager(libhelm.HelmConfig{BinaryPath: assetsPath})
|
||||||
}
|
}
|
||||||
|
|
||||||
func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
|
func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
|
||||||
@@ -187,16 +203,36 @@ func initJWTService(userSessionTimeout string, dataStore dataservices.DataStore)
|
|||||||
userSessionTimeout = portainer.DefaultUserSessionTimeout
|
userSessionTimeout = portainer.DefaultUserSessionTimeout
|
||||||
}
|
}
|
||||||
|
|
||||||
return jwt.NewService(userSessionTimeout, dataStore)
|
jwtService, err := jwt.NewService(userSessionTimeout, dataStore)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwtService, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func initDigitalSignatureService() portainer.DigitalSignatureService {
|
func initDigitalSignatureService() portainer.DigitalSignatureService {
|
||||||
return crypto.NewECDSAService(os.Getenv("AGENT_SECRET"))
|
return crypto.NewECDSAService(os.Getenv("AGENT_SECRET"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initCryptoService() portainer.CryptoService {
|
||||||
|
return &crypto.Service{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initLDAPService() portainer.LDAPService {
|
||||||
|
return &ldap.Service{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initOAuthService() portainer.OAuthService {
|
||||||
|
return oauth.NewService()
|
||||||
|
}
|
||||||
|
|
||||||
|
func initGitService(ctx context.Context) portainer.GitService {
|
||||||
|
return git.NewService(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func initSSLService(addr, certPath, keyPath string, fileService portainer.FileService, dataStore dataservices.DataStore, shutdownTrigger context.CancelFunc) (*ssl.Service, error) {
|
func initSSLService(addr, certPath, keyPath string, fileService portainer.FileService, dataStore dataservices.DataStore, shutdownTrigger context.CancelFunc) (*ssl.Service, error) {
|
||||||
slices := strings.Split(addr, ":")
|
slices := strings.Split(addr, ":")
|
||||||
|
|
||||||
host := slices[0]
|
host := slices[0]
|
||||||
if host == "" {
|
if host == "" {
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
@@ -204,13 +240,22 @@ func initSSLService(addr, certPath, keyPath string, fileService portainer.FileSe
|
|||||||
|
|
||||||
sslService := ssl.NewService(fileService, dataStore, shutdownTrigger)
|
sslService := ssl.NewService(fileService, dataStore, shutdownTrigger)
|
||||||
|
|
||||||
if err := sslService.Init(host, certPath, keyPath); err != nil {
|
err := sslService.Init(host, certPath, keyPath)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return sslService, nil
|
return sslService, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initDockerClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *dockerclient.ClientFactory {
|
||||||
|
return dockerclient.NewClientFactory(signatureService, reverseTunnelService)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, dataStore dataservices.DataStore, instanceID, addrHTTPS, userSessionTimeout string) (*kubecli.ClientFactory, error) {
|
||||||
|
return kubecli.NewClientFactory(signatureService, reverseTunnelService, dataStore, instanceID, addrHTTPS, userSessionTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
func initSnapshotService(
|
func initSnapshotService(
|
||||||
snapshotIntervalFromFlag string,
|
snapshotIntervalFromFlag string,
|
||||||
dataStore dataservices.DataStore,
|
dataStore dataservices.DataStore,
|
||||||
@@ -243,21 +288,34 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.SnapshotInterval = cmp.Or(*flags.SnapshotInterval, settings.SnapshotInterval)
|
if *flags.SnapshotInterval != "" {
|
||||||
settings.LogoURL = cmp.Or(*flags.Logo, settings.LogoURL)
|
settings.SnapshotInterval = *flags.SnapshotInterval
|
||||||
settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
|
}
|
||||||
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
|
|
||||||
|
if *flags.Logo != "" {
|
||||||
|
settings.LogoURL = *flags.Logo
|
||||||
|
}
|
||||||
|
|
||||||
|
if *flags.EnableEdgeComputeFeatures {
|
||||||
|
settings.EnableEdgeComputeFeatures = *flags.EnableEdgeComputeFeatures
|
||||||
|
}
|
||||||
|
|
||||||
|
if *flags.Templates != "" {
|
||||||
|
settings.TemplatesURL = *flags.Templates
|
||||||
|
}
|
||||||
|
|
||||||
if *flags.Labels != nil {
|
if *flags.Labels != nil {
|
||||||
settings.BlackListedLabels = *flags.Labels
|
settings.BlackListedLabels = *flags.Labels
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.AgentSecret = ""
|
|
||||||
if agentKey, ok := os.LookupEnv("AGENT_SECRET"); ok {
|
if agentKey, ok := os.LookupEnv("AGENT_SECRET"); ok {
|
||||||
settings.AgentSecret = agentKey
|
settings.AgentSecret = agentKey
|
||||||
|
} else {
|
||||||
|
settings.AgentSecret = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := dataStore.Settings().UpdateSettings(settings); err != nil {
|
err = dataStore.Settings().UpdateSettings(settings)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +338,6 @@ func loadAndParseKeyPair(fileService portainer.FileService, signatureService por
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return signatureService.ParseKeyPair(private, public)
|
return signatureService.ParseKeyPair(private, public)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,9 +346,7 @@ func generateAndStoreKeyPair(fileService portainer.FileService, signatureService
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
privateHeader, publicHeader := signatureService.PEMHeaders()
|
privateHeader, publicHeader := signatureService.PEMHeaders()
|
||||||
|
|
||||||
return fileService.StoreKeyPair(private, public, privateHeader, publicHeader)
|
return fileService.StoreKeyPair(private, public, privateHeader, publicHeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,23 +359,11 @@ func initKeyPair(fileService portainer.FileService, signatureService portainer.D
|
|||||||
if existingKeyPair {
|
if existingKeyPair {
|
||||||
return loadAndParseKeyPair(fileService, signatureService)
|
return loadAndParseKeyPair(fileService, signatureService)
|
||||||
}
|
}
|
||||||
|
|
||||||
return generateAndStoreKeyPair(fileService, signatureService)
|
return generateAndStoreKeyPair(fileService, signatureService)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dbSecretPath build the path to the file that contains the db encryption
|
|
||||||
// secret. Normally in Docker this is built from the static path inside
|
|
||||||
// /run/secrets for example: /run/secrets/<keyFilenameFlag> but for ease of
|
|
||||||
// use outside Docker it also accepts an absolute path
|
|
||||||
func dbSecretPath(keyFilenameFlag string) string {
|
|
||||||
if path.IsAbs(keyFilenameFlag) {
|
|
||||||
return keyFilenameFlag
|
|
||||||
}
|
|
||||||
return path.Join("/run/secrets", keyFilenameFlag)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadEncryptionSecretKey(keyfilename string) []byte {
|
func loadEncryptionSecretKey(keyfilename string) []byte {
|
||||||
content, err := os.ReadFile(keyfilename)
|
content, err := os.ReadFile(path.Join("/run/secrets", keyfilename))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
log.Info().Str("filename", keyfilename).Msg("encryption key file not present")
|
log.Info().Str("filename", keyfilename).Msg("encryption key file not present")
|
||||||
@@ -332,9 +375,7 @@ func loadEncryptionSecretKey(keyfilename string) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// return a 32 byte hash of the secret (required for AES)
|
// return a 32 byte hash of the secret (required for AES)
|
||||||
// fips compliant version of this is not implemented in -ce
|
|
||||||
hash := sha256.Sum256(content)
|
hash := sha256.Sum256(content)
|
||||||
|
|
||||||
return hash[:]
|
return hash[:]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,23 +386,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
|
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
|
||||||
}
|
}
|
||||||
|
|
||||||
trustedOrigins := []string{}
|
|
||||||
if *flags.TrustedOrigins != "" {
|
|
||||||
// validate if the trusted origins are valid urls
|
|
||||||
for origin := range strings.SplitSeq(*flags.TrustedOrigins, ",") {
|
|
||||||
if !validate.IsTrustedOrigin(origin) {
|
|
||||||
log.Fatal().Str("trusted_origin", origin).Msg("invalid url for trusted origin. Please check the trusted origins flag.")
|
|
||||||
}
|
|
||||||
|
|
||||||
trustedOrigins = append(trustedOrigins, origin)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -ce can not ever be run in FIPS mode
|
|
||||||
fips.InitFIPS(false)
|
|
||||||
|
|
||||||
fileService := initFileService(*flags.Data)
|
fileService := initFileService(*flags.Data)
|
||||||
encryptionKey := loadEncryptionSecretKey(dbSecretPath(*flags.SecretKeyName))
|
encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName)
|
||||||
if encryptionKey == nil {
|
if encryptionKey == nil {
|
||||||
log.Info().Msg("proceeding without encryption key")
|
log.Info().Msg("proceeding without encryption key")
|
||||||
}
|
}
|
||||||
@@ -394,22 +420,21 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
log.Fatal().Err(err).Msg("failed initializing JWT service")
|
log.Fatal().Err(err).Msg("failed initializing JWT service")
|
||||||
}
|
}
|
||||||
|
|
||||||
ldapService := ldap.Service{}
|
ldapService := initLDAPService()
|
||||||
|
|
||||||
oauthService := oauth.NewService()
|
oauthService := initOAuthService()
|
||||||
|
|
||||||
gitService := git.NewService(shutdownCtx)
|
gitService := initGitService(shutdownCtx)
|
||||||
|
|
||||||
// Setting insecureSkipVerify to true to preserve the old behaviour.
|
openAMTService := openamt.NewService()
|
||||||
openAMTService := openamt.NewService(true)
|
|
||||||
|
|
||||||
cryptoService := crypto.Service{}
|
cryptoService := initCryptoService()
|
||||||
|
|
||||||
signatureService := initDigitalSignatureService()
|
digitalSignatureService := initDigitalSignatureService()
|
||||||
|
|
||||||
edgeStacksService := edgestacks.NewService(dataStore)
|
edgeStacksService := edgestacks.NewService(dataStore)
|
||||||
|
|
||||||
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.TLSCert, *flags.TLSKey, fileService, dataStore, shutdownTrigger)
|
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("")
|
log.Fatal().Err(err).Msg("")
|
||||||
}
|
}
|
||||||
@@ -419,71 +444,77 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
log.Fatal().Err(err).Msg("failed to get SSL settings")
|
log.Fatal().Err(err).Msg("failed to get SSL settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := initKeyPair(fileService, signatureService); err != nil {
|
err = initKeyPair(fileService, digitalSignatureService)
|
||||||
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed initializing key pair")
|
log.Fatal().Err(err).Msg("failed initializing key pair")
|
||||||
}
|
}
|
||||||
|
|
||||||
reverseTunnelService := chisel.NewService(dataStore, shutdownCtx, fileService)
|
reverseTunnelService := chisel.NewService(dataStore, shutdownCtx, fileService)
|
||||||
|
|
||||||
dockerClientFactory := dockerclient.NewClientFactory(signatureService, reverseTunnelService)
|
dockerClientFactory := initDockerClientFactory(digitalSignatureService, reverseTunnelService)
|
||||||
|
kubernetesClientFactory, err := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, dataStore, instanceID, *flags.AddrHTTPS, settings.UserSessionTimeout)
|
||||||
kubernetesClientFactory, err := kubecli.NewClientFactory(signatureService, reverseTunnelService, dataStore, instanceID, *flags.AddrHTTPS, settings.UserSessionTimeout)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed initializing Kubernetes Client Factory service")
|
|
||||||
}
|
|
||||||
|
|
||||||
authorizationService := authorization.NewService(dataStore)
|
authorizationService := authorization.NewService(dataStore)
|
||||||
authorizationService.K8sClientFactory = kubernetesClientFactory
|
authorizationService.K8sClientFactory = kubernetesClientFactory
|
||||||
|
|
||||||
kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager()
|
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory, authorizationService, shutdownCtx)
|
||||||
|
|
||||||
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService(*flags.BaseURL, *flags.AddrHTTPS, sslSettings.CertPath)
|
|
||||||
|
|
||||||
proxyManager := proxy.NewManager(kubernetesClientFactory)
|
|
||||||
|
|
||||||
reverseTunnelService.ProxyManager = proxyManager
|
|
||||||
|
|
||||||
dockerConfigPath := fileService.GetDockerConfigPath()
|
|
||||||
|
|
||||||
composeDeployer := compose.NewComposeDeployer()
|
|
||||||
|
|
||||||
composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager, dataStore)
|
|
||||||
|
|
||||||
swarmStackManager, err := exec.NewSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
|
|
||||||
}
|
|
||||||
|
|
||||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
|
|
||||||
|
|
||||||
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory)
|
|
||||||
pendingActionsService.RegisterHandler(actions.CleanNAPWithOverridePolicies, handlers.NewHandlerCleanNAPWithOverridePolicies(authorizationService, dataStore))
|
|
||||||
pendingActionsService.RegisterHandler(actions.DeletePortainerK8sRegistrySecrets, handlers.NewHandlerDeleteRegistrySecrets(authorizationService, dataStore, kubernetesClientFactory))
|
|
||||||
pendingActionsService.RegisterHandler(actions.PostInitMigrateEnvironment, handlers.NewHandlerPostInitMigrateEnvironment(authorizationService, dataStore, kubernetesClientFactory, dockerClientFactory, *flags.Assets, kubernetesDeployer))
|
|
||||||
|
|
||||||
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx, pendingActionsService)
|
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx, pendingActionsService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed initializing snapshot service")
|
log.Fatal().Err(err).Msg("failed initializing snapshot service")
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotService.Start()
|
snapshotService.Start()
|
||||||
|
|
||||||
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService, jwtService)
|
kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager()
|
||||||
|
|
||||||
helmPackageManager, err := initHelmPackageManager()
|
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService(*flags.BaseURL, *flags.AddrHTTPS, sslSettings.CertPath)
|
||||||
|
|
||||||
|
proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService)
|
||||||
|
|
||||||
|
reverseTunnelService.ProxyManager = proxyManager
|
||||||
|
|
||||||
|
dockerConfigPath := fileService.GetDockerConfigPath()
|
||||||
|
|
||||||
|
composeDeployer, err := compose.NewComposeDeployer(*flags.Assets, dockerConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed initializing compose deployer")
|
||||||
|
}
|
||||||
|
|
||||||
|
composeStackManager := initComposeStackManager(composeDeployer, proxyManager)
|
||||||
|
|
||||||
|
swarmStackManager, err := initSwarmStackManager(*flags.Assets, dockerConfigPath, digitalSignatureService, fileService, reverseTunnelService, dataStore)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
|
||||||
|
}
|
||||||
|
|
||||||
|
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, proxyManager, *flags.Assets)
|
||||||
|
|
||||||
|
helmPackageManager, err := initHelmPackageManager(*flags.Assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed initializing helm package manager")
|
log.Fatal().Err(err).Msg("failed initializing helm package manager")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = edge.LoadEdgeJobs(dataStore, reverseTunnelService)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed loading edge jobs from database")
|
||||||
|
}
|
||||||
|
|
||||||
applicationStatus := initStatus(instanceID)
|
applicationStatus := initStatus(instanceID)
|
||||||
|
|
||||||
|
demoService := demo.NewService()
|
||||||
|
if *flags.DemoEnvironment {
|
||||||
|
err := demoService.Init(dataStore, cryptoService)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed initializing demo environment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// channel to control when the admin user is created
|
// channel to control when the admin user is created
|
||||||
adminCreationDone := make(chan struct{}, 1)
|
adminCreationDone := make(chan struct{}, 1)
|
||||||
|
|
||||||
go endpointutils.InitEndpoint(shutdownCtx, adminCreationDone, flags, dataStore, snapshotService)
|
go endpointutils.InitEndpoint(shutdownCtx, adminCreationDone, flags, dataStore, snapshotService)
|
||||||
|
|
||||||
adminPasswordHash := ""
|
adminPasswordHash := ""
|
||||||
|
|
||||||
if *flags.AdminPasswordFile != "" {
|
if *flags.AdminPasswordFile != "" {
|
||||||
content, err := fileService.GetFileContent(*flags.AdminPasswordFile, "")
|
content, err := fileService.GetFileContent(*flags.AdminPasswordFile, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -506,14 +537,14 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
|
|
||||||
if len(users) == 0 {
|
if len(users) == 0 {
|
||||||
log.Info().Msg("created admin user with the given password.")
|
log.Info().Msg("created admin user with the given password.")
|
||||||
|
|
||||||
user := &portainer.User{
|
user := &portainer.User{
|
||||||
Username: "admin",
|
Username: "admin",
|
||||||
Role: portainer.AdministratorRole,
|
Role: portainer.AdministratorRole,
|
||||||
Password: adminPasswordHash,
|
Password: adminPasswordHash,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := dataStore.User().Create(user); err != nil {
|
err := dataStore.User().Create(user)
|
||||||
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed creating admin user")
|
log.Fatal().Err(err).Msg("failed creating admin user")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,35 +555,21 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService); err != nil {
|
err = reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService)
|
||||||
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed starting tunnel server")
|
log.Fatal().Err(err).Msg("failed starting tunnel server")
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduler := scheduler.NewScheduler(shutdownCtx)
|
scheduler := scheduler.NewScheduler(shutdownCtx)
|
||||||
stackDeployer := deployments.NewStackDeployer(swarmStackManager, composeStackManager, kubernetesDeployer, dockerClientFactory, dataStore)
|
stackDeployer := deployments.NewStackDeployer(swarmStackManager, composeStackManager, kubernetesDeployer, dockerClientFactory, dataStore)
|
||||||
if err := deployments.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService); err != nil {
|
deployments.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService)
|
||||||
log.Fatal().Err(err).Msg("failed to start stack scheduler")
|
|
||||||
}
|
|
||||||
|
|
||||||
sslDBSettings, err := dataStore.SSLSettings().Settings()
|
sslDBSettings, err := dataStore.SSLSettings().Settings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Msg("failed to fetch SSL settings from DB")
|
log.Fatal().Msg("failed to fetch SSL settings from DB")
|
||||||
}
|
}
|
||||||
|
|
||||||
platformService, err := platform.NewService(dataStore)
|
upgradeService, err := upgrade.NewService(*flags.Assets, composeDeployer, kubernetesClientFactory)
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed initializing platform service")
|
|
||||||
}
|
|
||||||
|
|
||||||
upgradeService, err := upgrade.NewService(
|
|
||||||
*flags.Assets,
|
|
||||||
kubernetesClientFactory,
|
|
||||||
dockerClientFactory,
|
|
||||||
composeStackManager,
|
|
||||||
dataStore,
|
|
||||||
fileService,
|
|
||||||
stackDeployer,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("failed initializing upgrade service")
|
log.Fatal().Err(err).Msg("failed initializing upgrade service")
|
||||||
}
|
}
|
||||||
@@ -561,12 +578,10 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
// but some more complex migrations require access to a kubernetes or docker
|
// but some more complex migrations require access to a kubernetes or docker
|
||||||
// client. Therefore we run a separate migration process just before
|
// client. Therefore we run a separate migration process just before
|
||||||
// starting the server.
|
// starting the server.
|
||||||
postInitMigrator := postinit.NewPostInitMigrator(
|
postInitMigrator := datastore.NewPostInitMigrator(
|
||||||
kubernetesClientFactory,
|
kubernetesClientFactory,
|
||||||
dockerClientFactory,
|
dockerClientFactory,
|
||||||
dataStore,
|
dataStore,
|
||||||
*flags.Assets,
|
|
||||||
kubernetesDeployer,
|
|
||||||
)
|
)
|
||||||
if err := postInitMigrator.PostInitMigrate(); err != nil {
|
if err := postInitMigrator.PostInitMigrate(); err != nil {
|
||||||
log.Fatal().Err(err).Msg("failure during post init migrations")
|
log.Fatal().Err(err).Msg("failure during post init migrations")
|
||||||
@@ -578,7 +593,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
Status: applicationStatus,
|
Status: applicationStatus,
|
||||||
BindAddress: *flags.Addr,
|
BindAddress: *flags.Addr,
|
||||||
BindAddressHTTPS: *flags.AddrHTTPS,
|
BindAddressHTTPS: *flags.AddrHTTPS,
|
||||||
CSP: *flags.CSP,
|
|
||||||
HTTPEnabled: sslDBSettings.HTTPEnabled,
|
HTTPEnabled: sslDBSettings.HTTPEnabled,
|
||||||
AssetsPath: *flags.Assets,
|
AssetsPath: *flags.Assets,
|
||||||
DataStore: dataStore,
|
DataStore: dataStore,
|
||||||
@@ -598,7 +612,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
ProxyManager: proxyManager,
|
ProxyManager: proxyManager,
|
||||||
KubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
KubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||||
KubeClusterAccessService: kubeClusterAccessService,
|
KubeClusterAccessService: kubeClusterAccessService,
|
||||||
SignatureService: signatureService,
|
SignatureService: digitalSignatureService,
|
||||||
SnapshotService: snapshotService,
|
SnapshotService: snapshotService,
|
||||||
SSLService: sslService,
|
SSLService: sslService,
|
||||||
DockerClientFactory: dockerClientFactory,
|
DockerClientFactory: dockerClientFactory,
|
||||||
@@ -607,39 +621,35 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
ShutdownCtx: shutdownCtx,
|
ShutdownCtx: shutdownCtx,
|
||||||
ShutdownTrigger: shutdownTrigger,
|
ShutdownTrigger: shutdownTrigger,
|
||||||
StackDeployer: stackDeployer,
|
StackDeployer: stackDeployer,
|
||||||
|
DemoService: demoService,
|
||||||
UpgradeService: upgradeService,
|
UpgradeService: upgradeService,
|
||||||
AdminCreationDone: adminCreationDone,
|
AdminCreationDone: adminCreationDone,
|
||||||
PendingActionsService: pendingActionsService,
|
PendingActionsService: pendingActionsService,
|
||||||
PlatformService: platformService,
|
|
||||||
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
|
||||||
TrustedOrigins: trustedOrigins,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logs.ConfigureLogger()
|
configureLogger()
|
||||||
logs.SetLoggingMode("PRETTY")
|
setLoggingMode("PRETTY")
|
||||||
|
|
||||||
flags := initCLI()
|
flags := initCLI()
|
||||||
|
|
||||||
logs.SetLoggingLevel(*flags.LogLevel)
|
setLoggingLevel(*flags.LogLevel)
|
||||||
logs.SetLoggingMode(*flags.LogMode)
|
setLoggingMode(*flags.LogMode)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
server := buildServer(flags)
|
server := buildServer(flags)
|
||||||
|
|
||||||
log.Info().
|
log.Info().
|
||||||
Str("version", portainer.APIVersion).
|
Str("version", portainer.APIVersion).
|
||||||
Str("build_number", build.BuildNumber).
|
Str("build_number", build.BuildNumber).
|
||||||
Str("image_tag", build.ImageTag).
|
Str("image_tag", build.ImageTag).
|
||||||
Str("nodejs_version", build.NodejsVersion).
|
Str("nodejs_version", build.NodejsVersion).
|
||||||
Str("pnpm_version", build.PnpmVersion).
|
Str("yarn_version", build.YarnVersion).
|
||||||
Str("webpack_version", build.WebpackVersion).
|
Str("webpack_version", build.WebpackVersion).
|
||||||
Str("go_version", build.GoVersion).
|
Str("go_version", build.GoVersion).
|
||||||
Msg("starting Portainer")
|
Msg("starting Portainer")
|
||||||
|
|
||||||
err := server.Start()
|
err := server.Start()
|
||||||
|
|
||||||
log.Info().Err(err).Msg("HTTP server exited")
|
log.Info().Err(err).Msg("HTTP server exited")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
const secretFileName = "secret.txt"
|
|
||||||
|
|
||||||
func createPasswordFile(t *testing.T, secretPath, password string) string {
|
|
||||||
err := os.WriteFile(secretPath, []byte(password), 0600)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return secretPath
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadEncryptionSecretKey(t *testing.T) {
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
secretPath := path.Join(tempDir, secretFileName)
|
|
||||||
|
|
||||||
// first pointing to file that does not exist, gives nil hash (no encryption)
|
|
||||||
encryptionKey := loadEncryptionSecretKey(secretPath)
|
|
||||||
require.Nil(t, encryptionKey)
|
|
||||||
|
|
||||||
// point to a directory instead of a file
|
|
||||||
encryptionKey = loadEncryptionSecretKey(tempDir)
|
|
||||||
require.Nil(t, encryptionKey)
|
|
||||||
|
|
||||||
password := "portainer@1234"
|
|
||||||
createPasswordFile(t, secretPath, password)
|
|
||||||
|
|
||||||
encryptionKey = loadEncryptionSecretKey(secretPath)
|
|
||||||
require.NotNil(t, encryptionKey)
|
|
||||||
// should be 32 bytes for aes256 encryption
|
|
||||||
require.Len(t, encryptionKey, 32)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDBSecretPath(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
keyFilenameFlag string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{keyFilenameFlag: "secret.txt", expected: "/run/secrets/secret.txt"},
|
|
||||||
{keyFilenameFlag: "/tmp/secret.txt", expected: "/tmp/secret.txt"},
|
|
||||||
{keyFilenameFlag: "/run/secrets/secret.txt", expected: "/run/secrets/secret.txt"},
|
|
||||||
{keyFilenameFlag: "./secret.txt", expected: "/run/secrets/secret.txt"},
|
|
||||||
{keyFilenameFlag: "../secret.txt", expected: "/run/secret.txt"},
|
|
||||||
{keyFilenameFlag: "foo/bar/secret.txt", expected: "/run/secrets/foo/bar/secret.txt"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
assert.Equal(t, test.expected, dbSecretPath(test.keyFilenameFlag))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
// Package concurrent provides utilities for running multiple functions concurrently in Go.
|
|
||||||
// For example, many kubernetes calls can take a while to fulfill. Oftentimes in Portainer
|
|
||||||
// we need to get a list of objects from multiple kubernetes REST APIs. We can often call these
|
|
||||||
// apis concurrently to speed up the response time.
|
|
||||||
// This package provides a clean way to do just that.
|
|
||||||
//
|
|
||||||
// Examples:
|
|
||||||
// The ConfigMaps and Secrets function converted using concurrent.Run.
|
|
||||||
/*
|
|
||||||
|
|
||||||
// GetConfigMapsAndSecrets gets all the ConfigMaps AND all the Secrets for a
|
|
||||||
// given namespace in a k8s endpoint. The result is a list of both config maps
|
|
||||||
// and secrets. The IsSecret boolean property indicates if a given struct is a
|
|
||||||
// secret or configmap.
|
|
||||||
func (kcl *KubeClient) GetConfigMapsAndSecrets(namespace string) ([]models.K8sConfigMapOrSecret, error) {
|
|
||||||
|
|
||||||
// use closures to capture the current kube client and namespace by declaring wrapper functions
|
|
||||||
// that match the interface signature for concurrent.Func
|
|
||||||
|
|
||||||
listConfigMaps := func(ctx context.Context) (any, error) {
|
|
||||||
return kcl.cli.CoreV1().ConfigMaps(namespace).List(context.Background(), meta.ListOptions{})
|
|
||||||
}
|
|
||||||
|
|
||||||
listSecrets := func(ctx context.Context) (any, error) {
|
|
||||||
return kcl.cli.CoreV1().Secrets(namespace).List(context.Background(), meta.ListOptions{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// run the functions concurrently and wait for results. We can also pass in a context to cancel.
|
|
||||||
// e.g. Deadline timer.
|
|
||||||
results, err := concurrent.Run(context.TODO(), listConfigMaps, listSecrets)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var configMapList *core.ConfigMapList
|
|
||||||
var secretList *core.SecretList
|
|
||||||
for _, r := range results {
|
|
||||||
switch v := r.Result.(type) {
|
|
||||||
case *core.ConfigMapList:
|
|
||||||
configMapList = v
|
|
||||||
case *core.SecretList:
|
|
||||||
secretList = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Applications
|
|
||||||
var combined []models.K8sConfigMapOrSecret
|
|
||||||
for _, m := range configMapList.Items {
|
|
||||||
var cm models.K8sConfigMapOrSecret
|
|
||||||
cm.UID = string(m.UID)
|
|
||||||
cm.Name = m.Name
|
|
||||||
cm.Namespace = m.Namespace
|
|
||||||
cm.Annotations = m.Annotations
|
|
||||||
cm.Data = m.Data
|
|
||||||
cm.CreationDate = m.CreationTimestamp.Time.UTC().Format(time.RFC3339)
|
|
||||||
combined = append(combined, cm)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, s := range secretList.Items {
|
|
||||||
var secret models.K8sConfigMapOrSecret
|
|
||||||
secret.UID = string(s.UID)
|
|
||||||
secret.Name = s.Name
|
|
||||||
secret.Namespace = s.Namespace
|
|
||||||
secret.Annotations = s.Annotations
|
|
||||||
secret.Data = msbToMss(s.Data)
|
|
||||||
secret.CreationDate = s.CreationTimestamp.Time.UTC().Format(time.RFC3339)
|
|
||||||
secret.IsSecret = true
|
|
||||||
secret.SecretType = string(s.Type)
|
|
||||||
combined = append(combined, secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
return combined, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
package concurrent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Result contains the result and any error returned from running a client task function
|
|
||||||
type Result struct {
|
|
||||||
Result any // the result of running the task function
|
|
||||||
Err error // any error that occurred while running the task function
|
|
||||||
}
|
|
||||||
|
|
||||||
// Func is a function returns a result or error
|
|
||||||
type Func func(ctx context.Context) (any, error)
|
|
||||||
|
|
||||||
// Run runs a list of functions returns the results
|
|
||||||
func Run(ctx context.Context, maxConcurrency int, tasks ...Func) ([]Result, error) {
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
resultsChan := make(chan Result, len(tasks))
|
|
||||||
taskChan := make(chan Func, len(tasks))
|
|
||||||
|
|
||||||
localCtx, cancelCtx := context.WithCancel(ctx)
|
|
||||||
defer cancelCtx()
|
|
||||||
|
|
||||||
runTask := func() {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
for fn := range taskChan {
|
|
||||||
result, err := fn(localCtx)
|
|
||||||
resultsChan <- Result{Result: result, Err: err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set maxConcurrency to the number of tasks if zero or negative
|
|
||||||
if maxConcurrency <= 0 {
|
|
||||||
maxConcurrency = len(tasks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start worker goroutines
|
|
||||||
for range maxConcurrency {
|
|
||||||
wg.Add(1)
|
|
||||||
go runTask()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tasks to the task channel
|
|
||||||
for _, fn := range tasks {
|
|
||||||
taskChan <- fn
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the task channel to signal workers to stop when all tasks are done
|
|
||||||
close(taskChan)
|
|
||||||
|
|
||||||
// Wait for all workers to complete
|
|
||||||
wg.Wait()
|
|
||||||
close(resultsChan)
|
|
||||||
|
|
||||||
// Collect the results and cancel on error
|
|
||||||
results := make([]Result, 0, len(tasks))
|
|
||||||
for r := range resultsChan {
|
|
||||||
if r.Err != nil {
|
|
||||||
cancelCtx()
|
|
||||||
|
|
||||||
return nil, r.Err
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
@@ -5,23 +5,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ReadTransaction interface {
|
type ReadTransaction interface {
|
||||||
GetObject(bucketName string, key []byte, object any) error
|
GetObject(bucketName string, key []byte, object interface{}) error
|
||||||
GetRawBytes(bucketName string, key []byte) ([]byte, error)
|
GetAll(bucketName string, obj interface{}, append func(o interface{}) (interface{}, error)) error
|
||||||
GetAll(bucketName string, obj any, append func(o any) (any, error)) error
|
GetAllWithJsoniter(bucketName string, obj interface{}, append func(o interface{}) (interface{}, error)) error
|
||||||
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, append func(o any) (any, error)) error
|
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj interface{}, append func(o interface{}) (interface{}, error)) error
|
||||||
KeyExists(bucketName string, key []byte) (bool, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Transaction interface {
|
type Transaction interface {
|
||||||
ReadTransaction
|
ReadTransaction
|
||||||
|
|
||||||
SetServiceName(bucketName string) error
|
SetServiceName(bucketName string) error
|
||||||
UpdateObject(bucketName string, key []byte, object any) error
|
UpdateObject(bucketName string, key []byte, object interface{}) error
|
||||||
DeleteObject(bucketName string, key []byte) error
|
DeleteObject(bucketName string, key []byte) error
|
||||||
CreateObject(bucketName string, fn func(uint64) (int, any)) error
|
CreateObject(bucketName string, fn func(uint64) (int, interface{})) error
|
||||||
CreateObjectWithId(bucketName string, id int, obj any) error
|
CreateObjectWithId(bucketName string, id int, obj interface{}) error
|
||||||
CreateObjectWithStringId(bucketName string, id []byte, obj any) error
|
CreateObjectWithStringId(bucketName string, id []byte, obj interface{}) error
|
||||||
DeleteAllObjects(bucketName string, obj any, matching func(o any) (id int, ok bool)) error
|
DeleteAllObjects(bucketName string, obj interface{}, matching func(o interface{}) (id int, ok bool)) error
|
||||||
GetNextIdentifier(bucketName string) int
|
GetNextIdentifier(bucketName string) int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,14 +41,13 @@ type Connection interface {
|
|||||||
GetDatabaseFileName() string
|
GetDatabaseFileName() string
|
||||||
GetDatabaseFilePath() string
|
GetDatabaseFilePath() string
|
||||||
GetStorePath() string
|
GetStorePath() string
|
||||||
GetDatabaseFileSize() (int64, error)
|
|
||||||
|
|
||||||
IsEncryptedStore() bool
|
IsEncryptedStore() bool
|
||||||
NeedsEncryptionMigration() (bool, error)
|
NeedsEncryptionMigration() (bool, error)
|
||||||
SetEncrypted(encrypted bool)
|
SetEncrypted(encrypted bool)
|
||||||
|
|
||||||
BackupMetadata() (map[string]any, error)
|
BackupMetadata() (map[string]interface{}, error)
|
||||||
RestoreMetadata(s map[string]any) error
|
RestoreMetadata(s map[string]interface{}) error
|
||||||
|
|
||||||
UpdateObjectFunc(bucketName string, key []byte, object any, updateFn func()) error
|
UpdateObjectFunc(bucketName string, key []byte, object any, updateFn func()) error
|
||||||
ConvertToKey(v int) []byte
|
ConvertToKey(v int) []byte
|
||||||
|
|||||||
@@ -1,382 +1,55 @@
|
|||||||
package crypto
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"crypto/aes"
|
"crypto/aes"
|
||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
"crypto/pbkdf2"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/portainer/portainer/pkg/fips"
|
"golang.org/x/crypto/scrypt"
|
||||||
|
|
||||||
// Not allowed in FIPS mode
|
|
||||||
"golang.org/x/crypto/argon2" //nolint:depguard
|
|
||||||
"golang.org/x/crypto/scrypt" //nolint:depguard
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// NOTE: has to go with what is considered to be a simplistic in that it omits any
|
||||||
// AES GCM settings
|
// authentication of the encrypted data.
|
||||||
aesGcmHeader = "AES256-GCM" // The encrypted file header
|
// Person with better knowledge is welcomed to improve it.
|
||||||
aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm
|
// sourced from https://golang.org/src/crypto/cipher/example_test.go
|
||||||
|
|
||||||
aesGcmFIPSHeader = "FIPS-AES256-GCM"
|
var emptySalt []byte = make([]byte, 0)
|
||||||
aesGcmFIPSBlockSize = 16 * 1024 * 1024 // 16MB block for aes gcm
|
|
||||||
|
|
||||||
// Argon2 settings
|
// AesEncrypt reads from input, encrypts with AES-256 and writes to the output.
|
||||||
// Recommended settings lower memory hardware according to current OWASP recommendations
|
|
||||||
// Considering some people run portainer on a NAS I think it's prudent not to assume we're on server grade hardware
|
|
||||||
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
|
|
||||||
argon2MemoryCost = 12 * 1024
|
|
||||||
argon2TimeCost = 3
|
|
||||||
argon2Threads = 1
|
|
||||||
argon2KeyLength = 32
|
|
||||||
|
|
||||||
pbkdf2Iterations = 600_000 // use recommended iterations from https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 a little overkill for this use
|
|
||||||
pbkdf2SaltLength = 32
|
|
||||||
)
|
|
||||||
|
|
||||||
// AesEncrypt reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key
|
|
||||||
func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
|
|
||||||
if fips.FIPSMode() {
|
|
||||||
if err := aesEncryptGCMFIPS(input, output, passphrase); err != nil {
|
|
||||||
return fmt.Errorf("error encrypting file: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := aesEncryptGCM(input, output, passphrase); err != nil {
|
|
||||||
return fmt.Errorf("error encrypting file: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from
|
|
||||||
func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
|
|
||||||
return aesDecrypt(input, passphrase, fips.FIPSMode())
|
|
||||||
}
|
|
||||||
|
|
||||||
func aesDecrypt(input io.Reader, passphrase []byte, fipsMode bool) (io.Reader, error) {
|
|
||||||
// Read file header to determine how it was encrypted
|
|
||||||
inputReader := bufio.NewReader(input)
|
|
||||||
header, err := inputReader.Peek(len(aesGcmFIPSHeader))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error reading encrypted backup file header: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(string(header), aesGcmFIPSHeader) {
|
|
||||||
if !fipsMode {
|
|
||||||
return nil, errors.New("fips encrypted file detected but fips mode is not enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, err := aesDecryptGCMFIPS(inputReader, passphrase)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error decrypting file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return reader, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(string(header), aesGcmHeader) {
|
|
||||||
if fipsMode {
|
|
||||||
return nil, errors.New("fips mode is enabled but non-fips encrypted file detected")
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, err := aesDecryptGCM(inputReader, passphrase)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error decrypting file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return reader, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the previous decryption routine which has no header (to support older archives)
|
|
||||||
reader, err := aesDecryptOFB(inputReader, passphrase)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error decrypting legacy file backup: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return reader, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// aesEncryptGCM reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key.
|
|
||||||
func aesEncryptGCM(input io.Reader, output io.Writer, passphrase []byte) error {
|
|
||||||
// Derive key using argon2 with a random salt
|
|
||||||
salt := make([]byte, 16) // 16 bytes salt
|
|
||||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
|
|
||||||
block, err := aes.NewCipher(key)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
aesgcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate nonce
|
|
||||||
nonce, err := NewRandomNonce(aesgcm.NonceSize())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// write the header
|
|
||||||
if _, err := output.Write([]byte(aesGcmHeader)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write nonce and salt to the output file
|
|
||||||
if _, err := output.Write(salt); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := output.Write(nonce.Value()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buffer for reading plaintext blocks
|
|
||||||
buf := make([]byte, aesGcmBlockSize) // Adjust buffer size as needed
|
|
||||||
ciphertext := make([]byte, len(buf)+aesgcm.Overhead())
|
|
||||||
|
|
||||||
// Encrypt plaintext in blocks
|
|
||||||
for {
|
|
||||||
n, err := io.ReadFull(input, buf)
|
|
||||||
if n == 0 {
|
|
||||||
break // end of plaintext input
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seal encrypts the plaintext using the nonce returning the updated slice.
|
|
||||||
ciphertext = aesgcm.Seal(ciphertext[:0], nonce.Value(), buf[:n], nil)
|
|
||||||
|
|
||||||
if _, err := output.Write(ciphertext); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := nonce.Increment(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// aesDecryptGCM reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from.
|
|
||||||
func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
|
|
||||||
// Reader & verify header
|
|
||||||
header := make([]byte, len(aesGcmHeader))
|
|
||||||
if _, err := io.ReadFull(input, header); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(header) != aesGcmHeader {
|
|
||||||
return nil, errors.New("invalid header")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read salt
|
|
||||||
salt := make([]byte, 16) // Salt size
|
|
||||||
if _, err := io.ReadFull(input, salt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
|
|
||||||
|
|
||||||
// Initialize AES cipher block
|
|
||||||
block, err := aes.NewCipher(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create GCM mode with the cipher block
|
|
||||||
aesgcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read nonce from the input reader
|
|
||||||
nonce := NewNonce(aesgcm.NonceSize())
|
|
||||||
if err := nonce.Read(input); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize a buffer to store decrypted data
|
|
||||||
buf := bytes.Buffer{}
|
|
||||||
plaintext := make([]byte, aesGcmBlockSize)
|
|
||||||
|
|
||||||
// Decrypt the ciphertext in blocks
|
|
||||||
for {
|
|
||||||
// Read a block of ciphertext from the input reader
|
|
||||||
ciphertextBlock := make([]byte, aesGcmBlockSize+aesgcm.Overhead()) // Adjust block size as needed
|
|
||||||
n, err := io.ReadFull(input, ciphertextBlock)
|
|
||||||
if n == 0 {
|
|
||||||
break // end of ciphertext
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt the block of ciphertext
|
|
||||||
plaintext, err = aesgcm.Open(plaintext[:0], nonce.Value(), ciphertextBlock[:n], nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := buf.Write(plaintext); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := nonce.Increment(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &buf, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// aesEncryptGCMFIPS reads from input, encrypts with AES-256 in a fips compliant
|
|
||||||
// way and writes to output. passphrase is used to generate an encryption key.
|
|
||||||
func aesEncryptGCMFIPS(input io.Reader, output io.Writer, passphrase []byte) error {
|
|
||||||
salt := make([]byte, pbkdf2SaltLength)
|
|
||||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
key, err := pbkdf2.Key(sha256.New, string(passphrase), salt, pbkdf2Iterations, 32)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error deriving key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
block, err := aes.NewCipher(key)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// write the header
|
|
||||||
if _, err := output.Write([]byte(aesGcmFIPSHeader)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write nonce and salt to the output file
|
|
||||||
if _, err := output.Write(salt); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buffer for reading plaintext blocks
|
|
||||||
buf := make([]byte, aesGcmFIPSBlockSize)
|
|
||||||
|
|
||||||
// Encrypt plaintext in blocks
|
|
||||||
for {
|
|
||||||
// new random nonce for each block
|
|
||||||
aesgcm, err := cipher.NewGCMWithRandomNonce(block)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error creating gcm: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := io.ReadFull(input, buf)
|
|
||||||
if n == 0 {
|
|
||||||
break // end of plaintext input
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seal encrypts the plaintext
|
|
||||||
ciphertext := aesgcm.Seal(nil, nil, buf[:n], nil)
|
|
||||||
|
|
||||||
if _, err := output.Write(ciphertext); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// aesDecryptGCMFIPS reads from input, decrypts with AES-256 in a fips compliant
|
|
||||||
// way and returns the reader to read the decrypted content from.
|
|
||||||
func aesDecryptGCMFIPS(input io.Reader, passphrase []byte) (io.Reader, error) {
|
|
||||||
// Reader & verify header
|
|
||||||
header := make([]byte, len(aesGcmFIPSHeader))
|
|
||||||
if _, err := io.ReadFull(input, header); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(header) != aesGcmFIPSHeader {
|
|
||||||
return nil, errors.New("invalid header")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read salt
|
|
||||||
salt := make([]byte, pbkdf2SaltLength)
|
|
||||||
if _, err := io.ReadFull(input, salt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
key, err := pbkdf2.Key(sha256.New, string(passphrase), salt, pbkdf2Iterations, 32)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error deriving key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize AES cipher block
|
|
||||||
block, err := aes.NewCipher(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize a buffer to store decrypted data
|
|
||||||
buf := bytes.Buffer{}
|
|
||||||
|
|
||||||
// Decrypt the ciphertext in blocks
|
|
||||||
for {
|
|
||||||
// Create GCM mode with the cipher block
|
|
||||||
aesgcm, err := cipher.NewGCMWithRandomNonce(block)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read a block of ciphertext from the input reader
|
|
||||||
ciphertextBlock := make([]byte, aesGcmFIPSBlockSize+aesgcm.Overhead())
|
|
||||||
n, err := io.ReadFull(input, ciphertextBlock)
|
|
||||||
if n == 0 {
|
|
||||||
break // end of ciphertext
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt the block of ciphertext
|
|
||||||
plaintext, err := aesgcm.Open(nil, nil, ciphertextBlock[:n], nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := buf.Write(plaintext); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &buf, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// aesDecryptOFB reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
|
|
||||||
// passphrase is used to generate an encryption key.
|
// passphrase is used to generate an encryption key.
|
||||||
// note: This function used to decrypt files that were encrypted without a header i.e. old archives
|
func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
|
||||||
func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
|
|
||||||
// making a 32 bytes key that would correspond to AES-256
|
// making a 32 bytes key that would correspond to AES-256
|
||||||
// don't necessarily need a salt, so just kept in empty
|
// don't necessarily need a salt, so just kept in empty
|
||||||
key, err := scrypt.Key(passphrase, nil, 32768, 8, 1, 32)
|
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the key is unique for each ciphertext, then it's ok to use a zero
|
||||||
|
// IV.
|
||||||
|
var iv [aes.BlockSize]byte
|
||||||
|
stream := cipher.NewOFB(block, iv[:])
|
||||||
|
|
||||||
|
writer := &cipher.StreamWriter{S: stream, W: output}
|
||||||
|
// Copy the input to the output, encrypting as we go.
|
||||||
|
if _, err := io.Copy(writer, input); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
|
||||||
|
// passphrase is used to generate an encryption key.
|
||||||
|
func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||||
|
// making a 32 bytes key that would correspond to AES-256
|
||||||
|
// don't necessarily need a salt, so just kept in empty
|
||||||
|
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -386,25 +59,12 @@ func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the key is unique for each ciphertext, then it's ok to use a zero IV.
|
// If the key is unique for each ciphertext, then it's ok to use a zero
|
||||||
|
// IV.
|
||||||
var iv [aes.BlockSize]byte
|
var iv [aes.BlockSize]byte
|
||||||
stream := cipher.NewOFB(block, iv[:])
|
stream := cipher.NewOFB(block, iv[:])
|
||||||
|
|
||||||
reader := &cipher.StreamReader{S: stream, R: input}
|
reader := &cipher.StreamReader{S: stream, R: input}
|
||||||
|
|
||||||
return reader, nil
|
return reader, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasEncryptedHeader checks if the data has an encrypted header, note that fips
|
|
||||||
// mode changes this behavior and so will only recognize data encrypted by the
|
|
||||||
// same mode (fips enabled or disabled)
|
|
||||||
func HasEncryptedHeader(data []byte) bool {
|
|
||||||
return hasEncryptedHeader(data, fips.FIPSMode())
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasEncryptedHeader(data []byte, fipsMode bool) bool {
|
|
||||||
if fipsMode {
|
|
||||||
return bytes.HasPrefix(data, []byte(aesGcmFIPSHeader))
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytes.HasPrefix(data, []byte(aesGcmHeader))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,44 +1,15 @@
|
|||||||
package crypto
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/logs"
|
|
||||||
"github.com/portainer/portainer/pkg/fips"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"golang.org/x/crypto/scrypt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
|
||||||
fips.InitFIPS(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
||||||
|
|
||||||
func randBytes(n int) []byte {
|
|
||||||
b := make([]byte, n)
|
|
||||||
for i := range b {
|
|
||||||
b[i] = letterBytes[rand.Intn(len(letterBytes))]
|
|
||||||
}
|
|
||||||
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
type encryptFunc func(input io.Reader, output io.Writer, passphrase []byte) error
|
|
||||||
type decryptFunc func(input io.Reader, passphrase []byte) (io.Reader, error)
|
|
||||||
|
|
||||||
func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
||||||
const passphrase = "passphrase"
|
|
||||||
|
|
||||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc, decryptShouldSucceed bool) {
|
|
||||||
tmpdir := t.TempDir()
|
tmpdir := t.TempDir()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -47,217 +18,37 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
|||||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||||
)
|
)
|
||||||
|
|
||||||
content := randBytes(1024*1024*100 + 523)
|
content := []byte("content")
|
||||||
err := os.WriteFile(originFilePath, content, 0600)
|
os.WriteFile(originFilePath, content, 0600)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
originFile, _ := os.Open(originFilePath)
|
originFile, _ := os.Open(originFilePath)
|
||||||
defer logs.CloseAndLogErr(originFile)
|
defer originFile.Close()
|
||||||
|
|
||||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||||
|
defer encryptedFileWriter.Close()
|
||||||
|
|
||||||
err = encrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||||
require.NoError(t, err, "Failed to encrypt a file")
|
assert.Nil(t, err, "Failed to encrypt a file")
|
||||||
logs.CloseAndLogErr(encryptedFileWriter)
|
|
||||||
|
|
||||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||||
require.NoError(t, err, "Couldn't read encrypted file")
|
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||||
|
|
||||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||||
require.NoError(t, err)
|
defer encryptedFileReader.Close()
|
||||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
|
||||||
|
|
||||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||||
require.NoError(t, err)
|
defer decryptedFileWriter.Close()
|
||||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
|
||||||
|
|
||||||
decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
|
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("passphrase"))
|
||||||
if !decryptShouldSucceed {
|
assert.Nil(t, err, "Failed to decrypt file")
|
||||||
require.Error(t, err, "Failed to decrypt file as indicated by decryptShouldSucceed")
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err, "Failed to decrypt file indicated by decryptShouldSucceed")
|
|
||||||
|
|
||||||
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
io.Copy(decryptedFileWriter, decryptedReader)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("fips", func(t *testing.T) {
|
|
||||||
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS, true)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("non_fips", func(t *testing.T) {
|
|
||||||
testFunc(t, aesEncryptGCM, aesDecryptGCM, true)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("system_fips_mode_public_entry_points", func(t *testing.T) {
|
|
||||||
// use the init mode, public entry points
|
|
||||||
testFunc(t, AesEncrypt, AesDecrypt, true)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("fips_encrypted_file_header_fails_in_non_fips_mode", func(t *testing.T) {
|
|
||||||
// use aesDecrypt which checks the header, confirm that it fails
|
|
||||||
decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) {
|
|
||||||
return aesDecrypt(input, passphrase, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
testFunc(t, aesEncryptGCMFIPS, decrypt, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("non_fips_encrypted_file_header_fails_in_fips_mode", func(t *testing.T) {
|
|
||||||
// use aesDecrypt which checks the header, confirm that it fails
|
|
||||||
decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) {
|
|
||||||
return aesDecrypt(input, passphrase, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
testFunc(t, aesEncryptGCM, decrypt, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("fips_encrypted_file_fails_in_non_fips_mode", func(t *testing.T) {
|
|
||||||
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCM, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("non_fips_encrypted_file_with_fips_mode_should_fail", func(t *testing.T) {
|
|
||||||
testFunc(t, aesEncryptGCM, aesDecryptGCMFIPS, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("fips_with_base_aesDecrypt", func(t *testing.T) {
|
|
||||||
// maximize coverage, use the base aesDecrypt function with valid fips mode
|
|
||||||
decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) {
|
|
||||||
return aesDecrypt(input, passphrase, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
testFunc(t, aesEncryptGCMFIPS, decrypt, true)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("legacy", func(t *testing.T) {
|
|
||||||
testFunc(t, legacyAesEncrypt, aesDecryptOFB, true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
|
|
||||||
const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+"
|
|
||||||
|
|
||||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
|
||||||
tmpdir := t.TempDir()
|
|
||||||
|
|
||||||
var (
|
|
||||||
originFilePath = filepath.Join(tmpdir, "origin2")
|
|
||||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
|
|
||||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
|
|
||||||
)
|
|
||||||
|
|
||||||
content := randBytes(500)
|
|
||||||
|
|
||||||
err := os.WriteFile(originFilePath, content, 0600)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
originFile, err := os.Open(originFilePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer logs.CloseAndLogErr(originFile)
|
|
||||||
|
|
||||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
|
||||||
|
|
||||||
err = encrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
|
||||||
require.NoError(t, err, "Failed to encrypt a file")
|
|
||||||
logs.CloseAndLogErr(encryptedFileWriter)
|
|
||||||
|
|
||||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
|
||||||
require.NoError(t, err, "Couldn't read encrypted file")
|
|
||||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
|
||||||
|
|
||||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
|
||||||
|
|
||||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
|
||||||
|
|
||||||
decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
|
|
||||||
require.NoError(t, err, "Failed to decrypt file")
|
|
||||||
|
|
||||||
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("fips", func(t *testing.T) {
|
|
||||||
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("non_fips", func(t *testing.T) {
|
|
||||||
testFunc(t, aesEncryptGCM, aesDecryptGCM)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
|
|
||||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
|
||||||
tmpdir := t.TempDir()
|
|
||||||
|
|
||||||
var (
|
|
||||||
originFilePath = filepath.Join(tmpdir, "origin2")
|
|
||||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
|
|
||||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
|
|
||||||
)
|
|
||||||
|
|
||||||
content := randBytes(500)
|
|
||||||
err := os.WriteFile(originFilePath, content, 0600)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
originFile, err := os.Open(originFilePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer logs.CloseAndLogErr(originFile)
|
|
||||||
|
|
||||||
encryptedFileWriter, err := os.Create(encryptedFilePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
|
||||||
require.NoError(t, err, "Failed to encrypt a file")
|
|
||||||
logs.CloseAndLogErr(encryptedFileWriter)
|
|
||||||
|
|
||||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
|
||||||
require.NoError(t, err, "Couldn't read encrypted file")
|
|
||||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
|
||||||
|
|
||||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
|
||||||
|
|
||||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
|
||||||
|
|
||||||
decryptedReader, err := decrypt(encryptedFileReader, []byte("passphrase"))
|
|
||||||
require.NoError(t, err, "Failed to decrypt file")
|
|
||||||
|
|
||||||
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("fips", func(t *testing.T) {
|
|
||||||
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("non_fips", func(t *testing.T) {
|
|
||||||
testFunc(t, aesEncryptGCM, aesDecryptGCM)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
||||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
|
||||||
tmpdir := t.TempDir()
|
tmpdir := t.TempDir()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -266,55 +57,37 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
|||||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||||
)
|
)
|
||||||
|
|
||||||
content := randBytes(1024 * 50)
|
content := []byte("content")
|
||||||
err := os.WriteFile(originFilePath, content, 0600)
|
os.WriteFile(originFilePath, content, 0600)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
originFile, err := os.Open(originFilePath)
|
originFile, _ := os.Open(originFilePath)
|
||||||
require.NoError(t, err)
|
defer originFile.Close()
|
||||||
defer logs.CloseAndLogErr(originFile)
|
|
||||||
|
|
||||||
encryptedFileWriter, err := os.Create(encryptedFilePath)
|
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||||
require.NoError(t, err)
|
defer encryptedFileWriter.Close()
|
||||||
defer logs.CloseAndLogErr(encryptedFileWriter)
|
|
||||||
|
|
||||||
err = encrypt(originFile, encryptedFileWriter, []byte(""))
|
|
||||||
require.NoError(t, err, "Failed to encrypt a file")
|
|
||||||
|
|
||||||
|
err := AesEncrypt(originFile, encryptedFileWriter, []byte(""))
|
||||||
|
assert.Nil(t, err, "Failed to encrypt a file")
|
||||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||||
require.NoError(t, err, "Couldn't read encrypted file")
|
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||||
|
|
||||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||||
require.NoError(t, err)
|
defer encryptedFileReader.Close()
|
||||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
|
||||||
|
|
||||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||||
require.NoError(t, err)
|
defer decryptedFileWriter.Close()
|
||||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
|
||||||
|
|
||||||
decryptedReader, err := decrypt(encryptedFileReader, []byte(""))
|
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(""))
|
||||||
require.NoError(t, err, "Failed to decrypt file")
|
assert.Nil(t, err, "Failed to decrypt file")
|
||||||
|
|
||||||
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
io.Copy(decryptedFileWriter, decryptedReader)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("fips", func(t *testing.T) {
|
|
||||||
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("non_fips", func(t *testing.T) {
|
|
||||||
testFunc(t, aesEncryptGCM, aesDecryptGCM)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T) {
|
func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T) {
|
||||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
|
||||||
tmpdir := t.TempDir()
|
tmpdir := t.TempDir()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -323,122 +96,32 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
|
|||||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||||
)
|
)
|
||||||
|
|
||||||
content := randBytes(1034)
|
content := []byte("content")
|
||||||
err := os.WriteFile(originFilePath, content, 0600)
|
os.WriteFile(originFilePath, content, 0600)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
originFile, err := os.Open(originFilePath)
|
originFile, _ := os.Open(originFilePath)
|
||||||
require.NoError(t, err)
|
defer originFile.Close()
|
||||||
defer logs.CloseAndLogErr(originFile)
|
|
||||||
|
|
||||||
encryptedFileWriter, err := os.Create(encryptedFilePath)
|
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||||
require.NoError(t, err)
|
defer encryptedFileWriter.Close()
|
||||||
defer logs.CloseAndLogErr(encryptedFileWriter)
|
|
||||||
|
|
||||||
err = encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||||
require.NoError(t, err, "Failed to encrypt a file")
|
assert.Nil(t, err, "Failed to encrypt a file")
|
||||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||||
require.NoError(t, err, "Couldn't read encrypted file")
|
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||||
|
|
||||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||||
require.NoError(t, err)
|
defer encryptedFileReader.Close()
|
||||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
|
||||||
|
|
||||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||||
require.NoError(t, err)
|
defer decryptedFileWriter.Close()
|
||||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
|
||||||
|
|
||||||
_, err = decrypt(encryptedFileReader, []byte("garbage"))
|
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("garbage"))
|
||||||
require.Error(t, err, "Should not allow decrypt with wrong passphrase")
|
assert.Nil(t, err, "Should allow to decrypt with wrong passphrase")
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("fips", func(t *testing.T) {
|
io.Copy(decryptedFileWriter, decryptedReader)
|
||||||
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("non_fips", func(t *testing.T) {
|
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||||
testFunc(t, aesEncryptGCM, aesDecryptGCM)
|
assert.NotEqual(t, content, decryptedContent, "Original and decrypted content should NOT match")
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func legacyAesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
|
|
||||||
key, err := scrypt.Key(passphrase, nil, 32768, 8, 1, 32)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
block, err := aes.NewCipher(key)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var iv [aes.BlockSize]byte
|
|
||||||
stream := cipher.NewOFB(block, iv[:])
|
|
||||||
|
|
||||||
writer := &cipher.StreamWriter{S: stream, W: output}
|
|
||||||
if _, err := io.Copy(writer, input); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_hasEncryptedHeader(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
data []byte
|
|
||||||
fipsMode bool
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "non-FIPS mode with valid header",
|
|
||||||
data: []byte("AES256-GCM" + "some encrypted data"),
|
|
||||||
fipsMode: false,
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-FIPS mode with FIPS header",
|
|
||||||
data: []byte("FIPS-AES256-GCM" + "some encrypted data"),
|
|
||||||
fipsMode: false,
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "FIPS mode with valid header",
|
|
||||||
data: []byte("FIPS-AES256-GCM" + "some encrypted data"),
|
|
||||||
fipsMode: true,
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "FIPS mode with non-FIPS header",
|
|
||||||
data: []byte("AES256-GCM" + "some encrypted data"),
|
|
||||||
fipsMode: true,
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid header",
|
|
||||||
data: []byte("INVALID-HEADER" + "some data"),
|
|
||||||
fipsMode: false,
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty data",
|
|
||||||
data: []byte{},
|
|
||||||
fipsMode: false,
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nil data",
|
|
||||||
data: nil,
|
|
||||||
fipsMode: false,
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := hasEncryptedHeader(tt.data, tt.fipsMode)
|
|
||||||
assert.Equal(t, tt.want, got)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func (service *ECDSAService) CreateSignature(message string) (string, error) {
|
|||||||
message = service.secret
|
message = service.secret
|
||||||
}
|
}
|
||||||
|
|
||||||
hash := libcrypto.InsecureHashFromBytes([]byte(message))
|
hash := libcrypto.HashFromBytes([]byte(message))
|
||||||
|
|
||||||
r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash)
|
r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
package crypto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateSignature(t *testing.T) {
|
|
||||||
var s = NewECDSAService("secret")
|
|
||||||
|
|
||||||
privKey, pubKey, err := s.GenerateKeyPair()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotEmpty(t, privKey)
|
|
||||||
require.NotEmpty(t, pubKey)
|
|
||||||
|
|
||||||
m := "test message"
|
|
||||||
r, err := s.CreateSignature(m)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotEqual(t, r, m)
|
|
||||||
require.NotEmpty(t, r)
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,22 @@
|
|||||||
package crypto
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
// Not allowed in FIPS mode
|
"golang.org/x/crypto/bcrypt"
|
||||||
"golang.org/x/crypto/bcrypt" //nolint:depguard
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service represents a service for encrypting/hashing data.
|
// Service represents a service for encrypting/hashing data.
|
||||||
type Service struct{}
|
type Service struct{}
|
||||||
|
|
||||||
// Hash hashes a string using the bcrypt algorithm
|
// Hash hashes a string using the bcrypt algorithm
|
||||||
func (Service) Hash(data string) (string, error) {
|
func (*Service) Hash(data string) (string, error) {
|
||||||
bytes, err := bcrypt.GenerateFromPassword([]byte(data), bcrypt.DefaultCost)
|
bytes, err := bcrypt.GenerateFromPassword([]byte(data), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(bytes), err
|
return string(bytes), err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompareHashAndData compares a hash to clear data and returns an error if the comparison fails.
|
// CompareHashAndData compares a hash to clear data and returns an error if the comparison fails.
|
||||||
func (Service) CompareHashAndData(hash string, data string) error {
|
func (*Service) CompareHashAndData(hash string, data string) error {
|
||||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(data))
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(data))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ package crypto
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestService_Hash(t *testing.T) {
|
func TestService_Hash(t *testing.T) {
|
||||||
var s = Service{}
|
var s = &Service{}
|
||||||
|
|
||||||
type args struct {
|
type args struct {
|
||||||
hash string
|
hash string
|
||||||
@@ -53,11 +51,3 @@ func TestService_Hash(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHash(t *testing.T) {
|
|
||||||
s := Service{}
|
|
||||||
|
|
||||||
hash, err := s.Hash("Passw0rd!")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotEmpty(t, hash)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
package crypto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Nonce struct {
|
|
||||||
val []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewNonce(size int) *Nonce {
|
|
||||||
return &Nonce{val: make([]byte, size)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRandomNonce generates a new initial nonce with the lower byte set to a random value
|
|
||||||
// This ensures there are plenty of nonce values available before rolling over
|
|
||||||
// Based on ideas from the Secure Programming Cookbook for C and C++ by John Viega, Matt Messier
|
|
||||||
// https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch04s09.html
|
|
||||||
func NewRandomNonce(size int) (*Nonce, error) {
|
|
||||||
randomBytes := 1
|
|
||||||
if size <= randomBytes {
|
|
||||||
return nil, errors.New("nonce size must be greater than the number of random bytes")
|
|
||||||
}
|
|
||||||
|
|
||||||
randomPart := make([]byte, randomBytes)
|
|
||||||
if _, err := rand.Read(randomPart); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
zeroPart := make([]byte, size-randomBytes)
|
|
||||||
nonceVal := append(randomPart, zeroPart...)
|
|
||||||
return &Nonce{val: nonceVal}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *Nonce) Read(stream io.Reader) error {
|
|
||||||
_, err := io.ReadFull(stream, n.val)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *Nonce) Value() []byte {
|
|
||||||
return n.val
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *Nonce) Increment() error {
|
|
||||||
// Start incrementing from the least significant byte
|
|
||||||
for i := len(n.val) - 1; i >= 0; i-- {
|
|
||||||
// Increment the current byte
|
|
||||||
n.val[i]++
|
|
||||||
|
|
||||||
// Check for overflow
|
|
||||||
if n.val[i] != 0 {
|
|
||||||
// No overflow, nonce is successfully incremented
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we reach here, it means the nonce has overflowed
|
|
||||||
return errors.New("nonce overflow")
|
|
||||||
}
|
|
||||||
@@ -4,32 +4,11 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/pkg/fips"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateTLSConfiguration creates a basic tls.Config with recommended TLS settings
|
// CreateTLSConfiguration creates a basic tls.Config with recommended TLS settings
|
||||||
func CreateTLSConfiguration(insecureSkipVerify bool) *tls.Config { //nolint:forbidigo
|
func CreateTLSConfiguration() *tls.Config {
|
||||||
return createTLSConfiguration(fips.FIPSMode(), insecureSkipVerify)
|
return &tls.Config{
|
||||||
}
|
|
||||||
|
|
||||||
func createTLSConfiguration(fipsEnabled bool, insecureSkipVerify bool) *tls.Config { //nolint:forbidigo
|
|
||||||
if fipsEnabled {
|
|
||||||
return &tls.Config{ //nolint:forbidigo
|
|
||||||
MinVersion: tls.VersionTLS12,
|
|
||||||
MaxVersion: tls.VersionTLS13,
|
|
||||||
CipherSuites: []uint16{
|
|
||||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
|
||||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
|
||||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
|
||||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
|
||||||
},
|
|
||||||
CurvePreferences: []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.CurveP521},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &tls.Config{ //nolint:forbidigo
|
|
||||||
MinVersion: tls.VersionTLS12,
|
MinVersion: tls.VersionTLS12,
|
||||||
CipherSuites: []uint16{
|
CipherSuites: []uint16{
|
||||||
tls.TLS_AES_128_GCM_SHA256,
|
tls.TLS_AES_128_GCM_SHA256,
|
||||||
@@ -43,40 +22,25 @@ func createTLSConfiguration(fipsEnabled bool, insecureSkipVerify bool) *tls.Conf
|
|||||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||||
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
|
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
|
||||||
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
|
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
|
||||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
|
|
||||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
|
|
||||||
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
|
|
||||||
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
|
|
||||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
|
|
||||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
|
|
||||||
},
|
},
|
||||||
InsecureSkipVerify: insecureSkipVerify, //nolint:forbidigo
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTLSConfigurationFromBytes initializes a tls.Config using a CA certificate, a certificate and a key
|
// CreateTLSConfigurationFromBytes initializes a tls.Config using a CA certificate, a certificate and a key
|
||||||
// loaded from memory.
|
// loaded from memory.
|
||||||
func CreateTLSConfigurationFromBytes(useTLS bool, caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { //nolint:forbidigo
|
func CreateTLSConfigurationFromBytes(caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) {
|
||||||
return createTLSConfigurationFromBytes(fips.FIPSMode(), useTLS, caCert, cert, key, skipClientVerification, skipServerVerification)
|
config := CreateTLSConfiguration()
|
||||||
}
|
config.InsecureSkipVerify = skipServerVerification
|
||||||
|
|
||||||
func createTLSConfigurationFromBytes(fipsEnabled, useTLS bool, caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { //nolint:forbidigo
|
if !skipClientVerification {
|
||||||
if !useTLS {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
config := createTLSConfiguration(fipsEnabled, skipServerVerification)
|
|
||||||
|
|
||||||
if !skipClientVerification || fipsEnabled {
|
|
||||||
certificate, err := tls.X509KeyPair(cert, key)
|
certificate, err := tls.X509KeyPair(cert, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Certificates = []tls.Certificate{certificate}
|
config.Certificates = []tls.Certificate{certificate}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !skipServerVerification || fipsEnabled {
|
if !skipServerVerification {
|
||||||
caCertPool := x509.NewCertPool()
|
caCertPool := x509.NewCertPool()
|
||||||
caCertPool.AppendCertsFromPEM(caCert)
|
caCertPool.AppendCertsFromPEM(caCert)
|
||||||
config.RootCAs = caCertPool
|
config.RootCAs = caCertPool
|
||||||
@@ -87,38 +51,29 @@ func createTLSConfigurationFromBytes(fipsEnabled, useTLS bool, caCert, cert, key
|
|||||||
|
|
||||||
// CreateTLSConfigurationFromDisk initializes a tls.Config using a CA certificate, a certificate and a key
|
// CreateTLSConfigurationFromDisk initializes a tls.Config using a CA certificate, a certificate and a key
|
||||||
// loaded from disk.
|
// loaded from disk.
|
||||||
func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
func CreateTLSConfigurationFromDisk(caCertPath, certPath, keyPath string, skipServerVerification bool) (*tls.Config, error) {
|
||||||
return createTLSConfigurationFromDisk(fips.FIPSMode(), config)
|
config := CreateTLSConfiguration()
|
||||||
}
|
config.InsecureSkipVerify = skipServerVerification
|
||||||
|
|
||||||
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
if certPath != "" && keyPath != "" {
|
||||||
if !config.TLS && fipsEnabled {
|
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||||
return nil, fips.ErrTLSRequired
|
|
||||||
} else if !config.TLS {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tlsConfig := createTLSConfiguration(fipsEnabled, config.TLSSkipVerify)
|
|
||||||
|
|
||||||
if config.TLSCertPath != "" && config.TLSKeyPath != "" {
|
|
||||||
cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tlsConfig.Certificates = []tls.Certificate{cert}
|
config.Certificates = []tls.Certificate{cert}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !tlsConfig.InsecureSkipVerify && config.TLSCACertPath != "" { //nolint:forbidigo
|
if !skipServerVerification && caCertPath != "" {
|
||||||
caCert, err := os.ReadFile(config.TLSCACertPath)
|
caCert, err := os.ReadFile(caCertPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
caCertPool := x509.NewCertPool()
|
caCertPool := x509.NewCertPool()
|
||||||
caCertPool.AppendCertsFromPEM(caCert)
|
caCertPool.AppendCertsFromPEM(caCert)
|
||||||
tlsConfig.RootCAs = caCertPool
|
config.RootCAs = caCertPool
|
||||||
}
|
}
|
||||||
|
|
||||||
return tlsConfig, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
package crypto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/tls"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateTLSConfiguration(t *testing.T) {
|
|
||||||
// InsecureSkipVerify = false
|
|
||||||
config := CreateTLSConfiguration(false)
|
|
||||||
require.Equal(t, config.MinVersion, uint16(tls.VersionTLS12)) //nolint:forbidigo
|
|
||||||
require.False(t, config.InsecureSkipVerify) //nolint:forbidigo
|
|
||||||
|
|
||||||
// InsecureSkipVerify = true
|
|
||||||
config = CreateTLSConfiguration(true)
|
|
||||||
require.Equal(t, config.MinVersion, uint16(tls.VersionTLS12)) //nolint:forbidigo
|
|
||||||
require.True(t, config.InsecureSkipVerify) //nolint:forbidigo
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTLSConfigurationFIPS(t *testing.T) {
|
|
||||||
fips := true
|
|
||||||
|
|
||||||
fipsCipherSuites := []uint16{
|
|
||||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
|
||||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
|
||||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
|
||||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
|
||||||
}
|
|
||||||
|
|
||||||
fipsCurvePreferences := []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.CurveP521}
|
|
||||||
|
|
||||||
config := createTLSConfiguration(fips, false)
|
|
||||||
require.Equal(t, config.MinVersion, uint16(tls.VersionTLS12)) //nolint:forbidigo
|
|
||||||
require.Equal(t, config.MaxVersion, uint16(tls.VersionTLS13)) //nolint:forbidigo
|
|
||||||
require.Equal(t, config.CipherSuites, fipsCipherSuites) //nolint:forbidigo
|
|
||||||
require.Equal(t, config.CurvePreferences, fipsCurvePreferences) //nolint:forbidigo
|
|
||||||
require.False(t, config.InsecureSkipVerify) //nolint:forbidigo
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTLSConfigurationFromBytes(t *testing.T) {
|
|
||||||
// No TLS
|
|
||||||
config, err := CreateTLSConfigurationFromBytes(false, nil, nil, nil, false, false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Nil(t, config)
|
|
||||||
|
|
||||||
// Skip TLS client/server verifications
|
|
||||||
config, err = CreateTLSConfigurationFromBytes(true, nil, nil, nil, true, true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, config)
|
|
||||||
|
|
||||||
// Empty TLS
|
|
||||||
config, err = CreateTLSConfigurationFromBytes(true, nil, nil, nil, false, false)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Nil(t, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTLSConfigurationFromDisk(t *testing.T) {
|
|
||||||
// No TLS
|
|
||||||
config, err := CreateTLSConfigurationFromDisk(portainer.TLSConfiguration{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Nil(t, config)
|
|
||||||
|
|
||||||
// Skip TLS verifications
|
|
||||||
config, err = CreateTLSConfigurationFromDisk(portainer.TLSConfiguration{
|
|
||||||
TLS: true,
|
|
||||||
TLSSkipVerify: true,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTLSConfigurationFromDiskFIPS(t *testing.T) {
|
|
||||||
fips := true
|
|
||||||
|
|
||||||
// Skipping TLS verifications cannot be done in FIPS mode
|
|
||||||
config, err := createTLSConfigurationFromDisk(fips, portainer.TLSConfiguration{
|
|
||||||
TLS: true,
|
|
||||||
TLSSkipVerify: true,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, config)
|
|
||||||
require.False(t, config.InsecureSkipVerify) //nolint:forbidigo
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
@@ -21,9 +20,6 @@ import (
|
|||||||
const (
|
const (
|
||||||
DatabaseFileName = "portainer.db"
|
DatabaseFileName = "portainer.db"
|
||||||
EncryptedDatabaseFileName = "portainer.edb"
|
EncryptedDatabaseFileName = "portainer.edb"
|
||||||
|
|
||||||
txMaxSize = 65536
|
|
||||||
compactedSuffix = ".compacted"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -38,7 +34,6 @@ type DbConnection struct {
|
|||||||
InitialMmapSize int
|
InitialMmapSize int
|
||||||
EncryptionKey []byte
|
EncryptionKey []byte
|
||||||
isEncrypted bool
|
isEncrypted bool
|
||||||
Compact bool
|
|
||||||
|
|
||||||
*bolt.DB
|
*bolt.DB
|
||||||
}
|
}
|
||||||
@@ -66,15 +61,6 @@ func (connection *DbConnection) GetStorePath() string {
|
|||||||
return connection.Path
|
return connection.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
func (connection *DbConnection) GetDatabaseFileSize() (int64, error) {
|
|
||||||
file, err := os.Stat(connection.GetDatabaseFilePath())
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("Failed to stat database file path: %s err: %w", connection.GetDatabaseFilePath(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return file.Size(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (connection *DbConnection) SetEncrypted(flag bool) {
|
func (connection *DbConnection) SetEncrypted(flag bool) {
|
||||||
connection.isEncrypted = flag
|
connection.isEncrypted = flag
|
||||||
}
|
}
|
||||||
@@ -87,6 +73,7 @@ func (connection *DbConnection) IsEncryptedStore() bool {
|
|||||||
// NeedsEncryptionMigration returns true if database encryption is enabled and
|
// NeedsEncryptionMigration returns true if database encryption is enabled and
|
||||||
// we have an un-encrypted DB that requires migration to an encrypted DB
|
// we have an un-encrypted DB that requires migration to an encrypted DB
|
||||||
func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
|
func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
|
||||||
|
|
||||||
// Cases: Note, we need to check both portainer.db and portainer.edb
|
// Cases: Note, we need to check both portainer.db and portainer.edb
|
||||||
// to determine if it's a new store. We only need to differentiate between cases 2,3 and 5
|
// to determine if it's a new store. We only need to differentiate between cases 2,3 and 5
|
||||||
|
|
||||||
@@ -134,10 +121,15 @@ func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
|
|||||||
|
|
||||||
// Open opens and initializes the BoltDB database.
|
// Open opens and initializes the BoltDB database.
|
||||||
func (connection *DbConnection) Open() error {
|
func (connection *DbConnection) Open() error {
|
||||||
|
|
||||||
log.Info().Str("filename", connection.GetDatabaseFileName()).Msg("loading PortainerDB")
|
log.Info().Str("filename", connection.GetDatabaseFileName()).Msg("loading PortainerDB")
|
||||||
|
|
||||||
|
// Now we open the db
|
||||||
databasePath := connection.GetDatabaseFilePath()
|
databasePath := connection.GetDatabaseFilePath()
|
||||||
db, err := bolt.Open(databasePath, 0600, connection.boltOptions(connection.Compact))
|
db, err := bolt.Open(databasePath, 0600, &bolt.Options{
|
||||||
|
Timeout: 1 * time.Second,
|
||||||
|
InitialMmapSize: connection.InitialMmapSize,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -146,24 +138,6 @@ func (connection *DbConnection) Open() error {
|
|||||||
db.MaxBatchDelay = connection.MaxBatchDelay
|
db.MaxBatchDelay = connection.MaxBatchDelay
|
||||||
connection.DB = db
|
connection.DB = db
|
||||||
|
|
||||||
if connection.Compact {
|
|
||||||
log.Info().Msg("compacting database")
|
|
||||||
if err := connection.compact(); err != nil {
|
|
||||||
log.Error().Err(err).Msg("failed to compact database")
|
|
||||||
|
|
||||||
// Close the read-only database and re-open in read-write mode
|
|
||||||
if err := connection.Close(); err != nil {
|
|
||||||
log.Warn().Err(err).Msg("failure to close the database after failed compaction")
|
|
||||||
}
|
|
||||||
|
|
||||||
connection.Compact = false
|
|
||||||
|
|
||||||
return connection.Open()
|
|
||||||
} else {
|
|
||||||
log.Info().Msg("database compaction completed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +178,6 @@ func (connection *DbConnection) ViewTx(fn func(portainer.Transaction) error) err
|
|||||||
func (connection *DbConnection) BackupTo(w io.Writer) error {
|
func (connection *DbConnection) BackupTo(w io.Writer) error {
|
||||||
return connection.View(func(tx *bolt.Tx) error {
|
return connection.View(func(tx *bolt.Tx) error {
|
||||||
_, err := tx.WriteTo(w)
|
_, err := tx.WriteTo(w)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -219,7 +192,6 @@ func (connection *DbConnection) ExportRaw(filename string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(filename, b, 0600)
|
return os.WriteFile(filename, b, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +201,6 @@ func (connection *DbConnection) ExportRaw(filename string) error {
|
|||||||
func (connection *DbConnection) ConvertToKey(v int) []byte {
|
func (connection *DbConnection) ConvertToKey(v int) []byte {
|
||||||
b := make([]byte, 8)
|
b := make([]byte, 8)
|
||||||
binary.BigEndian.PutUint64(b, uint64(v))
|
binary.BigEndian.PutUint64(b, uint64(v))
|
||||||
|
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +212,7 @@ func keyToString(b []byte) string {
|
|||||||
|
|
||||||
v := binary.BigEndian.Uint64(b)
|
v := binary.BigEndian.Uint64(b)
|
||||||
if v <= math.MaxInt32 {
|
if v <= math.MaxInt32 {
|
||||||
return strconv.FormatUint(v, 10)
|
return fmt.Sprintf("%d", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(b)
|
return string(b)
|
||||||
@@ -255,38 +226,12 @@ func (connection *DbConnection) SetServiceName(bucketName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetObject is a generic function used to retrieve an unmarshalled object from a database.
|
// GetObject is a generic function used to retrieve an unmarshalled object from a database.
|
||||||
func (connection *DbConnection) GetObject(bucketName string, key []byte, object any) error {
|
func (connection *DbConnection) GetObject(bucketName string, key []byte, object interface{}) error {
|
||||||
return connection.ViewTx(func(tx portainer.Transaction) error {
|
return connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
return tx.GetObject(bucketName, key, object)
|
return tx.GetObject(bucketName, key, object)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (connection *DbConnection) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
|
|
||||||
var value []byte
|
|
||||||
|
|
||||||
err := connection.ViewTx(func(tx portainer.Transaction) error {
|
|
||||||
var err error
|
|
||||||
value, err = tx.GetRawBytes(bucketName, key)
|
|
||||||
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
|
|
||||||
return value, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (connection *DbConnection) KeyExists(bucketName string, key []byte) (bool, error) {
|
|
||||||
var exists bool
|
|
||||||
|
|
||||||
err := connection.ViewTx(func(tx portainer.Transaction) error {
|
|
||||||
var err error
|
|
||||||
exists, err = tx.KeyExists(bucketName, key)
|
|
||||||
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
|
|
||||||
return exists, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (connection *DbConnection) getEncryptionKey() []byte {
|
func (connection *DbConnection) getEncryptionKey() []byte {
|
||||||
if !connection.isEncrypted {
|
if !connection.isEncrypted {
|
||||||
return nil
|
return nil
|
||||||
@@ -296,7 +241,7 @@ func (connection *DbConnection) getEncryptionKey() []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateObject is a generic function used to update an object inside a database.
|
// UpdateObject is a generic function used to update an object inside a database.
|
||||||
func (connection *DbConnection) UpdateObject(bucketName string, key []byte, object any) error {
|
func (connection *DbConnection) UpdateObject(bucketName string, key []byte, object interface{}) error {
|
||||||
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
return tx.UpdateObject(bucketName, key, object)
|
return tx.UpdateObject(bucketName, key, object)
|
||||||
})
|
})
|
||||||
@@ -337,7 +282,7 @@ func (connection *DbConnection) DeleteObject(bucketName string, key []byte) erro
|
|||||||
|
|
||||||
// DeleteAllObjects delete all objects where matching() returns (id, ok).
|
// DeleteAllObjects delete all objects where matching() returns (id, ok).
|
||||||
// TODO: think about how to return the error inside (maybe change ok to type err, and use "notfound"?
|
// TODO: think about how to return the error inside (maybe change ok to type err, and use "notfound"?
|
||||||
func (connection *DbConnection) DeleteAllObjects(bucketName string, obj any, matching func(o any) (id int, ok bool)) error {
|
func (connection *DbConnection) DeleteAllObjects(bucketName string, obj interface{}, matching func(o interface{}) (id int, ok bool)) error {
|
||||||
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
return tx.DeleteAllObjects(bucketName, obj, matching)
|
return tx.DeleteAllObjects(bucketName, obj, matching)
|
||||||
})
|
})
|
||||||
@@ -356,64 +301,71 @@ func (connection *DbConnection) GetNextIdentifier(bucketName string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateObject creates a new object in the bucket, using the next bucket sequence id
|
// CreateObject creates a new object in the bucket, using the next bucket sequence id
|
||||||
func (connection *DbConnection) CreateObject(bucketName string, fn func(uint64) (int, any)) error {
|
func (connection *DbConnection) CreateObject(bucketName string, fn func(uint64) (int, interface{})) error {
|
||||||
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
return tx.CreateObject(bucketName, fn)
|
return tx.CreateObject(bucketName, fn)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateObjectWithId creates a new object in the bucket, using the specified id
|
// CreateObjectWithId creates a new object in the bucket, using the specified id
|
||||||
func (connection *DbConnection) CreateObjectWithId(bucketName string, id int, obj any) error {
|
func (connection *DbConnection) CreateObjectWithId(bucketName string, id int, obj interface{}) error {
|
||||||
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
return tx.CreateObjectWithId(bucketName, id, obj)
|
return tx.CreateObjectWithId(bucketName, id, obj)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateObjectWithStringId creates a new object in the bucket, using the specified id
|
// CreateObjectWithStringId creates a new object in the bucket, using the specified id
|
||||||
func (connection *DbConnection) CreateObjectWithStringId(bucketName string, id []byte, obj any) error {
|
func (connection *DbConnection) CreateObjectWithStringId(bucketName string, id []byte, obj interface{}) error {
|
||||||
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
return tx.CreateObjectWithStringId(bucketName, id, obj)
|
return tx.CreateObjectWithStringId(bucketName, id, obj)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (connection *DbConnection) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error {
|
func (connection *DbConnection) GetAll(bucketName string, obj interface{}, append func(o interface{}) (interface{}, error)) error {
|
||||||
return connection.ViewTx(func(tx portainer.Transaction) error {
|
return connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
return tx.GetAll(bucketName, obj, appendFn)
|
return tx.GetAll(bucketName, obj, append)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (connection *DbConnection) GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, appendFn func(o any) (any, error)) error {
|
// TODO: decide which Unmarshal to use, and use one...
|
||||||
|
func (connection *DbConnection) GetAllWithJsoniter(bucketName string, obj interface{}, append func(o interface{}) (interface{}, error)) error {
|
||||||
return connection.ViewTx(func(tx portainer.Transaction) error {
|
return connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
return tx.GetAllWithKeyPrefix(bucketName, keyPrefix, obj, appendFn)
|
return tx.GetAllWithJsoniter(bucketName, obj, append)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (connection *DbConnection) GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj interface{}, append func(o interface{}) (interface{}, error)) error {
|
||||||
|
return connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
return tx.GetAllWithKeyPrefix(bucketName, keyPrefix, obj, append)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackupMetadata will return a copy of the boltdb sequence numbers for all buckets.
|
// BackupMetadata will return a copy of the boltdb sequence numbers for all buckets.
|
||||||
func (connection *DbConnection) BackupMetadata() (map[string]any, error) {
|
func (connection *DbConnection) BackupMetadata() (map[string]interface{}, error) {
|
||||||
buckets := map[string]any{}
|
buckets := map[string]interface{}{}
|
||||||
|
|
||||||
err := connection.View(func(tx *bolt.Tx) error {
|
err := connection.View(func(tx *bolt.Tx) error {
|
||||||
return tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
|
err := tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
|
||||||
bucketName := string(name)
|
bucketName := string(name)
|
||||||
seqId := bucket.Sequence()
|
seqId := bucket.Sequence()
|
||||||
buckets[bucketName] = int(seqId)
|
buckets[bucketName] = int(seqId)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
return buckets, err
|
return buckets, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestoreMetadata will restore the boltdb sequence numbers for all buckets.
|
// RestoreMetadata will restore the boltdb sequence numbers for all buckets.
|
||||||
func (connection *DbConnection) RestoreMetadata(s map[string]any) error {
|
func (connection *DbConnection) RestoreMetadata(s map[string]interface{}) error {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
for bucketName, v := range s {
|
for bucketName, v := range s {
|
||||||
id, ok := v.(float64) // JSON ints are unmarshalled to interface as float64. See: https://pkg.go.dev/encoding/json#Decoder.Decode
|
id, ok := v.(float64) // JSON ints are unmarshalled to interface as float64. See: https://pkg.go.dev/encoding/json#Decoder.Decode
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Error().Str("bucket", bucketName).Msg("failed to restore metadata to bucket, skipped")
|
log.Error().Str("bucket", bucketName).Msg("failed to restore metadata to bucket, skipped")
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,48 +381,3 @@ func (connection *DbConnection) RestoreMetadata(s map[string]any) error {
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// compact attempts to compact the database and replace it iff it succeeds
|
|
||||||
func (connection *DbConnection) compact() (err error) {
|
|
||||||
compactedPath := connection.GetDatabaseFilePath() + compactedSuffix
|
|
||||||
|
|
||||||
if err := os.Remove(compactedPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return fmt.Errorf("failure to remove an existing compacted database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
compactedDB, err := bolt.Open(compactedPath, 0o600, connection.boltOptions(false))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failure to create the compacted database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
compactedDB.MaxBatchSize = connection.MaxBatchSize
|
|
||||||
compactedDB.MaxBatchDelay = connection.MaxBatchDelay
|
|
||||||
|
|
||||||
if err := bolt.Compact(compactedDB, connection.DB, txMaxSize); err != nil {
|
|
||||||
return fmt.Errorf("failure to compact the database: %w",
|
|
||||||
errors.Join(err, compactedDB.Close(), os.Remove(compactedPath)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Rename(compactedPath, connection.GetDatabaseFilePath()); err != nil {
|
|
||||||
return fmt.Errorf("failure to move the compacted database: %w",
|
|
||||||
errors.Join(err, compactedDB.Close(), os.Remove(compactedPath)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := connection.Close(); err != nil {
|
|
||||||
log.Warn().Err(err).Msg("failure to close the database after compaction")
|
|
||||||
}
|
|
||||||
|
|
||||||
connection.DB = compactedDB
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (connection *DbConnection) boltOptions(readOnly bool) *bolt.Options {
|
|
||||||
return &bolt.Options{
|
|
||||||
Timeout: 1 * time.Second,
|
|
||||||
InitialMmapSize: connection.InitialMmapSize,
|
|
||||||
FreelistType: bolt.FreelistMapType,
|
|
||||||
NoFreelistSync: true,
|
|
||||||
ReadOnly: readOnly,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.etcd.io/bbolt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_NeedsEncryptionMigration(t *testing.T) {
|
func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||||
@@ -91,43 +87,28 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|
||||||
connection := DbConnection{Path: dir}
|
connection := DbConnection{Path: dir}
|
||||||
|
|
||||||
if tc.dbname == "both" {
|
if tc.dbname == "both" {
|
||||||
// Special case. If portainer.db and portainer.edb exist.
|
// Special case. If portainer.db and portainer.edb exist.
|
||||||
dbFile1 := path.Join(connection.Path, DatabaseFileName)
|
dbFile1 := path.Join(connection.Path, DatabaseFileName)
|
||||||
f, _ := os.Create(dbFile1)
|
f, _ := os.Create(dbFile1)
|
||||||
|
f.Close()
|
||||||
err := f.Close()
|
defer os.Remove(dbFile1)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
err := os.Remove(dbFile1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}()
|
|
||||||
|
|
||||||
dbFile2 := path.Join(connection.Path, EncryptedDatabaseFileName)
|
dbFile2 := path.Join(connection.Path, EncryptedDatabaseFileName)
|
||||||
f, _ = os.Create(dbFile2)
|
f, _ = os.Create(dbFile2)
|
||||||
|
f.Close()
|
||||||
err = f.Close()
|
defer os.Remove(dbFile2)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
err := os.Remove(dbFile2)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}()
|
|
||||||
} else if tc.dbname != "" {
|
} else if tc.dbname != "" {
|
||||||
dbFile := path.Join(connection.Path, tc.dbname)
|
dbFile := path.Join(connection.Path, tc.dbname)
|
||||||
f, _ := os.Create(dbFile)
|
f, _ := os.Create(dbFile)
|
||||||
|
f.Close()
|
||||||
err := f.Close()
|
defer os.Remove(dbFile)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
err := os.Remove(dbFile)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.key {
|
if tc.key {
|
||||||
@@ -141,60 +122,3 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDBCompaction(t *testing.T) {
|
|
||||||
db := &DbConnection{Path: t.TempDir()}
|
|
||||||
|
|
||||||
err := db.Open()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = db.Update(func(tx *bbolt.Tx) error {
|
|
||||||
b, err := tx.CreateBucketIfNotExists([]byte("testbucket"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = b.Put([]byte("key"), []byte("value"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = db.Close()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Reopen the DB to trigger compaction
|
|
||||||
db.Compact = true
|
|
||||||
err = db.Open()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Check that the data is still there
|
|
||||||
err = db.View(func(tx *bbolt.Tx) error {
|
|
||||||
b := tx.Bucket([]byte("testbucket"))
|
|
||||||
if b == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
val := b.Get([]byte("key"))
|
|
||||||
require.Equal(t, []byte("value"), val)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = db.Close()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Failures
|
|
||||||
compactedPath := db.GetDatabaseFilePath() + compactedSuffix
|
|
||||||
err = os.Mkdir(compactedPath, 0o755)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
f, err := os.Create(filesystem.JoinPaths(compactedPath, "somefile"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, f.Close())
|
|
||||||
|
|
||||||
err = db.Open()
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ package boltdb
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/logs"
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/segmentio/encoding/json"
|
"github.com/segmentio/encoding/json"
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func backupMetadata(connection *bolt.DB) (map[string]any, error) {
|
func backupMetadata(connection *bolt.DB) (map[string]interface{}, error) {
|
||||||
buckets := map[string]any{}
|
buckets := map[string]interface{}{}
|
||||||
|
|
||||||
err := connection.View(func(tx *bolt.Tx) error {
|
err := connection.View(func(tx *bolt.Tx) error {
|
||||||
err := tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
|
err := tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
|
||||||
@@ -38,9 +37,9 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return []byte("{}"), err
|
return []byte("{}"), err
|
||||||
}
|
}
|
||||||
defer logs.CloseAndLogErr(connection)
|
defer connection.Close()
|
||||||
|
|
||||||
backup := make(map[string]any)
|
backup := make(map[string]interface{})
|
||||||
if metadata {
|
if metadata {
|
||||||
meta, err := backupMetadata(connection)
|
meta, err := backupMetadata(connection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -50,10 +49,10 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
|
|||||||
backup["__metadata"] = meta
|
backup["__metadata"] = meta
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := connection.View(func(tx *bolt.Tx) error {
|
err = connection.View(func(tx *bolt.Tx) error {
|
||||||
return tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
|
err = tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
|
||||||
bucketName := string(name)
|
bucketName := string(name)
|
||||||
var list []any
|
var list []interface{}
|
||||||
version := make(map[string]string)
|
version := make(map[string]string)
|
||||||
cursor := bucket.Cursor()
|
cursor := bucket.Cursor()
|
||||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||||
@@ -61,7 +60,7 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var obj any
|
var obj interface{}
|
||||||
err := c.UnmarshalObject(v, &obj)
|
err := c.UnmarshalObject(v, &obj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
@@ -85,6 +84,7 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(list) > 0 {
|
||||||
if bucketName == "ssl" ||
|
if bucketName == "ssl" ||
|
||||||
bucketName == "settings" ||
|
bucketName == "settings" ||
|
||||||
bucketName == "tunnel_server" {
|
bucketName == "tunnel_server" {
|
||||||
@@ -92,15 +92,19 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
|
|||||||
if len(list) > 0 {
|
if len(list) > 0 {
|
||||||
backup[bucketName] = list[0]
|
backup[bucketName] = list[0]
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
backup[bucketName] = list
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
backup[bucketName] = list
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}); err != nil {
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
return []byte("{}"), err
|
return []byte("{}"), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/aes"
|
"crypto/aes"
|
||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/segmentio/encoding/json"
|
"github.com/segmentio/encoding/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errEncryptedStringTooShort = errors.New("encrypted string too short")
|
var errEncryptedStringTooShort = fmt.Errorf("encrypted string too short")
|
||||||
|
|
||||||
// MarshalObject encodes an object to binary format
|
// MarshalObject encodes an object to binary format
|
||||||
func (connection *DbConnection) MarshalObject(object any) ([]byte, error) {
|
func (connection *DbConnection) MarshalObject(object interface{}) ([]byte, error) {
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
|
|
||||||
// Special case for the VERSION bucket. Here we're not using json
|
// Special case for the VERSION bucket. Here we're not using json
|
||||||
@@ -36,7 +39,7 @@ func (connection *DbConnection) MarshalObject(object any) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalObject decodes an object from binary data
|
// UnmarshalObject decodes an object from binary data
|
||||||
func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
func (connection *DbConnection) UnmarshalObject(data []byte, object interface{}) error {
|
||||||
var err error
|
var err error
|
||||||
if connection.getEncryptionKey() != nil {
|
if connection.getEncryptionKey() != nil {
|
||||||
data, err = decrypt(data, connection.getEncryptionKey())
|
data, err = decrypt(data, connection.getEncryptionKey())
|
||||||
@@ -44,18 +47,17 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
|||||||
return errors.Wrap(err, "Failed decrypting object")
|
return errors.Wrap(err, "Failed decrypting object")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
e := json.Unmarshal(data, object)
|
||||||
if err := json.Unmarshal(data, object); err != nil {
|
if e != nil {
|
||||||
// Special case for the VERSION bucket. Here we're not using json
|
// Special case for the VERSION bucket. Here we're not using json
|
||||||
// So we need to return it as a string
|
// So we need to return it as a string
|
||||||
s, ok := object.(*string)
|
s, ok := object.(*string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.Wrap(err, "Failed unmarshalling object")
|
return errors.Wrap(err, e.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
*s = string(data)
|
*s = string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,41 +65,48 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
|||||||
// https://gist.github.com/atoponce/07d8d4c833873be2f68c34f9afc5a78a#symmetric-encryption
|
// https://gist.github.com/atoponce/07d8d4c833873be2f68c34f9afc5a78a#symmetric-encryption
|
||||||
|
|
||||||
func encrypt(plaintext []byte, passphrase []byte) (encrypted []byte, err error) {
|
func encrypt(plaintext []byte, passphrase []byte) (encrypted []byte, err error) {
|
||||||
block, err := aes.NewCipher(passphrase)
|
block, _ := aes.NewCipher(passphrase)
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return encrypted, err
|
return encrypted, err
|
||||||
}
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
// NewGCMWithRandomNonce in go 1.24 handles setting up the nonce and adding it to the encrypted output
|
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
|
||||||
if err != nil {
|
|
||||||
return encrypted, err
|
return encrypted, err
|
||||||
}
|
}
|
||||||
|
ciphertextByte := gcm.Seal(
|
||||||
return gcm.Seal(nil, nil, plaintext, nil), nil
|
nonce,
|
||||||
|
nonce,
|
||||||
|
plaintext,
|
||||||
|
nil)
|
||||||
|
return ciphertextByte, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decrypt(encrypted []byte, passphrase []byte) (plaintextByte []byte, err error) {
|
func decrypt(encrypted []byte, passphrase []byte) (plaintextByte []byte, err error) {
|
||||||
if string(encrypted) == "false" {
|
if string(encrypted) == "false" {
|
||||||
return []byte("false"), nil
|
return []byte("false"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
block, err := aes.NewCipher(passphrase)
|
block, err := aes.NewCipher(passphrase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return encrypted, errors.Wrap(err, "Error creating cypher block")
|
return encrypted, errors.Wrap(err, "Error creating cypher block")
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGCMWithRandomNonce in go 1.24 handles reading the nonce from the encrypted input for us
|
gcm, err := cipher.NewGCM(block)
|
||||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return encrypted, errors.Wrap(err, "Error creating GCM")
|
return encrypted, errors.Wrap(err, "Error creating GCM")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(encrypted) < gcm.NonceSize() {
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(encrypted) < nonceSize {
|
||||||
return encrypted, errEncryptedStringTooShort
|
return encrypted, errEncryptedStringTooShort
|
||||||
}
|
}
|
||||||
|
|
||||||
plaintextByte, err = gcm.Open(nil, nil, encrypted, nil)
|
nonce, ciphertextByteClean := encrypted[:nonceSize], encrypted[nonceSize:]
|
||||||
|
plaintextByte, err = gcm.Open(
|
||||||
|
nil,
|
||||||
|
nonce,
|
||||||
|
ciphertextByteClean,
|
||||||
|
nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return encrypted, errors.Wrap(err, "Error decrypting text")
|
return encrypted, errors.Wrap(err, "Error decrypting text")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
package boltdb
|
package boltdb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/gofrs/uuid"
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
|
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
|
||||||
passphrase = "my secret key"
|
passphrase = "my secret key"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,10 +22,10 @@ func secretToEncryptionKey(passphrase string) []byte {
|
|||||||
func Test_MarshalObjectUnencrypted(t *testing.T) {
|
func Test_MarshalObjectUnencrypted(t *testing.T) {
|
||||||
is := assert.New(t)
|
is := assert.New(t)
|
||||||
|
|
||||||
uuid := uuid.New()
|
uuid := uuid.Must(uuid.NewV4())
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
object any
|
object interface{}
|
||||||
expected string
|
expected string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -64,7 +57,7 @@ func Test_MarshalObjectUnencrypted(t *testing.T) {
|
|||||||
expected: uuid.String(),
|
expected: uuid.String(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
object: map[string]any{"key": "value"},
|
object: map[string]interface{}{"key": "value"},
|
||||||
expected: `{"key":"value"}`,
|
expected: `{"key":"value"}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -80,11 +73,11 @@ func Test_MarshalObjectUnencrypted(t *testing.T) {
|
|||||||
expected: `["1","2","3"]`,
|
expected: `["1","2","3"]`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
object: []map[string]any{{"key1": "value1"}, {"key2": "value2"}},
|
object: []map[string]interface{}{{"key1": "value1"}, {"key2": "value2"}},
|
||||||
expected: `[{"key1":"value1"},{"key2":"value2"}]`,
|
expected: `[{"key1":"value1"},{"key2":"value2"}]`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
object: []any{1, "2", false, map[string]any{"key1": "value1"}},
|
object: []interface{}{1, "2", false, map[string]interface{}{"key1": "value1"}},
|
||||||
expected: `[1,"2",false,{"key1":"value1"}]`,
|
expected: `[1,"2",false,{"key1":"value1"}]`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -94,7 +87,7 @@ func Test_MarshalObjectUnencrypted(t *testing.T) {
|
|||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||||
data, err := conn.MarshalObject(test.object)
|
data, err := conn.MarshalObject(test.object)
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
is.Equal(test.expected, string(data))
|
is.Equal(test.expected, string(data))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -135,7 +128,7 @@ func Test_UnMarshalObjectUnencrypted(t *testing.T) {
|
|||||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||||
var object string
|
var object string
|
||||||
err := conn.UnmarshalObject(test.object, &object)
|
err := conn.UnmarshalObject(test.object, &object)
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
is.Equal(test.expected, object)
|
is.Equal(test.expected, object)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -167,109 +160,18 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
key := secretToEncryptionKey(passphrase)
|
key := secretToEncryptionKey(passphrase)
|
||||||
conn := DbConnection{EncryptionKey: key, isEncrypted: true}
|
conn := DbConnection{EncryptionKey: key}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||||
|
|
||||||
data, err := conn.MarshalObject(test.object)
|
data, err := conn.MarshalObject(test.object)
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
|
|
||||||
var object []byte
|
var object []byte
|
||||||
err = conn.UnmarshalObject(data, &object)
|
err = conn.UnmarshalObject(data, &object)
|
||||||
|
|
||||||
require.NoError(t, err)
|
is.NoError(err)
|
||||||
is.Equal(test.object, object)
|
is.Equal(test.object, object)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_NonceSources(t *testing.T) {
|
|
||||||
// ensure that the new go 1.24 NewGCMWithRandomNonce works correctly with
|
|
||||||
// the old way of creating and including the nonce
|
|
||||||
|
|
||||||
encryptOldFn := func(plaintext []byte, passphrase []byte) (encrypted []byte, err error) {
|
|
||||||
block, _ := aes.NewCipher(passphrase)
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return encrypted, err
|
|
||||||
}
|
|
||||||
|
|
||||||
nonce := make([]byte, gcm.NonceSize())
|
|
||||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
||||||
return encrypted, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
decryptOldFn := func(encrypted []byte, passphrase []byte) (plaintext []byte, err error) {
|
|
||||||
block, err := aes.NewCipher(passphrase)
|
|
||||||
if err != nil {
|
|
||||||
return encrypted, errors.Wrap(err, "Error creating cypher block")
|
|
||||||
}
|
|
||||||
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return encrypted, errors.Wrap(err, "Error creating GCM")
|
|
||||||
}
|
|
||||||
|
|
||||||
nonceSize := gcm.NonceSize()
|
|
||||||
if len(encrypted) < nonceSize {
|
|
||||||
return encrypted, errEncryptedStringTooShort
|
|
||||||
}
|
|
||||||
|
|
||||||
nonce, ciphertextByteClean := encrypted[:nonceSize], encrypted[nonceSize:]
|
|
||||||
|
|
||||||
plaintext, err = gcm.Open(nil, nonce, ciphertextByteClean, nil)
|
|
||||||
if err != nil {
|
|
||||||
return encrypted, errors.Wrap(err, "Error decrypting text")
|
|
||||||
}
|
|
||||||
|
|
||||||
return plaintext, err
|
|
||||||
}
|
|
||||||
|
|
||||||
encryptNewFn := encrypt
|
|
||||||
decryptNewFn := decrypt
|
|
||||||
|
|
||||||
passphrase := make([]byte, 32)
|
|
||||||
_, err := io.ReadFull(rand.Reader, passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
junk := make([]byte, 1024)
|
|
||||||
_, err = io.ReadFull(rand.Reader, junk)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
junkEnc := make([]byte, base64.StdEncoding.EncodedLen(len(junk)))
|
|
||||||
base64.StdEncoding.Encode(junkEnc, junk)
|
|
||||||
|
|
||||||
cases := [][]byte{
|
|
||||||
[]byte("test"),
|
|
||||||
[]byte("35"),
|
|
||||||
[]byte("9ca4a1dd-a439-4593-b386-a7dfdc2e9fc6"),
|
|
||||||
[]byte(jsonobject),
|
|
||||||
passphrase,
|
|
||||||
junk,
|
|
||||||
junkEnc,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, plain := range cases {
|
|
||||||
var enc, dec []byte
|
|
||||||
var err error
|
|
||||||
|
|
||||||
enc, err = encryptOldFn(plain, passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
dec, err = decryptNewFn(enc, passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.Equal(t, plain, dec)
|
|
||||||
|
|
||||||
enc, err = encryptNewFn(plain, passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
dec, err = decryptOldFn(enc, passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.Equal(t, plain, dec)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
|
|
||||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
@@ -21,7 +20,7 @@ func (tx *DbTransaction) SetServiceName(bucketName string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tx *DbTransaction) GetObject(bucketName string, key []byte, object any) error {
|
func (tx *DbTransaction) GetObject(bucketName string, key []byte, object interface{}) error {
|
||||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||||
|
|
||||||
value := bucket.Get(key)
|
value := bucket.Get(key)
|
||||||
@@ -32,34 +31,7 @@ func (tx *DbTransaction) GetObject(bucketName string, key []byte, object any) er
|
|||||||
return tx.conn.UnmarshalObject(value, object)
|
return tx.conn.UnmarshalObject(value, object)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tx *DbTransaction) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
|
func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object interface{}) error {
|
||||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
|
||||||
|
|
||||||
value := bucket.Get(key)
|
|
||||||
if value == nil {
|
|
||||||
return nil, fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
if tx.conn.getEncryptionKey() != nil {
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if value, err = decrypt(value, tx.conn.getEncryptionKey()); err != nil {
|
|
||||||
return value, errors.Wrap(err, "Failed decrypting object")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tx *DbTransaction) KeyExists(bucketName string, key []byte) (bool, error) {
|
|
||||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
|
||||||
|
|
||||||
value := bucket.Get(key)
|
|
||||||
|
|
||||||
return value != nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object any) error {
|
|
||||||
data, err := tx.conn.MarshalObject(object)
|
data, err := tx.conn.MarshalObject(object)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -74,7 +46,7 @@ func (tx *DbTransaction) DeleteObject(bucketName string, key []byte) error {
|
|||||||
return bucket.Delete(key)
|
return bucket.Delete(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tx *DbTransaction) DeleteAllObjects(bucketName string, obj any, matchingFn func(o any) (id int, ok bool)) error {
|
func (tx *DbTransaction) DeleteAllObjects(bucketName string, obj interface{}, matchingFn func(o interface{}) (id int, ok bool)) error {
|
||||||
var ids []int
|
var ids []int
|
||||||
|
|
||||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||||
@@ -102,18 +74,16 @@ func (tx *DbTransaction) DeleteAllObjects(bucketName string, obj any, matchingFn
|
|||||||
|
|
||||||
func (tx *DbTransaction) GetNextIdentifier(bucketName string) int {
|
func (tx *DbTransaction) GetNextIdentifier(bucketName string) int {
|
||||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||||
|
|
||||||
id, err := bucket.NextSequence()
|
id, err := bucket.NextSequence()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("bucket", bucketName).Msg("failed to get the next identifier")
|
log.Error().Err(err).Str("bucket", bucketName).Msg("failed to get the next identifer")
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return int(id)
|
return int(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tx *DbTransaction) CreateObject(bucketName string, fn func(uint64) (int, any)) error {
|
func (tx *DbTransaction) CreateObject(bucketName string, fn func(uint64) (int, interface{})) error {
|
||||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||||
|
|
||||||
seqId, _ := bucket.NextSequence()
|
seqId, _ := bucket.NextSequence()
|
||||||
@@ -127,7 +97,7 @@ func (tx *DbTransaction) CreateObject(bucketName string, fn func(uint64) (int, a
|
|||||||
return bucket.Put(tx.conn.ConvertToKey(id), data)
|
return bucket.Put(tx.conn.ConvertToKey(id), data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tx *DbTransaction) CreateObjectWithId(bucketName string, id int, obj any) error {
|
func (tx *DbTransaction) CreateObjectWithId(bucketName string, id int, obj interface{}) error {
|
||||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||||
data, err := tx.conn.MarshalObject(obj)
|
data, err := tx.conn.MarshalObject(obj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -137,7 +107,7 @@ func (tx *DbTransaction) CreateObjectWithId(bucketName string, id int, obj any)
|
|||||||
return bucket.Put(tx.conn.ConvertToKey(id), data)
|
return bucket.Put(tx.conn.ConvertToKey(id), data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tx *DbTransaction) CreateObjectWithStringId(bucketName string, id []byte, obj any) error {
|
func (tx *DbTransaction) CreateObjectWithStringId(bucketName string, id []byte, obj interface{}) error {
|
||||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||||
data, err := tx.conn.MarshalObject(obj)
|
data, err := tx.conn.MarshalObject(obj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -147,7 +117,7 @@ func (tx *DbTransaction) CreateObjectWithStringId(bucketName string, id []byte,
|
|||||||
return bucket.Put(id, data)
|
return bucket.Put(id, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tx *DbTransaction) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error {
|
func (tx *DbTransaction) GetAll(bucketName string, obj interface{}, appendFn func(o interface{}) (interface{}, error)) error {
|
||||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||||
|
|
||||||
return bucket.ForEach(func(k []byte, v []byte) error {
|
return bucket.ForEach(func(k []byte, v []byte) error {
|
||||||
@@ -160,7 +130,20 @@ func (tx *DbTransaction) GetAll(bucketName string, obj any, appendFn func(o any)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tx *DbTransaction) GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, appendFn func(o any) (any, error)) error {
|
func (tx *DbTransaction) GetAllWithJsoniter(bucketName string, obj interface{}, appendFn func(o interface{}) (interface{}, error)) error {
|
||||||
|
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||||
|
|
||||||
|
return bucket.ForEach(func(k []byte, v []byte) error {
|
||||||
|
err := tx.conn.UnmarshalObject(v, obj)
|
||||||
|
if err == nil {
|
||||||
|
obj, err = appendFn(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tx *DbTransaction) GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj interface{}, appendFn func(o interface{}) (interface{}, error)) error {
|
||||||
cursor := tx.tx.Bucket([]byte(bucketName)).Cursor()
|
cursor := tx.tx.Bucket([]byte(bucketName)).Cursor()
|
||||||
|
|
||||||
for k, v := cursor.Seek(keyPrefix); k != nil && bytes.HasPrefix(k, keyPrefix); k, v = cursor.Next() {
|
for k, v := cursor.Seek(keyPrefix); k != nil && bytes.HasPrefix(k, keyPrefix); k, v = cursor.Next() {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const testBucketName = "test-bucket"
|
const testBucketName = "test-bucket"
|
||||||
@@ -18,55 +17,70 @@ type testStruct struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTxs(t *testing.T) {
|
func TestTxs(t *testing.T) {
|
||||||
conn := DbConnection{Path: t.TempDir()}
|
conn := DbConnection{
|
||||||
|
Path: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
err := conn.Open()
|
err := conn.Open()
|
||||||
require.NoError(t, err)
|
if err != nil {
|
||||||
defer func() {
|
t.Fatal(err)
|
||||||
err := conn.Close()
|
}
|
||||||
require.NoError(t, err)
|
defer conn.Close()
|
||||||
}()
|
|
||||||
|
|
||||||
// Error propagation
|
// Error propagation
|
||||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
return errors.New("this is an error")
|
return errors.New("this is an error")
|
||||||
})
|
})
|
||||||
require.Error(t, err)
|
if err == nil {
|
||||||
|
t.Fatal("an error was expected, got nil instead")
|
||||||
|
}
|
||||||
|
|
||||||
// Create an object
|
// Create an object
|
||||||
newObj := testStruct{Key: "key", Value: "value"}
|
newObj := testStruct{
|
||||||
|
Key: "key",
|
||||||
|
Value: "value",
|
||||||
|
}
|
||||||
|
|
||||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
if err := tx.SetServiceName(testBucketName); err != nil {
|
err = tx.SetServiceName(testBucketName)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.CreateObjectWithId(testBucketName, testId, newObj)
|
return tx.CreateObjectWithId(testBucketName, testId, newObj)
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
obj := testStruct{}
|
obj := testStruct{}
|
||||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||||
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
if obj.Key != newObj.Key || obj.Value != newObj.Value {
|
if obj.Key != newObj.Key || obj.Value != newObj.Value {
|
||||||
t.Fatalf("expected %s:%s, got %s:%s instead", newObj.Key, newObj.Value, obj.Key, obj.Value)
|
t.Fatalf("expected %s:%s, got %s:%s instead", newObj.Key, newObj.Value, obj.Key, obj.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update an object
|
// Update an object
|
||||||
updatedObj := testStruct{Key: "updated-key", Value: "updated-value"}
|
updatedObj := testStruct{
|
||||||
|
Key: "updated-key",
|
||||||
|
Value: "updated-value",
|
||||||
|
}
|
||||||
|
|
||||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
return tx.UpdateObject(testBucketName, conn.ConvertToKey(testId), &updatedObj)
|
return tx.UpdateObject(testBucketName, conn.ConvertToKey(testId), &updatedObj)
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||||
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
if obj.Key != updatedObj.Key || obj.Value != updatedObj.Value {
|
if obj.Key != updatedObj.Key || obj.Value != updatedObj.Value {
|
||||||
t.Fatalf("expected %s:%s, got %s:%s instead", updatedObj.Key, updatedObj.Value, obj.Key, obj.Value)
|
t.Fatalf("expected %s:%s, got %s:%s instead", updatedObj.Key, updatedObj.Value, obj.Key, obj.Value)
|
||||||
@@ -76,12 +90,16 @@ func TestTxs(t *testing.T) {
|
|||||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
return tx.DeleteObject(testBucketName, conn.ConvertToKey(testId))
|
return tx.DeleteObject(testBucketName, conn.ConvertToKey(testId))
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||||
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
||||||
})
|
})
|
||||||
require.True(t, dataservices.IsErrObjectNotFound(err))
|
if !dataservices.IsErrObjectNotFound(err) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
// Get next identifier
|
// Get next identifier
|
||||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
@@ -94,11 +112,15 @@ func TestTxs(t *testing.T) {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
// Try to write in a read transaction
|
// Try to write in a read transaction
|
||||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||||
return tx.CreateObjectWithId(testBucketName, testId, newObj)
|
return tx.CreateObjectWithId(testBucketName, testId, newObj)
|
||||||
})
|
})
|
||||||
require.Error(t, err)
|
if err == nil {
|
||||||
|
t.Fatal("an error was expected, got nil instead")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NewDatabase should use config options to return a connection to the requested database
|
// NewDatabase should use config options to return a connection to the requested database
|
||||||
func NewDatabase(storeType, storePath string, encryptionKey []byte, compact bool) (connection portainer.Connection, err error) {
|
func NewDatabase(storeType, storePath string, encryptionKey []byte) (connection portainer.Connection, err error) {
|
||||||
if storeType == "boltdb" {
|
if storeType == "boltdb" {
|
||||||
return &boltdb.DbConnection{
|
return &boltdb.DbConnection{
|
||||||
Path: storePath,
|
Path: storePath,
|
||||||
EncryptionKey: encryptionKey,
|
EncryptionKey: encryptionKey,
|
||||||
Compact: compact,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/database/boltdb"
|
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewDatabase(t *testing.T) {
|
|
||||||
dbPath := filesystem.JoinPaths(t.TempDir(), "test.db")
|
|
||||||
connection, err := NewDatabase("boltdb", dbPath, nil, false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, connection)
|
|
||||||
|
|
||||||
_, ok := connection.(*boltdb.DbConnection)
|
|
||||||
require.True(t, ok)
|
|
||||||
|
|
||||||
connection, err = NewDatabase("unknown", dbPath, nil, false)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Nil(t, connection)
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,8 @@ type Service struct {
|
|||||||
|
|
||||||
// NewService creates a new instance of a service.
|
// NewService creates a new instance of a service.
|
||||||
func NewService(connection portainer.Connection) (*Service, error) {
|
func NewService(connection portainer.Connection) (*Service, error) {
|
||||||
if err := connection.SetServiceName(BucketName); err != nil {
|
err := connection.SetServiceName(BucketName)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ func (service *Service) GetAPIKeysByUserID(userID portainer.UserID) ([]portainer
|
|||||||
err := service.Connection.GetAll(
|
err := service.Connection.GetAll(
|
||||||
BucketName,
|
BucketName,
|
||||||
&portainer.APIKey{},
|
&portainer.APIKey{},
|
||||||
func(obj any) (any, error) {
|
func(obj interface{}) (interface{}, error) {
|
||||||
record, ok := obj.(*portainer.APIKey)
|
record, ok := obj.(*portainer.APIKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to APIKey object")
|
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to APIKey object")
|
||||||
@@ -61,11 +62,11 @@ func (service *Service) GetAPIKeysByUserID(userID portainer.UserID) ([]portainer
|
|||||||
// Note: there is a 1-to-1 mapping of api-key and digest
|
// Note: there is a 1-to-1 mapping of api-key and digest
|
||||||
func (service *Service) GetAPIKeyByDigest(digest string) (*portainer.APIKey, error) {
|
func (service *Service) GetAPIKeyByDigest(digest string) (*portainer.APIKey, error) {
|
||||||
var k *portainer.APIKey
|
var k *portainer.APIKey
|
||||||
stop := errors.New("ok")
|
stop := fmt.Errorf("ok")
|
||||||
err := service.Connection.GetAll(
|
err := service.Connection.GetAll(
|
||||||
BucketName,
|
BucketName,
|
||||||
&portainer.APIKey{},
|
&portainer.APIKey{},
|
||||||
func(obj any) (any, error) {
|
func(obj interface{}) (interface{}, error) {
|
||||||
key, ok := obj.(*portainer.APIKey)
|
key, ok := obj.(*portainer.APIKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to APIKey object")
|
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to APIKey object")
|
||||||
@@ -94,7 +95,7 @@ func (service *Service) GetAPIKeyByDigest(digest string) (*portainer.APIKey, err
|
|||||||
func (service *Service) Create(record *portainer.APIKey) error {
|
func (service *Service) Create(record *portainer.APIKey) error {
|
||||||
return service.Connection.CreateObject(
|
return service.Connection.CreateObject(
|
||||||
BucketName,
|
BucketName,
|
||||||
func(id uint64) (int, any) {
|
func(id uint64) (int, interface{}) {
|
||||||
record.ID = portainer.APIKeyID(id)
|
record.ID = portainer.APIKeyID(id)
|
||||||
|
|
||||||
return int(record.ID), record
|
return int(record.ID), record
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import (
|
|||||||
type BaseCRUD[T any, I constraints.Integer] interface {
|
type BaseCRUD[T any, I constraints.Integer] interface {
|
||||||
Create(element *T) error
|
Create(element *T) error
|
||||||
Read(ID I) (*T, error)
|
Read(ID I) (*T, error)
|
||||||
Exists(ID I) (bool, error)
|
ReadAll() ([]T, error)
|
||||||
ReadAll(predicates ...func(T) bool) ([]T, error)
|
|
||||||
Update(ID I, element *T) error
|
Update(ID I, element *T) error
|
||||||
Delete(ID I) error
|
Delete(ID I) error
|
||||||
}
|
}
|
||||||
@@ -43,26 +42,12 @@ func (service BaseDataService[T, I]) Read(ID I) (*T, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service BaseDataService[T, I]) Exists(ID I) (bool, error) {
|
func (service BaseDataService[T, I]) ReadAll() ([]T, error) {
|
||||||
var exists bool
|
|
||||||
|
|
||||||
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
|
||||||
var err error
|
|
||||||
exists, err = service.Tx(tx).Exists(ID)
|
|
||||||
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
|
|
||||||
return exists, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
|
||||||
func (service BaseDataService[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) {
|
|
||||||
var collection = make([]T, 0)
|
var collection = make([]T, 0)
|
||||||
|
|
||||||
return collection, service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
return collection, service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
var err error
|
var err error
|
||||||
collection, err = service.Tx(tx).ReadAll(predicates...)
|
collection, err = service.Tx(tx).ReadAll()
|
||||||
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
package dataservices
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/slicesx"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testObject struct {
|
|
||||||
ID int
|
|
||||||
Value int
|
|
||||||
}
|
|
||||||
|
|
||||||
type mockConnection struct {
|
|
||||||
store map[int]testObject
|
|
||||||
|
|
||||||
portainer.Connection
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m mockConnection) UpdateObject(bucket string, key []byte, value any) error {
|
|
||||||
obj := value.(*testObject)
|
|
||||||
|
|
||||||
m.store[obj.ID] = *obj
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m mockConnection) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error {
|
|
||||||
for _, v := range m.store {
|
|
||||||
if _, err := appendFn(&v); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m mockConnection) UpdateTx(fn func(portainer.Transaction) error) error {
|
|
||||||
return fn(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error {
|
|
||||||
return fn(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m mockConnection) ConvertToKey(v int) []byte {
|
|
||||||
return []byte(strconv.Itoa(v))
|
|
||||||
}
|
|
||||||
func TestReadAll(t *testing.T) {
|
|
||||||
service := BaseDataService[testObject, int]{
|
|
||||||
Bucket: "testBucket",
|
|
||||||
Connection: mockConnection{store: make(map[int]testObject)},
|
|
||||||
}
|
|
||||||
|
|
||||||
data := []testObject{
|
|
||||||
{ID: 1, Value: 1},
|
|
||||||
{ID: 2, Value: 2},
|
|
||||||
{ID: 3, Value: 3},
|
|
||||||
{ID: 4, Value: 4},
|
|
||||||
{ID: 5, Value: 5},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range data {
|
|
||||||
err := service.Update(item.ID, &item)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadAll without predicates
|
|
||||||
result, err := service.ReadAll()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
expected := append([]testObject{}, data...)
|
|
||||||
|
|
||||||
require.ElementsMatch(t, expected, result)
|
|
||||||
|
|
||||||
// ReadAll with predicates
|
|
||||||
hasLowID := func(obj testObject) bool { return obj.ID < 3 }
|
|
||||||
isEven := func(obj testObject) bool { return obj.Value%2 == 0 }
|
|
||||||
|
|
||||||
result, err = service.ReadAll(hasLowID, isEven)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
expected = slicesx.Filter(expected, hasLowID)
|
|
||||||
expected = slicesx.Filter(expected, isEven)
|
|
||||||
|
|
||||||
require.ElementsMatch(t, expected, result)
|
|
||||||
}
|
|
||||||
@@ -28,39 +28,14 @@ func (service BaseDataServiceTx[T, I]) Read(ID I) (*T, error) {
|
|||||||
return &element, nil
|
return &element, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) {
|
func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) {
|
||||||
identifier := service.Connection.ConvertToKey(int(ID))
|
|
||||||
|
|
||||||
return service.Tx.KeyExists(service.Bucket, identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
|
||||||
func (service BaseDataServiceTx[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) {
|
|
||||||
var collection = make([]T, 0)
|
var collection = make([]T, 0)
|
||||||
|
|
||||||
if len(predicates) == 0 {
|
return collection, service.Tx.GetAllWithJsoniter(
|
||||||
return collection, service.Tx.GetAll(
|
|
||||||
service.Bucket,
|
service.Bucket,
|
||||||
new(T),
|
new(T),
|
||||||
AppendFn(&collection),
|
AppendFn(&collection),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
filterFn := func(element T) bool {
|
|
||||||
for _, p := range predicates {
|
|
||||||
if !p(element) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return collection, service.Tx.GetAll(
|
|
||||||
service.Bucket,
|
|
||||||
new(T),
|
|
||||||
FilterFn(&collection, filterFn),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service BaseDataServiceTx[T, I]) Update(ID I, element *T) error {
|
func (service BaseDataServiceTx[T, I]) Update(ID I, element *T) error {
|
||||||
@@ -72,13 +47,3 @@ func (service BaseDataServiceTx[T, I]) Delete(ID I) error {
|
|||||||
identifier := service.Connection.ConvertToKey(int(ID))
|
identifier := service.Connection.ConvertToKey(int(ID))
|
||||||
return service.Tx.DeleteObject(service.Bucket, identifier)
|
return service.Tx.DeleteObject(service.Bucket, identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Read[T any](tx portainer.Transaction, bucket string, key []byte) (*T, error) {
|
|
||||||
var element T
|
|
||||||
|
|
||||||
if err := tx.GetObject(bucket, key, &element); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &element, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,12 +28,13 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateCustomTemplate uses the existing id and saves it.
|
||||||
|
// TODO: where does the ID come from, and is it safe?
|
||||||
|
func (service *Service) Create(customTemplate *portainer.CustomTemplate) error {
|
||||||
|
return service.Connection.CreateObjectWithId(BucketName, int(customTemplate.ID), customTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNextIdentifier returns the next identifier for a custom template.
|
||||||
func (service *Service) GetNextIdentifier() int {
|
func (service *Service) GetNextIdentifier() int {
|
||||||
return service.Connection.GetNextIdentifier(BucketName)
|
return service.Connection.GetNextIdentifier(BucketName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) Create(customTemplate *portainer.CustomTemplate) error {
|
|
||||||
return service.Connection.UpdateTx(func(tx portainer.Transaction) error {
|
|
||||||
return service.Tx(tx).Create(customTemplate)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
package customtemplate_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/datastore"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCustomTemplateCreate(t *testing.T) {
|
|
||||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
|
||||||
require.NotNil(t, ds)
|
|
||||||
|
|
||||||
require.NoError(t, ds.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1}))
|
|
||||||
e, err := ds.CustomTemplate().Read(1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, portainer.CustomTemplateID(1), e.ID)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package customtemplate
|
|
||||||
|
|
||||||
import (
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Service represents a service for managing custom template data.
|
|
||||||
type ServiceTx struct {
|
|
||||||
dataservices.BaseDataServiceTx[portainer.CustomTemplate, portainer.CustomTemplateID]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
|
||||||
return ServiceTx{
|
|
||||||
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.CustomTemplate, portainer.CustomTemplateID]{
|
|
||||||
Bucket: BucketName,
|
|
||||||
Connection: service.Connection,
|
|
||||||
Tx: tx,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (service ServiceTx) GetNextIdentifier() int {
|
|
||||||
return service.Tx.GetNextIdentifier(BucketName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateCustomTemplate uses the existing id and saves it.
|
|
||||||
// TODO: where does the ID come from, and is it safe?
|
|
||||||
func (service ServiceTx) Create(customTemplate *portainer.CustomTemplate) error {
|
|
||||||
return service.Tx.CreateObjectWithId(BucketName, int(customTemplate.ID), customTemplate)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package customtemplate_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
|
||||||
"github.com/portainer/portainer/api/datastore"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCustomTemplateCreateTx(t *testing.T) {
|
|
||||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
|
||||||
require.NotNil(t, ds)
|
|
||||||
|
|
||||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
|
||||||
return tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1})
|
|
||||||
}))
|
|
||||||
|
|
||||||
var template *portainer.CustomTemplate
|
|
||||||
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
|
|
||||||
var err error
|
|
||||||
template, err = tx.CustomTemplate().Read(1)
|
|
||||||
return err
|
|
||||||
}))
|
|
||||||
|
|
||||||
require.Equal(t, portainer.CustomTemplateID(1), template.ID)
|
|
||||||
}
|
|
||||||
@@ -17,29 +17,11 @@ func (service ServiceTx) UpdateEdgeGroupFunc(ID portainer.EdgeGroupID, updateFun
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (service ServiceTx) Create(group *portainer.EdgeGroup) error {
|
func (service ServiceTx) Create(group *portainer.EdgeGroup) error {
|
||||||
es := group.Endpoints
|
return service.Tx.CreateObject(
|
||||||
group.Endpoints = nil // Clear deprecated field
|
|
||||||
|
|
||||||
err := service.Tx.CreateObject(
|
|
||||||
BucketName,
|
BucketName,
|
||||||
func(id uint64) (int, any) {
|
func(id uint64) (int, interface{}) {
|
||||||
group.ID = portainer.EdgeGroupID(id)
|
group.ID = portainer.EdgeGroupID(id)
|
||||||
return int(group.ID), group
|
return int(group.ID), group
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
group.Endpoints = es // Restore endpoints after create
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (service ServiceTx) Update(ID portainer.EdgeGroupID, group *portainer.EdgeGroup) error {
|
|
||||||
es := group.Endpoints
|
|
||||||
group.Endpoints = nil // Clear deprecated field
|
|
||||||
|
|
||||||
err := service.BaseDataServiceTx.Update(ID, group)
|
|
||||||
|
|
||||||
group.Endpoints = es // Restore endpoints after update
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type Service struct {
|
|||||||
connection portainer.Connection
|
connection portainer.Connection
|
||||||
idxVersion map[portainer.EdgeStackID]int
|
idxVersion map[portainer.EdgeStackID]int
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)
|
cacheInvalidationFn func(portainer.EdgeStackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) BucketName() string {
|
func (service *Service) BucketName() string {
|
||||||
@@ -23,7 +23,7 @@ func (service *Service) BucketName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new instance of a service.
|
// NewService creates a new instance of a service.
|
||||||
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)) (*Service, error) {
|
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.EdgeStackID)) (*Service, error) {
|
||||||
err := connection.SetServiceName(BucketName)
|
err := connection.SetServiceName(BucketName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -36,7 +36,7 @@ func NewService(connection portainer.Connection, cacheInvalidationFn func(portai
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.cacheInvalidationFn == nil {
|
if s.cacheInvalidationFn == nil {
|
||||||
s.cacheInvalidationFn = func(portainer.Transaction, portainer.EdgeStackID) {}
|
s.cacheInvalidationFn = func(portainer.EdgeStackID) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
es, err := s.EdgeStacks()
|
es, err := s.EdgeStacks()
|
||||||
@@ -106,7 +106,7 @@ func (service *Service) Create(id portainer.EdgeStackID, edgeStack *portainer.Ed
|
|||||||
|
|
||||||
service.mu.Lock()
|
service.mu.Lock()
|
||||||
service.idxVersion[id] = edgeStack.Version
|
service.idxVersion[id] = edgeStack.Version
|
||||||
service.cacheInvalidationFn(service.connection, id)
|
service.cacheInvalidationFn(id)
|
||||||
service.mu.Unlock()
|
service.mu.Unlock()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -125,7 +125,7 @@ func (service *Service) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *por
|
|||||||
}
|
}
|
||||||
|
|
||||||
service.idxVersion[ID] = edgeStack.Version
|
service.idxVersion[ID] = edgeStack.Version
|
||||||
service.cacheInvalidationFn(service.connection, ID)
|
service.cacheInvalidationFn(ID)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ func (service *Service) UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc
|
|||||||
updateFunc(edgeStack)
|
updateFunc(edgeStack)
|
||||||
|
|
||||||
service.idxVersion[ID] = edgeStack.Version
|
service.idxVersion[ID] = edgeStack.Version
|
||||||
service.cacheInvalidationFn(service.connection, ID)
|
service.cacheInvalidationFn(ID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ func (service *Service) DeleteEdgeStack(ID portainer.EdgeStackID) error {
|
|||||||
|
|
||||||
delete(service.idxVersion, ID)
|
delete(service.idxVersion, ID)
|
||||||
|
|
||||||
service.cacheInvalidationFn(service.connection, ID)
|
service.cacheInvalidationFn(ID)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
package edgestack
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/database/boltdb"
|
|
||||||
"github.com/portainer/portainer/api/logs"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUpdate(t *testing.T) {
|
|
||||||
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
|
|
||||||
err := conn.Open()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer logs.CloseAndLogErr(conn)
|
|
||||||
|
|
||||||
service, err := NewService(conn, func(portainer.Transaction, portainer.EdgeStackID) {})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
const edgeStackID = 1
|
|
||||||
edgeStack := &portainer.EdgeStack{
|
|
||||||
ID: edgeStackID,
|
|
||||||
Name: "Test Stack",
|
|
||||||
}
|
|
||||||
|
|
||||||
err = service.Create(edgeStackID, edgeStack)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = service.UpdateEdgeStackFunc(edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
|
||||||
edgeStack.Name = "Updated Stack"
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
updatedStack, err := service.EdgeStack(edgeStackID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "Updated Stack", updatedStack.Name)
|
|
||||||
|
|
||||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
|
||||||
return service.UpdateEdgeStackFuncTx(tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
|
||||||
edgeStack.Name = "Updated Stack Again"
|
|
||||||
})
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
updatedStack, err = service.EdgeStack(edgeStackID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "Updated Stack Again", updatedStack.Name)
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,7 @@ func (service ServiceTx) EdgeStacks() ([]portainer.EdgeStack, error) {
|
|||||||
err := service.tx.GetAll(
|
err := service.tx.GetAll(
|
||||||
BucketName,
|
BucketName,
|
||||||
&portainer.EdgeStack{},
|
&portainer.EdgeStack{},
|
||||||
func(obj any) (any, error) {
|
func(obj interface{}) (interface{}, error) {
|
||||||
stack, ok := obj.(*portainer.EdgeStack)
|
stack, ok := obj.(*portainer.EdgeStack)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to EdgeStack object")
|
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to EdgeStack object")
|
||||||
@@ -44,7 +44,8 @@ func (service ServiceTx) EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeSta
|
|||||||
var stack portainer.EdgeStack
|
var stack portainer.EdgeStack
|
||||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
if err := service.tx.GetObject(BucketName, identifier, &stack); err != nil {
|
err := service.tx.GetObject(BucketName, identifier, &stack)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,17 +65,18 @@ func (service ServiceTx) EdgeStackVersion(ID portainer.EdgeStackID) (int, bool)
|
|||||||
func (service ServiceTx) Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error {
|
func (service ServiceTx) Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error {
|
||||||
edgeStack.ID = id
|
edgeStack.ID = id
|
||||||
|
|
||||||
if err := service.tx.CreateObjectWithId(
|
err := service.tx.CreateObjectWithId(
|
||||||
BucketName,
|
BucketName,
|
||||||
int(edgeStack.ID),
|
int(edgeStack.ID),
|
||||||
edgeStack,
|
edgeStack,
|
||||||
); err != nil {
|
)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
service.service.mu.Lock()
|
service.service.mu.Lock()
|
||||||
service.service.idxVersion[id] = edgeStack.Version
|
service.service.idxVersion[id] = edgeStack.Version
|
||||||
service.service.cacheInvalidationFn(service.tx, id)
|
service.service.cacheInvalidationFn(id)
|
||||||
service.service.mu.Unlock()
|
service.service.mu.Unlock()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -87,12 +89,13 @@ func (service ServiceTx) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *po
|
|||||||
|
|
||||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
if err := service.tx.UpdateObject(BucketName, identifier, edgeStack); err != nil {
|
err := service.tx.UpdateObject(BucketName, identifier, edgeStack)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
service.service.idxVersion[ID] = edgeStack.Version
|
service.service.idxVersion[ID] = edgeStack.Version
|
||||||
service.service.cacheInvalidationFn(service.tx, ID)
|
service.service.cacheInvalidationFn(ID)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -116,13 +119,14 @@ func (service ServiceTx) DeleteEdgeStack(ID portainer.EdgeStackID) error {
|
|||||||
|
|
||||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
if err := service.tx.DeleteObject(BucketName, identifier); err != nil {
|
err := service.tx.DeleteObject(BucketName, identifier)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(service.service.idxVersion, ID)
|
delete(service.service.idxVersion, ID)
|
||||||
|
|
||||||
service.service.cacheInvalidationFn(service.tx, ID)
|
service.service.cacheInvalidationFn(ID)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
package edgestackstatus
|
|
||||||
|
|
||||||
import (
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ dataservices.EdgeStackStatusService = &Service{}
|
|
||||||
|
|
||||||
const BucketName = "edge_stack_status"
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
conn portainer.Connection
|
|
||||||
}
|
|
||||||
|
|
||||||
func (service *Service) BucketName() string {
|
|
||||||
return BucketName
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService(connection portainer.Connection) (*Service, error) {
|
|
||||||
if err := connection.SetServiceName(BucketName); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Service{conn: connection}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) Tx(tx portainer.Transaction) ServiceTx {
|
|
||||||
return ServiceTx{
|
|
||||||
service: s,
|
|
||||||
tx: tx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
|
|
||||||
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
|
||||||
return s.Tx(tx).Create(edgeStackID, endpointID, status)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) {
|
|
||||||
var element *portainer.EdgeStackStatusForEnv
|
|
||||||
|
|
||||||
return element, s.conn.ViewTx(func(tx portainer.Transaction) error {
|
|
||||||
var err error
|
|
||||||
element, err = s.Tx(tx).Read(edgeStackID, endpointID)
|
|
||||||
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) {
|
|
||||||
var collection = make([]portainer.EdgeStackStatusForEnv, 0)
|
|
||||||
|
|
||||||
return collection, s.conn.ViewTx(func(tx portainer.Transaction) error {
|
|
||||||
var err error
|
|
||||||
collection, err = s.Tx(tx).ReadAll(edgeStackID)
|
|
||||||
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
|
|
||||||
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
|
||||||
return s.Tx(tx).Update(edgeStackID, endpointID, status)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error {
|
|
||||||
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
|
||||||
return s.Tx(tx).Delete(edgeStackID, endpointID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) DeleteAll(edgeStackID portainer.EdgeStackID) error {
|
|
||||||
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
|
||||||
return s.Tx(tx).DeleteAll(edgeStackID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error {
|
|
||||||
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
|
||||||
return s.Tx(tx).Clear(edgeStackID, relatedEnvironmentsIDs)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) key(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) []byte {
|
|
||||||
return append(s.conn.ConvertToKey(int(edgeStackID)), s.conn.ConvertToKey(int(endpointID))...)
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package edgestackstatus
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ dataservices.EdgeStackStatusService = &Service{}
|
|
||||||
|
|
||||||
type ServiceTx struct {
|
|
||||||
service *Service
|
|
||||||
tx portainer.Transaction
|
|
||||||
}
|
|
||||||
|
|
||||||
func (service ServiceTx) Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
|
|
||||||
identifier := service.service.key(edgeStackID, endpointID)
|
|
||||||
return service.tx.CreateObjectWithStringId(BucketName, identifier, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s ServiceTx) Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) {
|
|
||||||
var status portainer.EdgeStackStatusForEnv
|
|
||||||
identifier := s.service.key(edgeStackID, endpointID)
|
|
||||||
|
|
||||||
if err := s.tx.GetObject(BucketName, identifier, &status); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &status, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s ServiceTx) ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) {
|
|
||||||
keyPrefix := s.service.conn.ConvertToKey(int(edgeStackID))
|
|
||||||
|
|
||||||
statuses := make([]portainer.EdgeStackStatusForEnv, 0)
|
|
||||||
|
|
||||||
if err := s.tx.GetAllWithKeyPrefix(BucketName, keyPrefix, &portainer.EdgeStackStatusForEnv{}, dataservices.AppendFn(&statuses)); err != nil {
|
|
||||||
return nil, fmt.Errorf("unable to retrieve EdgeStackStatus for EdgeStack %d: %w", edgeStackID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return statuses, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s ServiceTx) Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
|
|
||||||
identifier := s.service.key(edgeStackID, endpointID)
|
|
||||||
return s.tx.UpdateObject(BucketName, identifier, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s ServiceTx) Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error {
|
|
||||||
identifier := s.service.key(edgeStackID, endpointID)
|
|
||||||
return s.tx.DeleteObject(BucketName, identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s ServiceTx) DeleteAll(edgeStackID portainer.EdgeStackID) error {
|
|
||||||
keyPrefix := s.service.conn.ConvertToKey(int(edgeStackID))
|
|
||||||
|
|
||||||
statuses := make([]portainer.EdgeStackStatusForEnv, 0)
|
|
||||||
|
|
||||||
if err := s.tx.GetAllWithKeyPrefix(BucketName, keyPrefix, &portainer.EdgeStackStatusForEnv{}, dataservices.AppendFn(&statuses)); err != nil {
|
|
||||||
return fmt.Errorf("unable to retrieve EdgeStackStatus for EdgeStack %d: %w", edgeStackID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, status := range statuses {
|
|
||||||
if err := s.tx.DeleteObject(BucketName, s.service.key(edgeStackID, status.EndpointID)); err != nil {
|
|
||||||
return fmt.Errorf("unable to delete EdgeStackStatus for EdgeStack %d and Endpoint %d: %w", edgeStackID, status.EndpointID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s ServiceTx) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error {
|
|
||||||
for _, envID := range relatedEnvironmentsIDs {
|
|
||||||
existingStatus, err := s.Read(edgeStackID, envID)
|
|
||||||
if err != nil && !dataservices.IsErrObjectNotFound(err) {
|
|
||||||
return fmt.Errorf("unable to retrieve status for environment %d: %w", envID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var deploymentInfo portainer.StackDeploymentInfo
|
|
||||||
if existingStatus != nil {
|
|
||||||
deploymentInfo = existingStatus.DeploymentInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.Update(edgeStackID, envID, &portainer.EdgeStackStatusForEnv{
|
|
||||||
EndpointID: envID,
|
|
||||||
Status: []portainer.EdgeStackDeploymentStatus{},
|
|
||||||
DeploymentInfo: deploymentInfo,
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// BucketName represents the name of the bucket where this service stores data.
|
// BucketName represents the name of the bucket where this service stores data.
|
||||||
@@ -119,19 +117,6 @@ func (service *Service) Endpoints() ([]portainer.Endpoint, error) {
|
|||||||
return endpoints, nil
|
return endpoints, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
|
||||||
func (service *Service) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) {
|
|
||||||
var endpoints []portainer.Endpoint
|
|
||||||
var err error
|
|
||||||
|
|
||||||
err = service.connection.ViewTx(func(tx portainer.Transaction) error {
|
|
||||||
endpoints, err = service.Tx(tx).ReadAll(predicates...)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
|
|
||||||
return endpoints, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// EndpointIDByEdgeID returns the EndpointID from the given EdgeID using an in-memory index
|
// EndpointIDByEdgeID returns the EndpointID from the given EdgeID using an in-memory index
|
||||||
func (service *Service) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
|
func (service *Service) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
|
||||||
service.mu.RLock()
|
service.mu.RLock()
|
||||||
@@ -172,7 +157,6 @@ func (service *Service) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -182,13 +166,11 @@ func (service *Service) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.
|
|||||||
func (service *Service) GetNextIdentifier() int {
|
func (service *Service) GetNextIdentifier() int {
|
||||||
var identifier int
|
var identifier int
|
||||||
|
|
||||||
if err := service.connection.UpdateTx(func(tx portainer.Transaction) error {
|
service.connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
identifier = service.Tx(tx).GetNextIdentifier()
|
identifier = service.Tx(tx).GetNextIdentifier()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}); err != nil {
|
})
|
||||||
log.Error().Err(err).Str("bucket", BucketName).Msg("could not get the next identifier")
|
|
||||||
}
|
|
||||||
|
|
||||||
return identifier
|
return identifier
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ func (service ServiceTx) BucketName() string {
|
|||||||
// Endpoint returns an environment(endpoint) by ID.
|
// Endpoint returns an environment(endpoint) by ID.
|
||||||
func (service ServiceTx) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) {
|
func (service ServiceTx) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) {
|
||||||
var endpoint portainer.Endpoint
|
var endpoint portainer.Endpoint
|
||||||
|
|
||||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
if err := service.tx.GetObject(BucketName, identifier, &endpoint); err != nil {
|
err := service.tx.GetObject(BucketName, identifier, &endpoint)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,8 @@ func (service ServiceTx) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint,
|
|||||||
func (service ServiceTx) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error {
|
func (service ServiceTx) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error {
|
||||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
if err := service.tx.UpdateObject(BucketName, identifier, endpoint); err != nil {
|
err := service.tx.UpdateObject(BucketName, identifier, endpoint)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +45,6 @@ func (service ServiceTx) UpdateEndpoint(ID portainer.EndpointID, endpoint *porta
|
|||||||
if len(endpoint.EdgeID) > 0 {
|
if len(endpoint.EdgeID) > 0 {
|
||||||
service.service.idxEdgeID[endpoint.EdgeID] = ID
|
service.service.idxEdgeID[endpoint.EdgeID] = ID
|
||||||
}
|
}
|
||||||
|
|
||||||
service.service.heartbeats.Store(ID, endpoint.LastCheckInDate)
|
service.service.heartbeats.Store(ID, endpoint.LastCheckInDate)
|
||||||
service.service.mu.Unlock()
|
service.service.mu.Unlock()
|
||||||
|
|
||||||
@@ -57,7 +57,8 @@ func (service ServiceTx) UpdateEndpoint(ID portainer.EndpointID, endpoint *porta
|
|||||||
func (service ServiceTx) DeleteEndpoint(ID portainer.EndpointID) error {
|
func (service ServiceTx) DeleteEndpoint(ID portainer.EndpointID) error {
|
||||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
if err := service.tx.DeleteObject(BucketName, identifier); err != nil {
|
err := service.tx.DeleteObject(BucketName, identifier)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +70,6 @@ func (service ServiceTx) DeleteEndpoint(ID portainer.EndpointID) error {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
service.service.heartbeats.Delete(ID)
|
service.service.heartbeats.Delete(ID)
|
||||||
service.service.mu.Unlock()
|
service.service.mu.Unlock()
|
||||||
|
|
||||||
@@ -82,18 +82,13 @@ func (service ServiceTx) DeleteEndpoint(ID portainer.EndpointID) error {
|
|||||||
func (service ServiceTx) Endpoints() ([]portainer.Endpoint, error) {
|
func (service ServiceTx) Endpoints() ([]portainer.Endpoint, error) {
|
||||||
var endpoints = make([]portainer.Endpoint, 0)
|
var endpoints = make([]portainer.Endpoint, 0)
|
||||||
|
|
||||||
return endpoints, service.tx.GetAll(
|
return endpoints, service.tx.GetAllWithJsoniter(
|
||||||
BucketName,
|
BucketName,
|
||||||
&portainer.Endpoint{},
|
&portainer.Endpoint{},
|
||||||
dataservices.AppendFn(&endpoints),
|
dataservices.AppendFn(&endpoints),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
|
||||||
func (service ServiceTx) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) {
|
|
||||||
return dataservices.BaseDataServiceTx[portainer.Endpoint, portainer.EndpointID]{Bucket: BucketName, Connection: service.service.connection, Tx: service.tx}.ReadAll(predicates...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (service ServiceTx) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
|
func (service ServiceTx) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
|
||||||
log.Error().Str("func", "EndpointIDByEdgeID").Msg("cannot be called inside a transaction")
|
log.Error().Str("func", "EndpointIDByEdgeID").Msg("cannot be called inside a transaction")
|
||||||
|
|
||||||
@@ -112,7 +107,8 @@ func (service ServiceTx) UpdateHeartbeat(endpointID portainer.EndpointID) {
|
|||||||
|
|
||||||
// CreateEndpoint assign an ID to a new environment(endpoint) and saves it.
|
// CreateEndpoint assign an ID to a new environment(endpoint) and saves it.
|
||||||
func (service ServiceTx) Create(endpoint *portainer.Endpoint) error {
|
func (service ServiceTx) Create(endpoint *portainer.Endpoint) error {
|
||||||
if err := service.tx.CreateObjectWithId(BucketName, int(endpoint.ID), endpoint); err != nil {
|
err := service.tx.CreateObjectWithId(BucketName, int(endpoint.ID), endpoint)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +116,6 @@ func (service ServiceTx) Create(endpoint *portainer.Endpoint) error {
|
|||||||
if len(endpoint.EdgeID) > 0 {
|
if len(endpoint.EdgeID) > 0 {
|
||||||
service.service.idxEdgeID[endpoint.EdgeID] = endpoint.ID
|
service.service.idxEdgeID[endpoint.EdgeID] = endpoint.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
service.service.heartbeats.Store(endpoint.ID, endpoint.LastCheckInDate)
|
service.service.heartbeats.Store(endpoint.ID, endpoint.LastCheckInDate)
|
||||||
service.service.mu.Unlock()
|
service.service.mu.Unlock()
|
||||||
|
|
||||||
@@ -139,7 +134,6 @@ func (service ServiceTx) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
|||||||
func (service *Service) Create(endpointGroup *portainer.EndpointGroup) error {
|
func (service *Service) Create(endpointGroup *portainer.EndpointGroup) error {
|
||||||
return service.Connection.CreateObject(
|
return service.Connection.CreateObject(
|
||||||
BucketName,
|
BucketName,
|
||||||
func(id uint64) (int, any) {
|
func(id uint64) (int, interface{}) {
|
||||||
endpointGroup.ID = portainer.EndpointGroupID(id)
|
endpointGroup.ID = portainer.EndpointGroupID(id)
|
||||||
return int(endpointGroup.ID), endpointGroup
|
return int(endpointGroup.ID), endpointGroup
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user