This commit is contained in:
ElevenNotes
2025-06-18 11:31:29 +02:00
parent 856c2229df
commit 6f2c0e7f77
20 changed files with 1346 additions and 2 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
# default
.git*
maintain/
LICENSE
*.md
img/
node_modules/

5
.gitattributes vendored
View File

@@ -1,2 +1,3 @@
# Auto detect text files and perform LF normalization
* text=auto
# default
* text=auto
*.sh eol=lf

115
.github/workflows/cron.update.yml vendored Normal file
View File

@@ -0,0 +1,115 @@
name: cron-update
on:
workflow_dispatch:
schedule:
- cron: "0 5 * * *"
jobs:
cron-update:
runs-on: ubuntu-latest
permissions:
actions: read
contents: write
steps:
- name: init / checkout
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
with:
ref: 'master'
fetch-depth: 0
- name: cron-update / get latest version
run: |
echo "LATEST_VERSION=$(curl -s https://api.github.com/repos/netbirdio/netbird/releases/latest | jq -r '.tag_name' | sed 's/v//')" >> "${GITHUB_ENV}"
echo "LATEST_TAG=$(git describe --abbrev=0 --tags `git rev-list --tags --max-count=1` | sed 's/v//')" >> "${GITHUB_ENV}"
- name: cron-update / setup node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- run: npm i semver
- name: cron-update / compare latest with current version
uses: actions/github-script@62c3794a3eb6788d9a2a72b219504732c0c9a298
with:
script: |
const { existsSync, readFileSync, writeFileSync } = require('node:fs');
const { resolve } = require('node:path');
const { inspect } = require('node:util');
const semver = require('semver')
const repository = {dot:{}};
try{
const path = resolve('.json');
if(existsSync(path)){
try{
repository.dot = JSON.parse(readFileSync(path).toString());
}catch(e){
throw new Error('could not parse .json');
}
}else{
throw new Error('.json does not exist');
}
}catch(e){
core.setFailed(e);
}
const latest = semver.valid(semver.coerce('${{ env.LATEST_VERSION }}'));
const current = semver.valid(semver.coerce(repository.dot.semver.version));
const tag = semver.valid(semver.coerce('${{ env.LATEST_TAG }}'));
if(latest && latest !== current){
core.info(`new ${semver.diff(current, latest)} release found (${latest})!`)
repository.dot.semver.version = latest;
if(tag){
core.exportVariable('WORKFLOW_NEW_TAG', semver.inc(tag, semver.diff(current, latest)));
}
if(repository.dot.semver?.latest){
repository.dot.semver.latest = repository.dot.semver.version;
}
if(repository.dot?.readme?.comparison?.image){
repository.dot.readme.comparison.image = repository.dot.readme.comparison.image.replace(current, repository.dot.semver.version);
}
try{
writeFileSync(resolve('.json'), JSON.stringify(repository.dot, null, 2));
core.exportVariable('WORKFLOW_AUTO_UPDATE', true);
}catch(e){
core.setFailed(e);
}
}else{
core.info('no new release found');
}
core.info(inspect(repository.dot, {showHidden:false, depth:null, colors:true}));
- name: cron-update / checkout
id: checkout
if: env.WORKFLOW_AUTO_UPDATE == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .json
git commit -m "[upgrade] ${{ env.LATEST_VERSION }}"
git push origin HEAD:master
- name: cron-update / tag
if: env.WORKFLOW_AUTO_UPDATE == 'true' && steps.checkout.outcome == 'success'
run: |
SHA256=$(git rev-list --branches --max-count=1)
git tag -a v${{ env.WORKFLOW_NEW_TAG }} -m "v${{ env.WORKFLOW_NEW_TAG }}" ${SHA256}
git push --follow-tags
- name: cron-update / build docker image
if: env.WORKFLOW_AUTO_UPDATE == 'true' && steps.checkout.outcome == 'success'
uses: the-actions-org/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7
with:
workflow: docker.yml
wait-for-completion: false
token: "${{ secrets.REPOSITORY_TOKEN }}"
inputs: '{ "release":"true", "readme":"true" }'
ref: "v${{ env.WORKFLOW_NEW_TAG }}"

439
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,439 @@
name: docker
run-name: ${{ inputs.run-name }}
on:
workflow_dispatch:
inputs:
run-name:
description: 'set run-name for workflow (multiple calls)'
type: string
required: false
default: 'docker'
runs-on:
description: 'set runs-on for workflow (github or selfhosted)'
type: string
required: false
default: 'ubuntu-22.04'
build:
description: 'set WORKFLOW_BUILD'
required: false
default: 'true'
release:
description: 'set WORKFLOW_GITHUB_RELEASE'
required: false
default: 'false'
readme:
description: 'set WORKFLOW_GITHUB_README'
required: false
default: 'false'
etc:
description: 'base64 encoded json string'
required: false
jobs:
docker:
runs-on: ${{ inputs.runs-on }}
timeout-minutes: 1440
services:
registry:
image: registry:2
ports:
- 5000:5000
permissions:
actions: read
contents: write
packages: write
steps:
- name: init / checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
- name: init / setup environment
uses: actions/github-script@62c3794a3eb6788d9a2a72b219504732c0c9a298
with:
script: |
const { existsSync, readFileSync } = require('node:fs');
const { resolve } = require('node:path');
const { inspect } = require('node:util');
const { Buffer } = require('node:buffer');
const inputs = `${{ toJSON(github.event.inputs) }}`;
const opt = {input:{}, dot:{}};
try{
if(inputs.length > 0){
opt.input = JSON.parse(inputs);
if(opt.input?.etc){
opt.input.etc = JSON.parse(Buffer.from(opt.input.etc, 'base64').toString('ascii'));
}
}
}catch(e){
core.warning('could not parse github.event.inputs');
}
try{
const path = resolve('.json');
if(existsSync(path)){
try{
opt.dot = JSON.parse(readFileSync(path).toString());
}catch(e){
throw new Error('could not parse .json');
}
}else{
throw new Error('.json does not exist');
}
}catch(e){
core.setFailed(e);
}
core.info(inspect(opt, {showHidden:false, depth:null, colors:true}));
const docker = {
image:{
name:opt.dot.image,
arch:(opt.dot.arch || 'linux/amd64,linux/arm64'),
prefix:((opt.input?.etc?.semverprefix) ? `${opt.input?.etc?.semverprefix}-` : ''),
suffix:((opt.input?.etc?.semversuffix) ? `-${opt.input?.etc?.semversuffix}` : ''),
description:(opt.dot?.readme?.description || ''),
tags:[],
},
app:{
image:opt.dot.image,
name:opt.dot.name,
version:(opt.input?.etc?.version || opt.dot?.semver?.version),
root:opt.dot.root,
UID:(opt.input?.etc?.uid || 1000),
GID:(opt.input?.etc?.gid || 1000),
no_cache:new Date().getTime(),
},
cache:{
registry:'localhost:5000/',
},
tags:[],
};
docker.cache.name = `${docker.image.name}:${docker.image.prefix}buildcache${docker.image.suffix}`;
docker.cache.grype = `${docker.cache.registry}${docker.image.name}:${docker.image.prefix}grype${docker.image.suffix}`;
docker.app.prefix = docker.image.prefix;
docker.app.suffix = docker.image.suffix;
// setup tags
if(!opt.dot?.semver?.disable?.rolling){
docker.image.tags.push('rolling');
}
if(opt.input?.etc?.dockerfile !== 'arch.dockerfile' && opt.input?.etc?.tag){
docker.image.tags.push(`${context.sha.substring(0,7)}`);
docker.image.tags.push(opt.input.etc.tag);
docker.image.tags.push(`${opt.input.etc.tag}-${docker.app.version}`);
docker.cache.name = `${docker.image.name}:buildcache-${opt.input.etc.tag}`;
}else if(docker.app.version !== 'latest'){
const semver = docker.app.version.split('.');
docker.image.tags.push(`${context.sha.substring(0,7)}`);
if(Array.isArray(semver)){
if(semver.length >= 1) docker.image.tags.push(`${semver[0]}`);
if(semver.length >= 2) docker.image.tags.push(`${semver[0]}.${semver[1]}`);
if(semver.length >= 3) docker.image.tags.push(`${semver[0]}.${semver[1]}.${semver[2]}`);
}
if(opt.dot?.semver?.stable && new RegExp(opt.dot?.semver.stable, 'ig').test(docker.image.tags.join(','))) docker.image.tags.push('stable');
if(opt.dot?.semver?.latest && new RegExp(opt.dot?.semver.latest, 'ig').test(docker.image.tags.join(','))) docker.image.tags.push('latest');
}else{
docker.image.tags.push('latest');
}
for(const tag of docker.image.tags){
docker.tags.push(`${docker.image.name}:${docker.image.prefix}${tag}${docker.image.suffix}`);
docker.tags.push(`ghcr.io/${docker.image.name}:${docker.image.prefix}${tag}${docker.image.suffix}`);
docker.tags.push(`quay.io/${docker.image.name}:${docker.image.prefix}${tag}${docker.image.suffix}`);
}
// setup build arguments
if(opt.input?.etc?.build?.args){
for(const arg in opt.input.etc.build.args){
docker.app[arg] = opt.input.etc.build.args[arg];
}
}
if(opt.dot?.build?.args){
for(const arg in opt.dot.build.args){
docker.app[arg] = opt.dot.build.args[arg];
}
}
const arguments = [];
for(const argument in docker.app){
arguments.push(`APP_${argument.toUpperCase()}=${docker.app[argument]}`);
}
// export to environment
core.exportVariable('DOCKER_CACHE_REGISTRY', docker.cache.registry);
core.exportVariable('DOCKER_CACHE_NAME', docker.cache.name);
core.exportVariable('DOCKER_CACHE_GRYPE', docker.cache.grype);
core.exportVariable('DOCKER_IMAGE_NAME', docker.image.name);
core.exportVariable('DOCKER_IMAGE_ARCH', docker.image.arch);
core.exportVariable('DOCKER_IMAGE_TAGS', docker.tags.join(','));
core.exportVariable('DOCKER_IMAGE_DESCRIPTION', docker.image.description);
core.exportVariable('DOCKER_IMAGE_ARGUMENTS', arguments.join("\r\n"));
core.exportVariable('DOCKER_IMAGE_DOCKERFILE', opt.input?.etc?.dockerfile || 'arch.dockerfile');
core.exportVariable('WORKFLOW_BUILD', (opt.input?.build === undefined) ? false : opt.input.build);
core.exportVariable('WORKFLOW_CREATE_RELEASE', (opt.input?.release === undefined) ? false : opt.input.release);
core.exportVariable('WORKFLOW_CREATE_README', (opt.input?.readme === undefined) ? false : opt.input.readme);
core.exportVariable('WORKFLOW_GRYPE_FAIL_ON_SEVERITY', (opt.dot?.grype?.fail === undefined) ? true : opt.dot.grype.fail);
core.exportVariable('WORKFLOW_GRYPE_SEVERITY_CUTOFF', (opt.dot?.grype?.severity || 'high'));
if(opt.dot?.readme?.comparison){
core.exportVariable('WORKFLOW_CREATE_COMPARISON', true);
core.exportVariable('WORKFLOW_CREATE_COMPARISON_FOREIGN_IMAGE', opt.dot.readme.comparison.image);
core.exportVariable('WORKFLOW_CREATE_COMPARISON_IMAGE', `${docker.image.name}:${docker.app.version}`);
}
# DOCKER
- name: docker / login to hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772
with:
username: 11notes
password: ${{ secrets.DOCKER_TOKEN }}
- name: github / login to ghcr
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772
with:
registry: ghcr.io
username: 11notes
password: ${{ secrets.GITHUB_TOKEN }}
- name: quay / login to quay
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772
with:
registry: quay.io
username: 11notes+github
password: ${{ secrets.QUAY_TOKEN }}
- name: docker / setup qemu
if: env.WORKFLOW_BUILD == 'true'
uses: docker/setup-qemu-action@53851d14592bedcffcf25ea515637cff71ef929a
- name: docker / setup buildx
if: env.WORKFLOW_BUILD == 'true'
uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5
with:
driver-opts: network=host
- name: docker / build & push & tag grype
if: env.WORKFLOW_BUILD == 'true'
id: docker-build
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d
with:
context: .
file: ${{ env.DOCKER_IMAGE_DOCKERFILE }}
push: true
platforms: ${{ env.DOCKER_IMAGE_ARCH }}
cache-from: type=registry,ref=${{ env.DOCKER_CACHE_NAME }}
cache-to: type=registry,ref=${{ env.DOCKER_CACHE_REGISTRY }}${{ env.DOCKER_CACHE_NAME }},mode=max,compression=zstd,force-compression=true
build-args: |
${{ env.DOCKER_IMAGE_ARGUMENTS }}
tags: |
${{ env.DOCKER_CACHE_GRYPE }}
- name: grype / scan
if: env.WORKFLOW_BUILD == 'true'
id: grype
uses: anchore/scan-action@dc6246fcaf83ae86fcc6010b9824c30d7320729e
with:
image: ${{ env.DOCKER_CACHE_GRYPE }}
fail-build: ${{ env.WORKFLOW_GRYPE_FAIL_ON_SEVERITY }}
severity-cutoff: ${{ env.WORKFLOW_GRYPE_SEVERITY_CUTOFF }}
output-format: 'sarif'
by-cve: true
cache-db: true
- name: grype / fail
if: env.WORKFLOW_BUILD == 'true' && (failure() || steps.grype.outcome == 'failure')
uses: anchore/scan-action@dc6246fcaf83ae86fcc6010b9824c30d7320729e
with:
image: ${{ env.DOCKER_CACHE_GRYPE }}
fail-build: false
severity-cutoff: ${{ env.WORKFLOW_GRYPE_SEVERITY_CUTOFF }}
output-format: 'table'
by-cve: true
cache-db: true
- name: docker / build & push
if: env.WORKFLOW_BUILD == 'true'
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d
with:
context: .
file: ${{ env.DOCKER_IMAGE_DOCKERFILE }}
push: true
sbom: true
provenance: mode=max
platforms: ${{ env.DOCKER_IMAGE_ARCH }}
cache-from: type=registry,ref=${{ env.DOCKER_CACHE_REGISTRY }}${{ env.DOCKER_CACHE_NAME }}
cache-to: type=registry,ref=${{ env.DOCKER_CACHE_NAME }},mode=max,compression=zstd,force-compression=true
build-args: |
${{ env.DOCKER_IMAGE_ARGUMENTS }}
tags: |
${{ env.DOCKER_IMAGE_TAGS }}
# RELEASE
- name: github / release / log
continue-on-error: true
id: git-log
run: |
LOCAL_LAST_TAG=$(git describe --abbrev=0 --tags `git rev-list --tags --skip=1 --max-count=1`)
echo "using last tag: ${LOCAL_LAST_TAG}"
LOCAL_COMMITS=$(git log ${LOCAL_LAST_TAG}..HEAD --oneline)
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "commits<<${EOF}" >> ${GITHUB_OUTPUT}
echo "${LOCAL_COMMITS}" >> ${GITHUB_OUTPUT}
echo "${EOF}" >> ${GITHUB_OUTPUT}
- name: github / release / markdown
if: env.WORKFLOW_CREATE_RELEASE == 'true' && steps.git-log.outcome == 'success'
id: git-release
uses: 11notes/action-docker-release@v1
# WHY IS THIS ACTION NOT SHA256 PINNED? SECURITY MUCH?!?!?!
# ---------------------------------------------------------------------------------
# the next step "github / release / create" creates a new release based on the code
# in the repo. This code is not modified and can't be modified by this action.
# It does create the markdown for the release, which could be abused, but to what
# extend? Adding a link to a malicious repo?
with:
git_log: ${{ steps.git-log.outputs.commits }}
- name: github / release / create
if: env.WORKFLOW_CREATE_RELEASE == 'true' && steps.git-release.outcome == 'success'
uses: actions/create-release@4c11c9fe1dcd9636620a16455165783b20fc7ea0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
body: ${{ steps.git-release.outputs.release }}
draft: false
prerelease: false
# LICENSE
- name: license / update year
continue-on-error: true
uses: actions/github-script@62c3794a3eb6788d9a2a72b219504732c0c9a298
with:
script: |
const { existsSync, readFileSync, writeFileSync } = require('node:fs');
const { resolve } = require('node:path');
const file = 'LICENSE';
const year = new Date().getFullYear();
try{
const path = resolve(file);
if(existsSync(path)){
let license = readFileSync(file).toString();
if(!new RegExp(`Copyright \\(c\\) ${year} 11notes`, 'i').test(license)){
license = license.replace(/Copyright \(c\) \d{4} /i, `Copyright (c) ${new Date().getFullYear()} `);
writeFileSync(path, license);
}
}else{
throw new Error(`file ${file} does not exist`);
}
}catch(e){
core.setFailed(e);
}
# README
- name: github / checkout HEAD
continue-on-error: true
run: |
git checkout HEAD
- name: docker / setup comparison images
if: env.WORKFLOW_CREATE_COMPARISON == 'true'
continue-on-error: true
run: |
docker image pull ${{ env.WORKFLOW_CREATE_COMPARISON_IMAGE }}
docker image ls --filter "reference=${{ env.WORKFLOW_CREATE_COMPARISON_IMAGE }}" --format json | jq --raw-output '.Size' &> ./comparison.size0.log
docker image pull ${{ env.WORKFLOW_CREATE_COMPARISON_FOREIGN_IMAGE }}
docker image ls --filter "reference=${{ env.WORKFLOW_CREATE_COMPARISON_FOREIGN_IMAGE }}" --format json | jq --raw-output '.Size' &> ./comparison.size1.log
docker run --entrypoint "/bin/sh" --rm ${{ env.WORKFLOW_CREATE_COMPARISON_FOREIGN_IMAGE }} -c id &> ./comparison.id.log
- name: github / create README.md
id: github-readme
continue-on-error: true
if: env.WORKFLOW_CREATE_README == 'true'
uses: 11notes/action-docker-readme@v1
# WHY IS THIS ACTION NOT SHA256 PINNED? SECURITY MUCH?!?!?!
# ---------------------------------------------------------------------------------
# the next step "github / commit & push" only adds the README and LICENSE as well as
# compose.yaml to the repository. This does not pose a security risk if this action
# would be compromised. The code of the app can't be changed by this action. Since
# only the files mentioned are commited to the repo. Sure, someone could make a bad
# compose.yaml, but since this serves only as an example I see no harm in that.
with:
sarif_file: ${{ steps.grype.outputs.sarif }}
build_output_metadata: ${{ steps.docker-build.outputs.metadata }}
- name: docker / push README.md to docker hub
continue-on-error: true
if: steps.github-readme.outcome == 'success' && hashFiles('README_NONGITHUB.md') != ''
uses: christian-korneck/update-container-description-action@d36005551adeaba9698d8d67a296bd16fa91f8e8
env:
DOCKER_USER: 11notes
DOCKER_PASS: ${{ secrets.DOCKER_TOKEN }}
with:
destination_container_repo: ${{ env.DOCKER_IMAGE_NAME }}
provider: dockerhub
short_description: ${{ env.DOCKER_IMAGE_DESCRIPTION }}
readme_file: 'README_NONGITHUB.md'
- name: github / commit & push
continue-on-error: true
if: steps.github-readme.outcome == 'success' && hashFiles('README.md') != ''
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add README.md
if [ -f compose.yaml ]; then
git add compose.yaml
fi
if [ -f LICENSE ]; then
git add LICENSE
fi
git commit -m "github-actions[bot]: update README.md"
git push origin HEAD:master
# REPOSITORY SETTINGS
- name: github / update description and set repo defaults
run: |
curl --request PATCH \
--url https://api.github.com/repos/${{ github.repository }} \
--header 'authorization: Bearer ${{ secrets.REPOSITORY_TOKEN }}' \
--header 'content-type: application/json' \
--data '{
"description":"${{ env.DOCKER_IMAGE_DESCRIPTION }}",
"homepage":"",
"has_issues":true,
"has_discussions":true,
"has_projects":false,
"has_wiki":false
}' \
--fail

16
.github/workflows/readme.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: readme
on:
workflow_dispatch:
jobs:
readme:
runs-on: ubuntu-latest
steps:
- name: update README.md
uses: the-actions-org/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7
with:
wait-for-completion: false
workflow: docker.yml
token: "${{ secrets.REPOSITORY_TOKEN }}"
inputs: '{ "build":"false", "release":"false", "readme":"true" }'

102
.github/workflows/tags.yml vendored Normal file
View File

@@ -0,0 +1,102 @@
name: tags
on:
push:
tags:
- 'v*'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: build docker image
uses: the-actions-org/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7
with:
workflow: docker.yml
token: "${{ secrets.REPOSITORY_TOKEN }}"
inputs: '{ "release":"true", "readme":"true" }'
docker-unraid:
runs-on: ubuntu-latest
steps:
- name: init / base64 nested json
uses: actions/github-script@62c3794a3eb6788d9a2a72b219504732c0c9a298
with:
script: |
const { Buffer } = require('node:buffer');
const etc = {
semversuffix:"unraid",
uid:99,
gid:100,
};
core.exportVariable('WORKFLOW_BASE64JSON', Buffer.from(JSON.stringify(etc)).toString('base64'));
- name: build docker image for unraid community
uses: the-actions-org/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7
with:
workflow: docker.yml
token: "${{ secrets.REPOSITORY_TOKEN }}"
inputs: '{ "release":"false", "readme":"false", "run-name":"unraid", "etc":"${{ env.WORKFLOW_BASE64JSON }}" }'
kms-gui:
runs-on: ubuntu-latest
needs: docker
steps:
- name: init / base64 nested json
uses: actions/github-script@62c3794a3eb6788d9a2a72b219504732c0c9a298
with:
script: |
const { Buffer } = require('node:buffer');
(async()=>{
try{
const master = await fetch('https://raw.githubusercontent.com/11notes/docker-kms/refs/heads/master/.json');
const dot = await master.json();
const etc = {
version:dot.semver.version,
};
core.exportVariable('WORKFLOW_BASE64JSON', Buffer.from(JSON.stringify(etc)).toString('base64'));
}catch(e){
core.setFailed(`workflow failed: ${e}`);
}
})();
- name: build downstream kms gui
uses: the-actions-org/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7
with:
workflow: docker.yml
token: "${{ secrets.REPOSITORY_TOKEN }}"
repo: 11notes/docker-kms-gui
ref: master
inputs: '{ "release":"false", "readme":"true", "etc":"${{ env.WORKFLOW_BASE64JSON }}" }'
kms-gui-unraid:
runs-on: ubuntu-latest
needs: docker-unraid
steps:
- name: init / base64 nested json
uses: actions/github-script@62c3794a3eb6788d9a2a72b219504732c0c9a298
with:
script: |
const { Buffer } = require('node:buffer');
(async()=>{
try{
const master = await fetch('https://raw.githubusercontent.com/11notes/docker-kms/refs/heads/master/.json');
const dot = await master.json();
const etc = {
version:dot.semver.version,
semversuffix:"unraid",
uid:99,
gid:100,
};
core.exportVariable('WORKFLOW_BASE64JSON', Buffer.from(JSON.stringify(etc)).toString('base64'));
}catch(e){
core.setFailed(`workflow failed: ${e}`);
}
})();
- name: build downstream kms gui for unraid community
uses: the-actions-org/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7
with:
workflow: docker.yml
token: "${{ secrets.REPOSITORY_TOKEN }}"
repo: 11notes/docker-kms-gui
ref: master
inputs: '{ "release":"false", "readme":"false", "run-name":"unraid", "etc":"${{ env.WORKFLOW_BASE64JSON }}" }'

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
# default
maintain/
node_modules/
# custom
.env

17
.json Normal file
View File

@@ -0,0 +1,17 @@
{
"image":"11notes/netbird",
"name":"netbird",
"root":"/netbird",
"arch":"linux/amd64,linux/arm64,linux/arm/v7",
"semver":{
"version":"1.0.2"
},
"readme":{
"description":"Activate any version of Windows and Office, forever",
"built":{
"netbirdio/netbird":"https://github.com/netbirdio/netbird"
}
}
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 11notes
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

151
arch.dockerfile Normal file
View File

@@ -0,0 +1,151 @@
# ╔═════════════════════════════════════════════════════╗
# ║ SETUP ║
# ╚═════════════════════════════════════════════════════╝
# GLOBAL
ARG APP_UID=1000 \
APP_GID=1000 \
BUILD_ROOT="/go/netbird/management /go/netbird/relay /go/netbird/signal"
# :: FOREIGN IMAGES
FROM 11notes/nginx:stable AS distroless-nginx
FROM 11notes/distroless:curl AS distroless-curl
FROM 11notes/util AS util
# ╔═════════════════════════════════════════════════════╗
# ║ BUILD ║
# ╚═════════════════════════════════════════════════════╝
# :: netbird
FROM golang:1.24-alpine AS build
COPY --from=util /usr/local/bin /usr/local/bin
ARG APP_VERSION \
BUILD_ROOT \
BUILD_BIN
ENV CGO_ENABLED=0
RUN set -ex; \
apk --update --no-cache add \
build-base \
upx \
git;
RUN set -ex; \
git clone https://github.com/netbirdio/netbird -b v${APP_VERSION};
RUN set -ex; \
mkdir -p /distroless/usr/local/bin; \
for BUILD in ${BUILD_ROOT}; do \
cd ${BUILD}; \
BIN="${BUILD}/$(echo ${BUILD} | awk -F '/' '{print $4}')"; \
go build -ldflags="-extldflags=-static" -o ${BIN} main.go; \
eleven checkStatic ${BIN}; \
eleven strip ${BIN}; \
cp ${BIN} /distroless/usr/local/bin; \
done; \
mv /distroless/usr/local/bin/management /distroless/usr/local/bin/netbird;
# :: management
FROM golang:1.24-alpine AS management
COPY --from=util /usr/local/bin /usr/local/bin
COPY ./build/go/management /go/management
ENV CGO_ENABLED=0
ARG BIN=/go/management/management
RUN set -ex; \
apk --update --no-cache add \
build-base \
upx;
RUN set -ex; \
cd /go/management; \
go build -ldflags="-extldflags=-static" -o ${BIN} main.go; \
mkdir -p /distroless/usr/local/bin; \
eleven checkStatic ${BIN}; \
eleven strip ${BIN}; \
cp ${BIN} /distroless/usr/local/bin;
# :: dashboard
FROM golang:1.24-alpine AS dashboard
COPY --from=util /usr/local/bin /usr/local/bin
COPY ./build/go/dashboard /go/dashboard
ENV CGO_ENABLED=0
ARG BIN=/go/dashboard/dashboard
RUN set -ex; \
apk --update --no-cache add \
build-base \
upx \
git \
nodejs \
npm;
RUN set -ex; \
cd /go/dashboard; \
go build -ldflags="-extldflags=-static" -o ${BIN} main.go; \
mkdir -p /distroless/usr/local/bin; \
eleven checkStatic ${BIN}; \
eleven strip ${BIN}; \
cp ${BIN} /distroless/usr/local/bin;
RUN set -ex; \
git clone https://github.com/netbirdio/dashboard /dashboard;
RUN set -ex; \
cd /dashboard; \
npm i --save; \
echo '{}' > .local-config.json; \
npm run build; \
mkdir -p /distroless/nginx/var; \
cp -R ./out/* /distroless/nginx/var;
# :: file system
FROM alpine AS file-system
COPY --from=util /usr/local/bin /usr/local/bin
ARG APP_ROOT
USER root
RUN set -ex; \
eleven mkdir /distroless${APP_ROOT}/{etc,var}; \
mkdir -p /distroless/var/lib; \
ln -sf ${APP_ROOT}/var /distroless/var/lib/netbird;
# ╔═════════════════════════════════════════════════════╗
# ║ IMAGE ║
# ╚═════════════════════════════════════════════════════╝
# :: HEADER
FROM scratch
# :: default arguments
ARG TARGETPLATFORM \
TARGETOS \
TARGETARCH \
TARGETVARIANT \
APP_IMAGE \
APP_NAME \
APP_VERSION \
APP_ROOT \
APP_UID \
APP_GID \
APP_NO_CACHE
# :: default environment
ENV APP_IMAGE=${APP_IMAGE} \
APP_NAME=${APP_NAME} \
APP_VERSION=${APP_VERSION} \
APP_ROOT=${APP_ROOT}
# :: multi-stage
COPY --from=build /distroless/ /
COPY --from=dashboard --chown=${APP_UID}:${APP_GID} /distroless/ /
COPY --from=management --chown=${APP_UID}:${APP_GID} /distroless/ /
COPY --from=distroless-nginx --chown=${APP_UID}:${APP_GID} / /
COPY --from=file-system --chown=${APP_UID}:${APP_GID} /distroless/ /
COPY --from=distroless-curl /usr/local/bin /usr/local/bin
COPY --chown=${APP_UID}:${APP_GID} ./rootfs/ /
# :: PERSISTENT DATA
VOLUME ["${APP_ROOT}/etc", "${APP_ROOT}/var"]
# :: EXECUTE
USER ${APP_UID}:${APP_GID}

View File

@@ -0,0 +1,2 @@
module github.com/11notes/docker-netbird
go 1.24

View File

View File

@@ -0,0 +1,91 @@
package main
import (
"fmt"
"io/ioutil"
"strings"
"path/filepath"
"io"
"os"
"regexp"
"syscall"
"time"
)
const TrustedDomainsTemplate = "/nginx/var/OidcTrustedDomains.js.tmpl"
const TrustedDomains = "/nginx/var/OidcTrustedDomains.js"
func logInfo(s string){
log(os.Stdout, fmt.Sprintf("INFO %s", s))
}
func logError(s string){
log(os.Stderr, fmt.Sprintf("ERROR %s", s))
}
func log(r io.Writer, s string) {
fmt.Fprintf(r, "%s %s\n", time.Now().Format(time.RFC3339), s)
}
func main() {
// find all the files that contain AUTH_SUPPORTED_SCOPES
err := filepath.Walk("/nginx/var",
func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
text, _ := ioutil.ReadFile(path)
if strings.Contains(string(text), "AUTH_SUPPORTED_SCOPES") {
replaceEnv(path, string(text))
}
return nil
})
if err != nil {
logError(fmt.Sprintf("filepath.Walk(\"/nginx/var\"): %s", err))
os.Exit(1)
}
// replace custom files
custom := []string{TrustedDomainsTemplate}
for _, path := range custom {
text, _ := ioutil.ReadFile(path)
replaceEnv(path, string(text))
}
// rename tmpl
os.Rename(TrustedDomainsTemplate, TrustedDomains)
logInfo("starting dashboard")
if err = syscall.Exec("/usr/local/bin/nginx", []string{}, os.Environ()); err != nil {
os.Exit(1)
}
}
func replaceEnv(path string, text string){
nextjs := []string{"USE_AUTH0", "AUTH_AUDIENCE", "AUTH_AUTHORITY", "AUTH_CLIENT_ID", "AUTH_CLIENT_SECRET", "AUTH_SUPPORTED_SCOPES", "NETBIRD_MGMT_API_ENDPOINT", "NETBIRD_MGMT_GRPC_API_ENDPOINT", "NETBIRD_HOTJAR_TRACK_ID", "NETBIRD_GOOGLE_ANALYTICS_ID", "NETBIRD_GOOGLE_TAG_MANAGER_ID", "AUTH_REDIRECT_URI", "AUTH_SILENT_REDIRECT_URI", "NETBIRD_TOKEN_SOURCE", "NETBIRD_DRAG_QUERY_PARAMS"}
// replace all environment variables in file
for _, e := range os.Environ() {
key := strings.Split(e, "=")[0]
value := os.Getenv(key)
text = string(regexp.MustCompile(fmt.Sprintf(`\$%s`, key)).ReplaceAllString(text, value))
}
// replace all not set environment variables in file
for _, e := range nextjs {
text = string(regexp.MustCompile(fmt.Sprintf(`\$%s`, e)).ReplaceAllString(text, ""))
}
err := ioutil.WriteFile(path, []byte(text), os.ModePerm)
if err != nil {
logError(fmt.Sprintf("ioutil.WriteFile(%s): %s", path, err))
os.Exit(3)
}
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}

View File

@@ -0,0 +1,2 @@
module github.com/11notes/docker-netbird
go 1.24

View File

View File

@@ -0,0 +1,49 @@
package main
import (
"fmt"
"io/ioutil"
"strings"
"os"
"regexp"
"syscall"
)
func main() {
custom := []string{"/netbird/etc/management.json"}
for _, path := range custom {
text, _ := ioutil.ReadFile(path)
replaceEnv(path, string(text))
}
if err := syscall.Exec("/usr/local/bin/netbird", []string{"management", "management", "--config", "/netbird/etc/management.json", "--log-file", "console", "--log-level", "info", "--disable-anonymous-metrics", "--disable-geolite-update"}, os.Environ()); err != nil {
os.Exit(1)
}
}
func replaceEnv(path string, text string){
// replace all environment variables in file
for _, e := range os.Environ() {
key := strings.Split(e, "=")[0]
value := os.Getenv(key)
text = string(regexp.MustCompile(fmt.Sprintf(`\${%s}`, key)).ReplaceAllString(text, value))
}
// replace all not set environment variables in file
uenv := regexp.MustCompile(`\$\{[A-Z_a-z]+\}`).FindAllString(text, -1)
for _, e := range uenv {
fmt.Printf("variable %s not set, will be set to empty string!\n", e)
text = string(regexp.MustCompile(fmt.Sprintf(`%s`, e)).ReplaceAllString(text, ""))
}
err := ioutil.WriteFile(path, []byte(text), os.ModePerm)
if err != nil {
os.Exit(3)
}
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}

142
compose.yaml Normal file
View File

@@ -0,0 +1,142 @@
name: "netbird"
services:
db:
image: "11notes/postgres:16"
read_only: true
environment:
TZ: "Europe/Zurich"
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- "db.etc:/postgres/etc"
- "db.var:/postgres/var"
- "db.backup:/postgres/backup"
- "db.cmd:/run/cmd"
tmpfs:
- "/run/postgresql:uid=1000,gid=1000"
- "/postgres/log:uid=1000,gid=1000"
networks:
backend:
restart: "always"
cron:
depends_on:
db:
condition: "service_healthy"
restart: true
image: "11notes/cron:4.6"
environment:
TZ: "Europe/Zurich"
CRONTAB: |-
0 3 * * * cmd-socket '{"bin":"backup"}' > /proc/1/fd/1
volumes:
- "db.cmd:/run/cmd"
restart: "always"
dashboard:
image: "11notes/netbird:0.46.0"
read_only: true
environment:
NETBIRD_MGMT_API_ENDPOINT: "https://${NETBIRD_FQDN}"
NETBIRD_MGMT_GRPC_API_ENDPOINT: "https://${NETBIRD_FQDN}"
AUTH_AUDIENCE: "netbird-client"
AUTH_CLIENT_ID: "netbird-client"
AUTH_CLIENT_SECRET:
AUTH_AUTHORITY: "https://${KEYCLOAK_FQDN}/realms/${KEYCLOAK_REALM}"
USE_AUTH0: false
AUTH_SUPPORTED_SCOPES: "openid"
NETBIRD_TOKEN_SOURCE: "accessToken"
entrypoint: ["/usr/local/bin/dashboard"]
volumes:
- "dashboard.var:/nginx/var"
tmpfs:
- "/nginx/cache:uid=1000,gid=1000"
- "/nginx/run:uid=1000,gid=1000"
networks:
frontend:
ports:
- "3000:3000/tcp"
healthcheck:
test: ["CMD", "/usr/local/bin/curl", "-kILs", "--fail", "http://localhost:3000/ping"]
interval: 5s
timeout: 2s
start_period: 5s
restart: "always"
management:
depends_on:
db:
condition: "service_healthy"
restart: true
image: "11notes/netbird:0.46.0"
read_only: true
env_file: '.env'
environment:
TZ: "Europe/Zurich"
NETBIRD_STORE_ENGINE_POSTGRES_DSN: "host=db user=postgres password=${POSTGRES_PASSWORD} dbname=postgres port=5432"
NB_ACTIVITY_EVENT_STORE_ENGINE: "postgres"
NB_ACTIVITY_EVENT_POSTGRES_DSN: "host=db user=postgres password=${POSTGRES_PASSWORD} dbname=postgres port=5432"
entrypoint: ["/usr/local/bin/management"]
volumes:
- "management.etc:/netbird/etc"
- "management.var:/netbird/var"
networks:
frontend:
backend:
ports:
- "3080:80/tcp"
- "33073:33073/tcp"
sysctls:
net.ipv4.ip_unprivileged_port_start: 80
healthcheck:
test: ["CMD", "/usr/local/bin/curl", "-kILs", "--fail", "http://localhost:9090/metrics"]
interval: 5s
timeout: 2s
start_period: 5s
restart: "always"
signal:
image: "11notes/netbird:0.46.0"
environment:
TZ: "Europe/Zurich"
entrypoint: ["/usr/local/bin/signal"]
command: [
"run",
"--log-file", "console",
"--log-level", "info"
]
volumes:
- "signal.var:/netbird/var"
networks:
frontend:
ports:
- "10000:10000/tcp"
restart: "always"
relay:
image: "11notes/netbird:0.46.0"
environment:
TZ: "Europe/Zurich"
NB_LISTEN_ADDRESS: ":33080"
NB_EXPOSED_ADDRESS: "rels://${NETBIRD_FQDN}:443"
NB_AUTH_SECRET: ${NETBIRD_RELAY_SECRET}
entrypoint: ["/usr/local/bin/relay"]
networks:
frontend:
ports:
- "33080:33080/tcp"
restart: "always"
volumes:
management.etc:
management.var:
dashboard.var:
signal.var:
db.etc:
db.var:
db.backup:
db.cmd:
networks:
frontend:
backend:
internal: true

64
project.md Normal file
View File

@@ -0,0 +1,64 @@
${{ content_synopsis }} This image will run netbird from a single image (not multiple) rootless and distroless for more security. Due to the nature of a single image and not multiple, you see in the [compose.yaml](https://github.com/11notes/docker-netbird/blob/master/compose.yaml) example that an ```entrypoint:``` has been defined. This image also needs some environment variables present in your **.env** file. This image's defaults (management.json) as well as the example **.env** are to be used with Keycloak as your IdP. You can however provide your own **management.json** file and use any IdP you like.
${{ github:> [!IMPORTANT] }}
${{ github:> }}* This image runs as 1000:1000 by default, most other images run everything as root
${{ github:> }}* This image has no shell since it is distroless, most other images run on a distro like Debian or Alpine with full shell access (security)
${{ github:> }}* This image does not ship with any critical or high rated CVE and is automatically maintained via CI/CD, most other images mostly have no CVE scanning or code quality tools in place
${{ github:> }}* This image is created via a secure, pinned CI/CD process and immune to upstream attacks, most other images have upstream dependencies that can be exploited
${{ github:> }}* This image works as read-only, most other images need to write files to the image filesystem
${{ github:> }}* This image is a lot smaller than most other images
If you value security, simplicity and the ability to interact with the maintainer and developer of an image. Using my images is a great start in that direction.
# COMPARISON 🏁
Below you find a comparison between this image and the most used or original one.
| **image** | 11notes/netbird | netbirdio/* |
| ---: | :---: | :---: |
| **image size on disk** | 44.6MB | 377.9MB |
| **process UID/GID** | 1000/1000 | 0/0 |
| **distroless?** | ✅ | ❌ |
| **rootless?** | ✅ | ❌ |
${{ title_volumes }}
* **${{ json_root }}/etc** - Directory of your management.json config
* **${{ json_root }}/var** - Directory of dynamic data from differnet init systems (relay, signal, management)
# EXAMPLE ENV FILE 📑
```ini
# postgres settings
POSTGRES_PASSWORD=
# netbird settings
NETBIRD_RELAY_SECRET=
NETBIRD_DATASTORE_ENCRYPTION_KEY=
NETBIRD_FQDN=netbird.domain.com
# Keycloak settings
KEYCLOAK_FQDN=keycloak.domain.com
KEYCLOAK_REALM=netbird
KEYCLOAK_CLIENT_SECRET=
# STUN/TURN configuration
STUN_FQDN_AND_PORT=turn.domain.com:5349
TURN_FQDN_AND_PORT=turn.domain.com:5349
TURN_SECRET=
```
${{ content_compose }}
${{ content_defaults }}
${{ content_environment }}
${{ content_source }}
${{ content_parent }}
${{ content_built }}
${{ content_tips }}
${{ title_caution }}
${{ github:> [!CAUTION] }}
${{ github:> }}* Because this image is distroless, it only works with PostgreSQL, not SQLite. The GeoLocation middleware is also disabled because of this!

View File

@@ -0,0 +1,98 @@
{
"Stuns": [
{
"Proto": "udp",
"URI": "stun:${STUN_FQDN_AND_PORT}",
"Secret": "$STUN_SECRET"
}
],
"TURNConfig": {
"Turns": [
{
"Proto": "udp",
"URI": "turn:${TURN_FQDN_AND_PORT}"
}
],
"CredentialsTTL": "12h",
"Secret": "${TURN_SECRET}",
"TimeBasedCredentials": false
},
"Relay": {
"Addresses": ["rels://${NETBIRD_FQDN}:443/relay"],
"CredentialsTTL": "24h",
"Secret": "${NETBIRD_RELAY_SECRET}"
},
"Signal": {
"Proto": "https",
"URI": "${NETBIRD_FQDN}:443",
"Username": "",
"Password": null
},
"ReverseProxy": {
"TrustedHTTPProxies": [],
"TrustedHTTPProxiesCount": 0,
"TrustedPeers": [
"0.0.0.0/0"
]
},
"Datadir": "/netbird/etc",
"DataStoreEncryptionKey": "${NETBIRD_DATASTORE_ENCRYPTION_KEY}",
"StoreConfig": {
"Engine": "postgres"
},
"HttpConfig": {
"Address": "0.0.0.0:33073",
"AuthIssuer": "https://${KEYCLOAK_FQDN}/realms/${KEYCLOAK_REALM}",
"AuthAudience": "netbird-client",
"AuthKeysLocation": "https://${KEYCLOAK_FQDN}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/certs",
"AuthUserIDClaim": "",
"IdpSignKeyRefreshEnabled": false,
"OIDCConfigEndpoint":"https://${KEYCLOAK_FQDN}/realms/${KEYCLOAK_REALM}/.well-known/openid-configuration"
},
"IdpManagerConfig": {
"ManagerType": "keycloak",
"ClientConfig": {
"ClientID": "netbird-backend",
"ClientSecret": "${KEYCLOAK_CLIENT_SECRET}",
"TokenEndpoint": "https://${KEYCLOAK_FQDN}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token",
"GrantType": "client_credentials"
},
"ExtraConfig": {
"AdminEndpoint": "https://${KEYCLOAK_FQDN}/admin/realms/${KEYCLOAK_REALM}",
"Auth0ClientCredentials": null,
"AzureClientCredentials": null,
"KeycloakClientCredentials": null,
"ZitadelClientCredentials": null
},
"DeviceAuthorizationFlow": {
"Provider": "hosted",
"ProviderConfig": {
"Audience": "netbird-client",
"AuthorizationEndpoint": "",
"Domain": "",
"ClientID": "netbird-client",
"ClientSecret": "",
"TokenEndpoint": "https://${KEYCLOAK_FQDN}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token",
"DeviceAuthEndpoint": "https://${KEYCLOAK_FQDN}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth/device",
"Scope": "openid",
"UseIDToken": false,
"RedirectURLs": null
}
}
},
"PKCEAuthorizationFlow": {
"ProviderConfig": {
"Audience": "netbird-client",
"ClientID": "netbird-client",
"ClientSecret": "",
"Domain": "",
"AuthorizationEndpoint": "https://${KEYCLOAK_FQDN}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth",
"TokenEndpoint": "https://${KEYCLOAK_FQDN}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token",
"Scope": "openid",
"RedirectURLs": ["http://localhost:53000"],
"UseIDToken": false,
"DisablePromptLogin": false,
"LoginFlag": 1
}
}
}

View File

@@ -0,0 +1,21 @@
server {
listen 3000 default_server;
root /nginx/var;
location / {
try_files $uri $uri.html $uri/ =404;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
expires off;
}
error_page 404 /404.html;
location = /404.html {
internal;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
expires off;
}
location /ping {
return 200;
}
}