diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d9c826c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.gitignore +.dockerignore +pangolin-cli +*.json +README.md +Makefile +public/ +LICENSE +CONTRIBUTING.md \ No newline at end of file diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 0000000..fc4bfbf --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,616 @@ +name: CI/CD Pipeline + +permissions: + contents: write # gh-release + packages: write # GHCR push + id-token: write # Keyless-Signatures & Attestations + attestations: write # actions/attest-build-provenance + security-events: write # upload-sarif + actions: read + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" + + workflow_dispatch: + inputs: + version: + description: "SemVer version to release (e.g., 1.2.3, no leading 'v')" + required: true + type: string + publish_latest: + description: "Also publish the 'latest' image tag" + required: true + type: boolean + default: false + publish_minor: + description: "Also publish the 'major.minor' image tag (e.g., 1.2)" + required: true + type: boolean + default: false + target_branch: + description: "Branch to tag" + required: false + default: "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }} + cancel-in-progress: true + +jobs: + prepare: + if: github.event_name == 'workflow_dispatch' + name: Prepare release (create tag) + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + + - name: Validate version input + shell: bash + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if ! [[ "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "Invalid version: $INPUT_VERSION (expected X.Y.Z or X.Y.Z-rc.N)" >&2 + exit 1 + fi + - name: Create and push tag + shell: bash + env: + TARGET_BRANCH: ${{ inputs.target_branch }} + VERSION: ${{ inputs.version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch --prune origin + git checkout "$TARGET_BRANCH" + git pull --ff-only origin "$TARGET_BRANCH" + if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then + echo "Tag $VERSION already exists" >&2 + exit 1 + fi + git tag -a "$VERSION" -m "Release $VERSION" + git push origin "refs/tags/$VERSION" + release: + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.actor != 'github-actions[bot]') }} + name: Build and Release + runs-on: ubuntu-24.04 + timeout-minutes: 120 + env: + DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + + - name: Capture created timestamp + run: echo "IMAGE_CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV + shell: bash + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Log in to Docker Hub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: docker.io + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Normalize image names to lowercase + run: | + set -euo pipefail + echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV" + echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" + shell: bash + + - name: Extract tag name + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "TAG=${INPUT_VERSION}" >> $GITHUB_ENV + else + echo "TAG=${{ github.ref_name }}" >> $GITHUB_ENV + fi + shell: bash + + - name: Validate pushed tag format (no leading 'v') + if: ${{ github.event_name == 'push' }} + shell: bash + env: + TAG_GOT: ${{ env.TAG }} + run: | + set -euo pipefail + if [[ "$TAG_GOT" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "Tag OK: $TAG_GOT" + exit 0 + fi + echo "ERROR: Tag '$TAG_GOT' is not allowed. Use 'X.Y.Z' or 'X.Y.Z-rc.N' (no leading 'v')." >&2 + exit 1 + - name: Wait for tag to be visible (dispatch only) + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + set -euo pipefail + for i in {1..90}; do + if git ls-remote --tags origin "refs/tags/${TAG}" | grep -qE "refs/tags/${TAG}$"; then + echo "Tag ${TAG} is visible on origin"; exit 0 + fi + echo "Tag not yet visible, retrying... ($i/90)" + sleep 2 + done + echo "Tag ${TAG} not visible after waiting"; exit 1 + shell: bash + + - name: Update version in main.go + run: | + TAG=${{ env.TAG }} + if [ -f main.go ]; then + sed -i 's/version_replaceme/'"$TAG"'/' main.go + echo "Updated main.go with version $TAG" + else + echo "main.go not found" + fi + + - name: Ensure repository is at the tagged commit (dispatch only) + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + set -euo pipefail + git fetch --tags --force + git checkout "refs/tags/${TAG}" + echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}" + shell: bash + + - name: Detect release candidate (rc) + run: | + set -euo pipefail + if [[ "${TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "IS_RC=true" >> $GITHUB_ENV + else + echo "IS_RC=false" >> $GITHUB_ENV + fi + shell: bash + + - name: Install Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version-file: go.mod + + - name: Resolve publish-latest flag + env: + EVENT_NAME: ${{ github.event_name }} + PL_INPUT: ${{ inputs.publish_latest }} + PL_VAR: ${{ vars.PUBLISH_LATEST }} + run: | + set -euo pipefail + val="false" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ "${PL_INPUT}" = "true" ]; then val="true"; fi + else + if [ "${PL_VAR}" = "true" ]; then val="true"; fi + fi + echo "PUBLISH_LATEST=$val" >> $GITHUB_ENV + shell: bash + + - name: Resolve publish-minor flag + env: + EVENT_NAME: ${{ github.event_name }} + PM_INPUT: ${{ inputs.publish_minor }} + PM_VAR: ${{ vars.PUBLISH_MINOR }} + run: | + set -euo pipefail + val="false" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ "${PM_INPUT}" = "true" ]; then val="true"; fi + else + if [ "${PM_VAR}" = "true" ]; then val="true"; fi + fi + echo "PUBLISH_MINOR=$val" >> $GITHUB_ENV + shell: bash + + - name: Cache Go modules + if: ${{ hashFiles('**/go.sum') != '' }} + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Go vet & test + if: ${{ hashFiles('**/go.mod') != '' }} + run: | + go version + go vet ./... + go test ./... -race -covermode=atomic + shell: bash + + - name: Resolve license fallback + run: echo "IMAGE_LICENSE=${{ github.event.repository.license.spdx_id || 'NOASSERTION' }}" >> $GITHUB_ENV + shell: bash + + - name: Resolve registries list (GHCR always, Docker Hub only if creds) + shell: bash + run: | + set -euo pipefail + images="${GHCR_IMAGE}" + if [ -n "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" ] && [ -n "${{ secrets.DOCKER_HUB_USERNAME }}" ]; then + images="${images}\n${DOCKERHUB_IMAGE}" + fi + { + echo 'IMAGE_LIST<> "$GITHUB_ENV" + - name: Docker meta + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + with: + images: ${{ env.IMAGE_LIST }} + tags: | + type=semver,pattern={{version}},value=${{ env.TAG }} + type=semver,pattern={{major}}.{{minor}},value=${{ env.TAG }},enable=${{ env.PUBLISH_MINOR == 'true' && env.IS_RC != 'true' }} + type=raw,value=latest,enable=${{ env.IS_RC != 'true' }} + flavor: | + latest=false + labels: | + org.opencontainers.image.title=${{ github.event.repository.name }} + org.opencontainers.image.version=${{ env.TAG }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=${{ github.event.repository.html_url }} + org.opencontainers.image.url=${{ github.event.repository.html_url }} + org.opencontainers.image.documentation=${{ github.event.repository.html_url }} + org.opencontainers.image.description=${{ github.event.repository.description }} + org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }} + org.opencontainers.image.created=${{ env.IMAGE_CREATED }} + org.opencontainers.image.ref.name=${{ env.TAG }} + org.opencontainers.image.authors=${{ github.repository_owner }} + - name: Echo build config (non-secret) + shell: bash + env: + IMAGE_TITLE: ${{ github.event.repository.name }} + IMAGE_VERSION: ${{ env.TAG }} + IMAGE_REVISION: ${{ github.sha }} + IMAGE_SOURCE_URL: ${{ github.event.repository.html_url }} + IMAGE_URL: ${{ github.event.repository.html_url }} + IMAGE_DESCRIPTION: ${{ github.event.repository.description }} + IMAGE_LICENSE: ${{ env.IMAGE_LICENSE }} + DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }} + GHCR_IMAGE: ${{ env.GHCR_IMAGE }} + DOCKER_HUB_USER: ${{ secrets.DOCKER_HUB_USERNAME }} + REPO: ${{ github.repository }} + OWNER: ${{ github.repository_owner }} + WORKFLOW_REF: ${{ github.workflow_ref }} + REF: ${{ github.ref }} + REF_NAME: ${{ github.ref_name }} + RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + echo "=== OCI Label Values ===" + echo "org.opencontainers.image.title=${IMAGE_TITLE}" + echo "org.opencontainers.image.version=${IMAGE_VERSION}" + echo "org.opencontainers.image.revision=${IMAGE_REVISION}" + echo "org.opencontainers.image.source=${IMAGE_SOURCE_URL}" + echo "org.opencontainers.image.url=${IMAGE_URL}" + echo "org.opencontainers.image.description=${IMAGE_DESCRIPTION}" + echo "org.opencontainers.image.licenses=${IMAGE_LICENSE}" + echo + echo "=== Images ===" + echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE}" + echo "GHCR_IMAGE=${GHCR_IMAGE}" + echo "DOCKER_HUB_USERNAME=${DOCKER_HUB_USER}" + echo + echo "=== GitHub Kontext ===" + echo "repository=${REPO}" + echo "owner=${OWNER}" + echo "workflow_ref=${WORKFLOW_REF}" + echo "ref=${REF}" + echo "ref_name=${REF_NAME}" + echo "run_url=${RUN_URL}" + echo + echo "=== docker/metadata-action outputs (Tags/Labels), raw ===" + echo "::group::tags" + echo "${{ steps.meta.outputs.tags }}" + echo "::endgroup::" + echo "::group::labels" + echo "${{ steps.meta.outputs.labels }}" + echo "::endgroup::" + - name: Build and push (Docker Hub + GHCR) + id: build + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64,linux/arm/v7 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ github.repository }} + cache-to: type=gha,mode=max,scope=${{ github.repository }} + provenance: mode=max + sbom: true + + - name: Compute image digest refs + run: | + echo "DIGEST=${{ steps.build.outputs.digest }}" >> $GITHUB_ENV + echo "GHCR_REF=$GHCR_IMAGE@${{ steps.build.outputs.digest }}" >> $GITHUB_ENV + echo "DH_REF=$DOCKERHUB_IMAGE@${{ steps.build.outputs.digest }}" >> $GITHUB_ENV + echo "Built digest: ${{ steps.build.outputs.digest }}" + shell: bash + + - name: Attest build provenance (GHCR) + id: attest-ghcr + uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0 + with: + subject-name: ${{ env.GHCR_IMAGE }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + show-summary: true + + - name: Attest build provenance (Docker Hub) + continue-on-error: true + id: attest-dh + uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0 + with: + subject-name: index.docker.io/fosrl/${{ github.event.repository.name }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + show-summary: true + + - name: Install cosign + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + with: + cosign-release: 'v3.0.2' + + - name: Sanity check cosign private key + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + set -euo pipefail + cosign public-key --key env://COSIGN_PRIVATE_KEY >/dev/null + shell: bash + + - name: Sign GHCR image (digest) with key (recursive) + env: + COSIGN_YES: "true" + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + set -euo pipefail + echo "Signing ${GHCR_REF} (digest) recursively with provided key" + cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${GHCR_REF}" + echo "Waiting 30 seconds for signatures to propagate..." + sleep 30 + shell: bash + + - name: Generate SBOM (SPDX JSON) + uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1 + with: + image-ref: ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }} + format: spdx-json + output: sbom.spdx.json + + - name: Validate SBOM JSON + run: jq -e . sbom.spdx.json >/dev/null + shell: bash + + - name: Minify SBOM JSON (optional hardening) + run: jq -c . sbom.spdx.json > sbom.min.json && mv sbom.min.json sbom.spdx.json + shell: bash + + - name: Create SBOM attestation (GHCR, private key) + env: + COSIGN_YES: "true" + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + set -euo pipefail + cosign attest \ + --key env://COSIGN_PRIVATE_KEY \ + --type spdxjson \ + --predicate sbom.spdx.json \ + "${GHCR_REF}" + shell: bash + + - name: Create SBOM attestation (Docker Hub, private key) + continue-on-error: true + env: + COSIGN_YES: "true" + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + cosign attest \ + --key env://COSIGN_PRIVATE_KEY \ + --type spdxjson \ + --predicate sbom.spdx.json \ + "${DH_REF}" + shell: bash + + - name: Keyless sign & verify GHCR digest (OIDC) + env: + COSIGN_YES: "true" + WORKFLOW_REF: ${{ github.workflow_ref }} # owner/repo/.github/workflows/@refs/tags/ + ISSUER: https://token.actions.githubusercontent.com + run: | + set -euo pipefail + echo "Keyless signing ${GHCR_REF}" + cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${GHCR_REF}" + echo "Verify keyless (OIDC) signature policy on ${GHCR_REF}" + cosign verify \ + --certificate-oidc-issuer "${ISSUER}" \ + --certificate-identity "https://github.com/${WORKFLOW_REF}" \ + "${GHCR_REF}" -o text + shell: bash + + - name: Sign Docker Hub image (digest) with key (recursive) + continue-on-error: true + env: + COSIGN_YES: "true" + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + echo "Signing ${DH_REF} (digest) recursively with provided key (Docker media types fallback)" + cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${DH_REF}" + shell: bash + + - name: Keyless sign & verify Docker Hub digest (OIDC) + continue-on-error: true + env: + COSIGN_YES: "true" + ISSUER: https://token.actions.githubusercontent.com + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + echo "Keyless signing ${DH_REF} (force public-good Rekor)" + cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${DH_REF}" + echo "Keyless verify via Rekor (strict identity)" + if ! cosign verify \ + --rekor-url https://rekor.sigstore.dev \ + --certificate-oidc-issuer "${ISSUER}" \ + --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ + "${DH_REF}" -o text; then + echo "Rekor verify failed — retry offline bundle verify (no Rekor)" + if ! cosign verify \ + --offline \ + --certificate-oidc-issuer "${ISSUER}" \ + --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ + "${DH_REF}" -o text; then + echo "Offline bundle verify failed — ignore tlog (TEMP for debugging)" + cosign verify \ + --insecure-ignore-tlog=true \ + --certificate-oidc-issuer "${ISSUER}" \ + --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ + "${DH_REF}" -o text || true + fi + fi + - name: Verify signature (public key) GHCR digest + tag + env: + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + run: | + set -euo pipefail + TAG_VAR="${TAG}" + echo "Verifying (digest) ${GHCR_REF}" + cosign verify --key env://COSIGN_PUBLIC_KEY "$GHCR_REF" -o text + echo "Verifying (tag) $GHCR_IMAGE:$TAG_VAR" + cosign verify --key env://COSIGN_PUBLIC_KEY "$GHCR_IMAGE:$TAG_VAR" -o text + shell: bash + + - name: Verify SBOM attestation (GHCR) + env: + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + run: cosign verify-attestation --key env://COSIGN_PUBLIC_KEY --type spdxjson "$GHCR_REF" -o text + shell: bash + + - name: Verify SLSA provenance (GHCR) + env: + ISSUER: https://token.actions.githubusercontent.com + WFREF: ${{ github.workflow_ref }} + run: | + set -euo pipefail + # (optional) show which predicate types are present to aid debugging + cosign download attestation "$GHCR_REF" \ + | jq -r '.payload | @base64d | fromjson | .predicateType' | sort -u || true + # Verify the SLSA v1 provenance attestation (predicate URL) + cosign verify-attestation \ + --type 'https://slsa.dev/provenance/v1' \ + --certificate-oidc-issuer "$ISSUER" \ + --certificate-identity "https://github.com/${WFREF}" \ + --rekor-url https://rekor.sigstore.dev \ + "$GHCR_REF" -o text + shell: bash + + - name: Verify signature (public key) Docker Hub digest + continue-on-error: true + env: + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + echo "Verifying (digest) ${DH_REF} with Docker media types" + cosign verify --key env://COSIGN_PUBLIC_KEY "${DH_REF}" -o text + shell: bash + + - name: Verify signature (public key) Docker Hub tag + continue-on-error: true + env: + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + echo "Verifying (tag) $DOCKERHUB_IMAGE:$TAG with Docker media types" + cosign verify --key env://COSIGN_PUBLIC_KEY "$DOCKERHUB_IMAGE:$TAG" -o text + shell: bash + + # - name: Trivy scan (GHCR image) + # id: trivy + # uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1 + # with: + # image-ref: ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }} + # format: sarif + # output: trivy-ghcr.sarif + # ignore-unfixed: true + # vuln-type: os,library + # severity: CRITICAL,HIGH + # exit-code: ${{ (vars.TRIVY_FAIL || '0') }} + + # - name: Upload SARIF,trivy + # if: ${{ always() && hashFiles('trivy-ghcr.sarif') != '' }} + # uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 + # with: + # sarif_file: trivy-ghcr.sarif + # category: Image Vulnerability Scan + + - name: Build binaries + env: + CGO_ENABLED: "0" + GOFLAGS: "-trimpath" + run: | + set -euo pipefail + TAG_VAR="${TAG}" + make -j 10 go-build-release tag=$TAG_VAR + shell: bash + + - name: Create GitHub Release + uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + with: + tag_name: ${{ env.TAG }} + generate_release_notes: true + prerelease: ${{ env.IS_RC == 'true' }} + files: | + bin/* + fail_on_unmatched_files: true + draft: true + body: | + ## Container Images + - GHCR: `${{ env.GHCR_REF }}` + - Docker Hub: `${{ env.DH_REF || 'N/A' }}` + **Digest:** `${{ steps.build.outputs.digest }}` diff --git a/.github/workflows/mirror.yaml b/.github/workflows/mirror.yaml new file mode 100644 index 0000000..57117d3 --- /dev/null +++ b/.github/workflows/mirror.yaml @@ -0,0 +1,132 @@ +name: Mirror & Sign (Docker Hub to GHCR) + +on: + workflow_dispatch: {} + +permissions: + contents: read + packages: write + id-token: write # for keyless OIDC + +env: + SOURCE_IMAGE: docker.io/fosrl/pangolin-cli + DEST_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + +jobs: + mirror-and-dual-sign: + runs-on: amd64-runner + steps: + - name: Install skopeo + jq + run: | + sudo apt-get update -y + sudo apt-get install -y skopeo jq + skopeo --version + + - name: Install cosign + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + + - name: Input check + run: | + test -n "${SOURCE_IMAGE}" || (echo "SOURCE_IMAGE is empty" && exit 1) + echo "Source : ${SOURCE_IMAGE}" + echo "Target : ${DEST_IMAGE}" + + # Auth for skopeo (containers-auth) + - name: Skopeo login to GHCR + run: | + skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}" + + # Auth for cosign (docker-config) + - name: Docker login to GHCR (for cosign) + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: List source tags + run: | + set -euo pipefail + skopeo list-tags --retry-times 3 docker://"${SOURCE_IMAGE}" \ + | jq -r '.Tags[]' | sort -u > src-tags.txt + echo "Found source tags: $(wc -l < src-tags.txt)" + head -n 20 src-tags.txt || true + + - name: List destination tags (skip existing) + run: | + set -euo pipefail + if skopeo list-tags --retry-times 3 docker://"${DEST_IMAGE}" >/tmp/dst.json 2>/dev/null; then + jq -r '.Tags[]' /tmp/dst.json | sort -u > dst-tags.txt + else + : > dst-tags.txt + fi + echo "Existing destination tags: $(wc -l < dst-tags.txt)" + + - name: Mirror, dual-sign, and verify + env: + # keyless + COSIGN_YES: "true" + # key-based + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + # verify + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + run: | + set -euo pipefail + copied=0; skipped=0; v_ok=0; errs=0 + + issuer="https://token.actions.githubusercontent.com" + id_regex="^https://github.com/${{ github.repository }}/.+" + + while read -r tag; do + [ -z "$tag" ] && continue + + if grep -Fxq "$tag" dst-tags.txt; then + echo "::notice ::Skip (exists) ${DEST_IMAGE}:${tag}" + skipped=$((skipped+1)) + continue + fi + + echo "==> Copy ${SOURCE_IMAGE}:${tag} → ${DEST_IMAGE}:${tag}" + if ! skopeo copy --all --retry-times 3 \ + docker://"${SOURCE_IMAGE}:${tag}" docker://"${DEST_IMAGE}:${tag}"; then + echo "::warning title=Copy failed::${SOURCE_IMAGE}:${tag}" + errs=$((errs+1)); continue + fi + copied=$((copied+1)) + + digest="$(skopeo inspect --retry-times 3 docker://"${DEST_IMAGE}:${tag}" | jq -r '.Digest')" + ref="${DEST_IMAGE}@${digest}" + + echo "==> cosign sign (keyless) --recursive ${ref}" + if ! cosign sign --recursive "${ref}"; then + echo "::warning title=Keyless sign failed::${ref}" + errs=$((errs+1)) + fi + + echo "==> cosign sign (key) --recursive ${ref}" + if ! cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${ref}"; then + echo "::warning title=Key sign failed::${ref}" + errs=$((errs+1)) + fi + + echo "==> cosign verify (public key) ${ref}" + if ! cosign verify --key env://COSIGN_PUBLIC_KEY "${ref}" -o text; then + echo "::warning title=Verify(pubkey) failed::${ref}" + errs=$((errs+1)) + fi + + echo "==> cosign verify (keyless policy) ${ref}" + if ! cosign verify \ + --certificate-oidc-issuer "${issuer}" \ + --certificate-identity-regexp "${id_regex}" \ + "${ref}" -o text; then + echo "::warning title=Verify(keyless) failed::${ref}" + errs=$((errs+1)) + else + v_ok=$((v_ok+1)) + fi + done < src-tags.txt + + echo "---- Summary ----" + echo "Copied : $copied" + echo "Skipped : $skipped" + echo "Verified OK : $v_ok" + echo "Errors : $errs" diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml new file mode 100644 index 0000000..157334c --- /dev/null +++ b/.github/workflows/nix-build.yml @@ -0,0 +1,23 @@ +name: Build Nix package + +on: + workflow_dispatch: + pull_request: + paths: + - go.mod + - go.sum + +jobs: + nix-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + + - name: Build flake package + run: | + nix build .#pangolin-cli -L diff --git a/.github/workflows/nix-dependabot-update-hash.yml b/.github/workflows/nix-dependabot-update-hash.yml new file mode 100644 index 0000000..74d43a2 --- /dev/null +++ b/.github/workflows/nix-dependabot-update-hash.yml @@ -0,0 +1,48 @@ +name: Update Nix Package Hash On Dependabot PRs + +on: + pull_request: + types: [opened, synchronize] + branches: + - main + +jobs: + nix-update: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + + - name: Run nix-update + run: | + nix run nixpkgs#nix-update -- --flake pangolin-cli --no-src --version skip + + - name: Check for changes + id: changes + run: | + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Commit and push changes + if: steps.changes.outputs.changed == 'true' + run: | + git config user.name "dependabot[bot]" + git config user.email "dependabot[bot]@users.noreply.github.com" + + git add . + git commit -m "chore(nix): fix hash for updated go dependencies" + git push diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml new file mode 100644 index 0000000..4df7e93 --- /dev/null +++ b/.github/workflows/stale-bot.yml @@ -0,0 +1,37 @@ +name: Mark and Close Stale Issues + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: # Allow manual trigger + +permissions: + contents: write # only for delete-branch option + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + with: + days-before-stale: 14 + days-before-close: 14 + stale-issue-message: 'This issue has been automatically marked as stale due to 14 days of inactivity. It will be closed in 14 days if no further activity occurs.' + close-issue-message: 'This issue has been automatically closed due to inactivity. If you believe this is still relevant, please open a new issue with up-to-date information.' + stale-issue-label: 'stale' + + exempt-issue-labels: 'needs investigating, networking, new feature, reverse proxy, bug, api, authentication, documentation, enhancement, help wanted, good first issue, question' + + exempt-all-issue-assignees: true + + only-labels: '' + exempt-pr-labels: '' + days-before-pr-stale: -1 + days-before-pr-close: -1 + + operations-per-run: 100 + remove-stale-when-updated: true + delete-branch: false + enable-statistics: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 32ced8f..490be06 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,10 +15,11 @@ jobs: strategy: matrix: target: + - local + - docker-build - go-build-release-darwin-amd64 - go-build-release-darwin-arm64 - go-build-release-linux-amd64 - - go-build-release-linux-arm64 - go-build-release-linux-arm32-v6 - go-build-release-linux-arm32-v7 - go-build-release-linux-riscv64 @@ -27,9 +28,9 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: go-version: 1.25 - name: Build targets via `make` - run: make ${{ matrix.target }} \ No newline at end of file + run: make ${{ matrix.target }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..92c3ba7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM golang:1.25-alpine AS builder + +# Install git and ca-certificates +RUN apk --no-cache add ca-certificates git tzdata + +# Set the working directory inside the container +WORKDIR /app + +# Copy go mod and sum files +COPY go.mod go.sum ./ + +# Download all dependencies +RUN go mod download + +# Copy the source code into the container +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /pangolin-cli + +FROM alpine:3.23 AS runner + +RUN apk --no-cache add ca-certificates tzdata iputils + +COPY --from=builder /pangolin-cli /usr/local/bin/ +COPY entrypoint.sh / + +# Admin/metrics endpoint (Prometheus scrape) +EXPOSE 2112 + +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] +CMD ["pangolin-cli"] diff --git a/Makefile b/Makefile index d9699b9..45a2865 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,27 @@ docs: @go run tools/gendocs/main.go -dir docs @echo "Documentation generated in docs/" +docker-build: + docker build -t fosrl/pangolin-cli:latest . + +docker-build-release: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make docker-build-release tag="; \ + exit 1; \ + fi + docker buildx build . \ + --platform linux/arm/v7,linux/arm64,linux/amd64 \ + -t fosrl/pangolin-cli:latest \ + -t fosrl/pangolin-cli:$(tag) \ + -f Dockerfile \ + --push + +.PHONY: go-build-release \ + go-build-release-linux-arm64 go-build-release-linux-arm32-v7 \ + go-build-release-linux-arm32-v6 go-build-release-linux-amd64 \ + go-build-release-linux-riscv64 go-build-release-darwin-arm64 \ + go-build-release-darwin-amd64 go-build-release-windows-amd64 + go-build-release: \ go-build-release-linux-arm64 \ go-build-release-linux-arm32-v7 \ @@ -37,22 +58,22 @@ go-build-release: \ go-build-release-darwin-amd64 go-build-release-linux-arm64: - GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o bin/pangolin-cli_linux_arm64 + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/pangolin-cli_linux_arm64 go-build-release-linux-arm32-v7: - GOOS=linux GOARCH=arm GOARM=7 go build $(LDFLAGS) -o bin/pangolin-cli_linux_arm32 + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o bin/pangolin-cli_linux_arm32 go-build-release-linux-arm32-v6: - GOOS=linux GOARCH=arm GOARM=6 go build $(LDFLAGS) -o bin/pangolin-cli_linux_arm32v6 + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o bin/pangolin-cli_linux_arm32v6 go-build-release-linux-amd64: - GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/pangolin-cli_linux_amd64 + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/pangolin-cli_linux_amd64 go-build-release-linux-riscv64: - GOOS=linux GOARCH=riscv64 go build $(LDFLAGS) -o bin/pangolin-cli_linux_riscv64 + CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -o bin/pangolin-cli_linux_riscv64 go-build-release-darwin-arm64: - GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o bin/pangolin-cli_darwin_arm64 + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/pangolin-cli_darwin_arm64 go-build-release-darwin-amd64: - GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o bin/pangolin-cli_darwin_amd64 + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/pangolin-cli_darwin_amd64 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..972b98f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +# To run a machine client +services: + pangolin-cli: + image: pangolin-cli:latest + container_name: pangolin-cli + environment: + - PANGOLIN_ENDPOINT=https://app.pangolin.net + - CLIENT_ID=5n52gnzfgl3tdox + - CLIENT_SECRET=wyael1dhftekp0ii2ni0ym6xczwjnwmucy2vr6u9kgkp8tw9 + devices: + - /dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..13b271c --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +set -e + +# Check if Pangolin environment variables are set +if [ -n "$PANGOLIN_ENDPOINT" ] && [ -n "$CLIENT_ID" ] && [ -n "$CLIENT_SECRET" ]; then + # Run pangolin-cli up --attach with the provided credentials + exec pangolin-cli up --attach --id "$CLIENT_ID" --secret "$CLIENT_SECRET" --endpoint "$PANGOLIN_ENDPOINT" "$@" +fi + +# If no arguments provided, run pangolin-cli with default behavior +if [ $# -eq 0 ]; then + exec pangolin-cli +fi + +# If first arg is a flag (starts with -), prepend pangolin-cli +if [ "${1#-}" != "$1" ]; then + exec pangolin-cli "$@" +fi + +# If first arg is a shell or absolute path, execute as-is +case "$1" in + sh|bash|/*) + exec "$@" + ;; + *) + # Otherwise, treat as a pangolin-cli subcommand + exec pangolin-cli "$@" + ;; +esac \ No newline at end of file diff --git a/go.mod b/go.mod index 5ac2e17..20b6f20 100644 --- a/go.mod +++ b/go.mod @@ -76,5 +76,5 @@ require ( // If changes to Olm or Newt are required, use these // replace directives during development. // -// replace github.com/fosrl/olm => ../olm -// replace github.com/fosrl/newt => ../newt +//replace github.com/fosrl/olm => ../olm +//replace github.com/fosrl/newt => ../newt