Compare commits

..

1 Commits

Author SHA1 Message Date
prabhat khera
bf6d205d78 fix(api): api error message [EE-6818] 2024-03-13 12:24:33 +13:00
3766 changed files with 77705 additions and 173946 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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

View 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
View File

@@ -18,5 +18,3 @@ api/docs
.env .env
go.work.sum go.work.sum
.vitest

1
.godir Normal file
View File

@@ -0,0 +1 @@
portainer

View File

@@ -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

View File

@@ -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$

View File

@@ -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

View File

@@ -1,3 +1,2 @@
dist dist
api/datastore/test_data api/datastore/test_data
coverage

View File

@@ -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) => {

View File

@@ -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];

View 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"]
}
]
}

View 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 } }\""
}
}

View 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"]
}

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
} }
}) })

View File

@@ -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
} }

View File

@@ -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)

View File

@@ -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)
} }

View File

@@ -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)

View File

@@ -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.

View File

@@ -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,

View File

@@ -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)
} }

View File

@@ -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")
} }

View File

@@ -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"))

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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
} }

View File

@@ -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
View 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

View File

@@ -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
View 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()
}

View File

@@ -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 {

View File

@@ -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)
} }

View File

@@ -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
} }

View File

@@ -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)
}
})
}
}

View File

@@ -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

View File

@@ -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
})
}

View File

@@ -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
} }

View File

@@ -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"
) )

View File

@@ -18,5 +18,4 @@ const (
defaultSnapshotInterval = "5m" defaultSnapshotInterval = "5m"
defaultBaseURL = "/" defaultBaseURL = "/"
defaultSecretKeyName = "portainer" defaultSecretKeyName = "portainer"
defaultPullLimitCheckDisabled = "false"
) )

View File

@@ -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
View 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
}

View File

@@ -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")
}
}

View File

@@ -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")
} }
} }

View File

@@ -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))
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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))
}

View File

@@ -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)
})
}
} }

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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))
} }

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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
} }

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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
} }

View File

@@ -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")
} }

View File

@@ -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)
}
}

View File

@@ -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() {

View File

@@ -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")
}
} }

View File

@@ -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
} }

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
}) })

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
})
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
} }

View File

@@ -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
} }

View File

@@ -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)
}

View File

@@ -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
} }

View File

@@ -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))...)
}

View File

@@ -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
}

View File

@@ -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
} }

View File

@@ -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
}), }),
) )

View File

@@ -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