diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..20912ac --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-fast + env: + DOCKER_ORG: ${{ vars.DOCKER_ORG }} + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."${{ vars.DOCKER_REGISTRY }}"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..6c86a3d --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,124 @@ +# Git(tea) Actions workflow: Build and publish standalone binaries **plus** bundled `static/` assets +# ──────────────────────────────────────────────────────────────────── +# ✧ Builds the Go‑based WoL server for four targets **and** packt das Verzeichnis +# `static` zusammen mit der Binary, sodass es relativ zur ausführbaren Datei +# liegt (wichtig für die eingebauten Bootstrap‑Assets & favicon). +# +# • linux/amd64 → wol-server-linux-amd64.tar.gz +# • linux/arm64 → wol-server-linux-arm64.tar.gz +# • linux/arm/v7 → wol-server-linux-armv7.tar.gz +# • windows/amd64 → wol-server-windows-amd64.zip +# +# ✧ Artefakte landen im Workflow und – bei Tag‑Push (vX.Y.Z) – als Release‑Assets. +# +# Secrets/variables: +# GITEA_TOKEN – optional, falls default token keine Release‑Rechte hat. +# ──────────────────────────────────────────────────────────────────── + +name: build-binaries + +on: + push: + branches: [ "main" ] + tags: [ "v*" ] + +jobs: + build: + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-fast + + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + ext: "" + - goos: linux + goarch: arm64 + ext: "" + - goos: linux + goarch: arm + goarm: "7" + ext: "" + - goos: windows + goarch: amd64 + ext: ".exe" + + env: + GO_VERSION: "1.24" + BINARY_NAME: virtual-clipboard-generator + + steps: + - name: Checkout source + uses: actions/checkout@v3 + + - name: Set up Go ${{ env.GO_VERSION }} + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Build ${{ matrix.goos }}/${{ matrix.goarch }}${{ matrix.goarm && format('/v{0}', matrix.goarm) || '' }} + shell: bash + run: | + set -e + mkdir -p dist/package + if [ -n "${{ matrix.goarm }}" ]; then export GOARM=${{ matrix.goarm }}; fi + CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -trimpath -ldflags "-s -w" \ + -o "dist/package/${BINARY_NAME}${{ matrix.ext }}" . + # Assets: statisches Verzeichnis beilegen + cp -r static dist/package/ + + - name: Package archive with static assets + shell: bash + run: | + set -e + cd dist + if [ "${{ matrix.goos }}" == "windows" ]; then + ZIP_NAME="${BINARY_NAME}-windows-amd64.zip" + (cd package && zip -r "../$ZIP_NAME" .) + else + ARCH_SUFFIX="${{ matrix.goarch }}" + if [ "${{ matrix.goarch }}" == "arm" ]; then ARCH_SUFFIX="armv${{ matrix.goarm }}"; fi + TAR_NAME="${BINARY_NAME}-${{ matrix.goos }}-${ARCH_SUFFIX}.tar.gz" + tar -czf "$TAR_NAME" -C package . + fi + + - name: Upload workflow artifact + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm && format('v{0}', matrix.goarm) || '' }} + path: dist/*.tar.gz + if-no-files-found: ignore + - uses: actions/upload-artifact@v3 + with: + name: windows-amd64 + path: dist/*.zip + if-no-files-found: ignore + + # Release Schritt für Tag‑Pushes + release: + if: startsWith(github.ref, 'refs/tags/') + needs: build + runs-on: ubuntu-fast + permissions: + contents: write + + steps: + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + path: ./dist + + - name: Create / Update release + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }} + with: + name: "Release ${{ github.ref_name }}" + tag_name: ${{ github.ref_name }} + draft: false + prerelease: false + files: | + dist/**/virtual-clipboard-generator-*.tar.gz + dist/**/virtual-clipboard-generator-*.zip diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..adae5fb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# -------- Dockerfile (Multi-Stage Build) -------- +# 1. Builder-Stage +FROM golang:1.24-alpine AS builder + +WORKDIR /app +COPY go.* ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /bin/vcg + +# 2. Runtime-Stage +FROM alpine:3.22 + +# HTTPS-Callouts in Alpine brauchen ca-certificates +RUN apk add --no-cache ca-certificates +COPY --from=builder /bin/vcg /bin/vcg +# Default listens on :8090 – siehe main.go +EXPOSE 8090 + +# Environment defaults; können per compose überschrieben werden +ENV PWGEN_LENGTH=14 \ +PWGEN_MIN_LOWER=2 \ +PWGEN_MIN_UPPER=2 \ +PWGEN_MIN_DIGITS=2 \ +PWGEN_MIN_SYMBOLS=2 \ +PWGEN_CHARSET="" \ +PWGEN_EXCLUDE="" \ +PWGEN_NO_AMBIGUOUS=true \ +PWGEN_NO_SEQ=true \ +PWGEN_NO_REPEAT=true \ +PWGEN_UNIQUE=false \ +PWGEN_TEMPLATE= \ +PWGEN_SYMBOLS=symbols \ +PWGEN_COUNT=1 \ +PWGEN_JSON=false \ +PWGEN_CLIPBOARD_BASE=http://clipboard:8080 \ +PWGEN_CLIPBOARD_ROOM=default \ +PWGEN_CLIPBOARD_AUTHOR=PWGEN \ +PWGEN_CLIPBOARD_TYPE=text \ +PWGEN_CLIPBOARD_TOKEN="" \ +CLIPBOARD_TOKEN="" \ +PWGEN_SEND_URL="" \ +PWGEN_SEND_FIELD="content" \ +PWGEN_SEND_HEADERS="" \ +PWGEN_SEND_METHOD="POST" \ +PWGEN_DRY_RUN=false \ +PWGEN_TIMEOUT="" \ +PWGEN_WEB=true \ +PWGEN_WEB_ADDR=":8090" \ +PWGEN_WEB_USER="" \ +PWGEN_WEB_PASS="" + +ENTRYPOINT ["/bin/vcg"] \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..73e10c1 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.send.nrw/sendnrw/virtual-clipboard-generator + +go 1.24.4 diff --git a/main.go b/main.go new file mode 100644 index 0000000..79b3bfc --- /dev/null +++ b/main.go @@ -0,0 +1,732 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "log" + "math" + "math/big" + "net/http" + "os" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +var ( + lower = "abcdefghijklmnopqrstuvwxyz" + upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + digits = "0123456789" + symbols = "!@#$%^&*()-=+;:,.?" + "|" // escaped backslash + _ = regexp.MustCompile(`[O0Il1|]+`) // reserved for future validation +) + +// ==== Options & ENV parsing ==== + +type options struct { + length int + minLower int + minUpper int + minDigits int + minSymbols int + custom string + exclude string + noAmbig bool + noSeq bool + noRepeat bool + unique bool + template string + symbolSet string + count int + jsonOut bool + + // Virtual Clipboard target + clipBase string // e.g. http://localhost:8080 + clipRoom string // e.g. default + clipAuthor string // optional author field + clipType string // usually "text" + clipToken string // X-Token value (falls back to CLIPBOARD_TOKEN) + + // legacy/custom sender (kept for compatibility) + sendURL string + sendField string + sendHeaders []string + sendMethod string + dryRun bool + timeout time.Duration + + // web ui + webEnabled bool + webAddr string + webUser string + webPass string +} + +func getenvInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if i, err := strconv.Atoi(v); err == nil { + return i + } + } + return def +} + +func getenvBool(key string, def bool) bool { + if v := os.Getenv(key); v != "" { + s := strings.ToLower(strings.TrimSpace(v)) + if s == "1" || s == "true" || s == "yes" || s == "on" { + return true + } + if s == "0" || s == "false" || s == "no" || s == "off" { + return false + } + } + return def +} + +func getenvStr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func getenvDuration(key string, def time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return def +} + +func parseEnv() options { + var opt options + opt.length = getenvInt("PWGEN_LENGTH", 20) + opt.minLower = getenvInt("PWGEN_MIN_LOWER", 1) + opt.minUpper = getenvInt("PWGEN_MIN_UPPER", 1) + opt.minDigits = getenvInt("PWGEN_MIN_DIGITS", 1) + opt.minSymbols = getenvInt("PWGEN_MIN_SYMBOLS", 1) + opt.custom = getenvStr("PWGEN_CHARSET", "") + opt.exclude = getenvStr("PWGEN_EXCLUDE", "") + opt.noAmbig = getenvBool("PWGEN_NO_AMBIGUOUS", true) + opt.noSeq = getenvBool("PWGEN_NO_SEQ", true) + opt.noRepeat = getenvBool("PWGEN_NO_REPEAT", true) + opt.unique = getenvBool("PWGEN_UNIQUE", false) + opt.template = getenvStr("PWGEN_TEMPLATE", "") + opt.symbolSet = getenvStr("PWGEN_SYMBOLS", symbols) + opt.count = getenvInt("PWGEN_COUNT", 1) + opt.jsonOut = getenvBool("PWGEN_JSON", false) + + // Virtual Clipboard integration + opt.clipBase = getenvStr("PWGEN_CLIPBOARD_BASE", "http://localhost:8080") + opt.clipRoom = getenvStr("PWGEN_CLIPBOARD_ROOM", "default") + opt.clipAuthor = getenvStr("PWGEN_CLIPBOARD_AUTHOR", "") + opt.clipType = getenvStr("PWGEN_CLIPBOARD_TYPE", "text") + opt.clipToken = getenvStr("PWGEN_CLIPBOARD_TOKEN", "") + if opt.clipToken == "" { + opt.clipToken = os.Getenv("CLIPBOARD_TOKEN") + } + + // legacy/custom sender (still supported) + opt.sendURL = getenvStr("PWGEN_SEND_URL", "") + opt.sendField = getenvStr("PWGEN_SEND_FIELD", "content") + if h := getenvStr("PWGEN_SEND_HEADERS", ""); h != "" { + opt.sendHeaders = strings.Split(h, ";") + } + opt.sendMethod = getenvStr("PWGEN_SEND_METHOD", "POST") + opt.dryRun = getenvBool("PWGEN_DRY_RUN", false) + opt.timeout = getenvDuration("PWGEN_TIMEOUT", 10*time.Second) + + opt.webEnabled = getenvBool("PWGEN_WEB", true) + // Use a different default port than the VC server to avoid conflicts + opt.webAddr = getenvStr("PWGEN_WEB_ADDR", ":8090") + opt.webUser = getenvStr("PWGEN_WEB_USER", "") + opt.webPass = getenvStr("PWGEN_WEB_PASS", "") + return opt +} + +// ==== Core generation logic ==== + +// randInt returns a crypto-strong uniform integer in [0, n) +func randInt(n int64) (int64, error) { + if n <= 0 { + return 0, errors.New("invalid n") + } + max := big.NewInt(n) + x, err := rand.Int(rand.Reader, max) + if err != nil { + return 0, err + } + return x.Int64(), nil +} + +func shuffleBytes(b []byte) error { + for i := len(b) - 1; i > 0; i-- { + j64, err := randInt(int64(i + 1)) + if err != nil { + return err + } + j := int(j64) + b[i], b[j] = b[j], b[i] + } + return nil +} + +func containsRun(s string, maxRun int) bool { + if maxRun <= 1 { // no runs allowed + for i := 1; i < len(s); i++ { + if s[i] == s[i-1] { + return true + } + } + return false + } + run := 1 + for i := 1; i < len(s); i++ { + if s[i] == s[i-1] { + run++ + if run > maxRun { + return true + } + } else { + run = 1 + } + } + return false +} + +func hasSeq(s string, window int) bool { + if window <= 1 || len(s) < window { + return false + } + for i := 0; i <= len(s)-window; i++ { + asc := true + for j := 1; j < window; j++ { + if s[i+j] != s[i+j-1]+1 { + asc = false + break + } + } + if asc { + return true + } + } + return false +} + +func removeChars(set, exclude string) string { + m := make(map[rune]bool) + for _, r := range exclude { + m[r] = true + } + var out strings.Builder + for _, r := range set { + if !m[r] { + out.WriteRune(r) + } + } + return out.String() +} + +func buildSets(opt options) (lowerSet, upperSet, digitSet, symbolSet, anySet string) { + ls, us, ds, ss := lower, upper, digits, opt.symbolSet + if opt.noAmbig { + ls = removeChars(ls, "l") + us = removeChars(us, "OI") + ds = removeChars(ds, "01") + ss = removeChars(ss, "|") + } + if opt.exclude != "" { + ls = removeChars(ls, opt.exclude) + us = removeChars(us, opt.exclude) + ds = removeChars(ds, opt.exclude) + ss = removeChars(ss, opt.exclude) + } + any := uniqueConcat(ls + us + ds + ss + opt.custom) + return ls, us, ds, ss, any +} + +func uniqueConcat(s string) string { + m := map[rune]bool{} + var b strings.Builder + for _, r := range s { + if !m[r] { + m[r] = true + b.WriteRune(r) + } + } + return b.String() +} + +func pickRandom(set string) (byte, error) { + if len(set) == 0 { + return 0, errors.New("empty character set after exclusions") + } + i, err := randInt(int64(len(set))) + if err != nil { + return 0, err + } + return set[i], nil +} + +func generateOne(opt options, sets [5]string) (string, error) { + ls, us, ds, ss, anyPool := sets[0], sets[1], sets[2], sets[3], sets[4] + if opt.template != "" { + return generateFromTemplate(opt, ls, us, ds, ss) + } + if opt.length <= 0 { + return "", errors.New("length must be > 0") + } + if opt.minLower+opt.minUpper+opt.minDigits+opt.minSymbols > opt.length { + return "", errors.New("sum of minimums exceeds length") + } + bytesBuf := make([]byte, 0, opt.length) + for i := 0; i < opt.minLower; i++ { + c, err := pickRandom(ls) + if err != nil { + return "", err + } + bytesBuf = append(bytesBuf, c) + } + for i := 0; i < opt.minUpper; i++ { + c, err := pickRandom(us) + if err != nil { + return "", err + } + bytesBuf = append(bytesBuf, c) + } + for i := 0; i < opt.minDigits; i++ { + c, err := pickRandom(ds) + if err != nil { + return "", err + } + bytesBuf = append(bytesBuf, c) + } + for i := 0; i < opt.minSymbols; i++ { + c, err := pickRandom(ss) + if err != nil { + return "", err + } + bytesBuf = append(bytesBuf, c) + } + pool := anyPool + if opt.unique && len(pool) < opt.length { + return "", fmt.Errorf("unique requested but pool size %d < length %d", len(pool), opt.length) + } + for len(bytesBuf) < opt.length { + c, err := pickRandom(pool) + if err != nil { + return "", err + } + if opt.unique && bytesContains(bytesBuf, c) { + continue + } + bytesBuf = append(bytesBuf, c) + } + if err := shuffleBytes(bytesBuf); err != nil { + return "", err + } + pwd := string(bytesBuf) + if opt.noRepeat && containsRun(pwd, 1) { + return generateOne(opt, sets) + } + if opt.noSeq && hasSeq(pwd, 3) { + return generateOne(opt, sets) + } + return pwd, nil +} + +func generateFromTemplate(opt options, ls, us, ds, ss string) (string, error) { + var out strings.Builder + for i := 0; i < len(opt.template); i++ { + ch := opt.template[i] + var set string + switch ch { + case 'l': + set = ls + case 'L': + set = us + case 'd': + set = ds + case 's': + set = ss + case 'a': + set = uniqueConcat(ls + us + ds) + case 'A': + set = uniqueConcat(ls + us + ds + ss) + default: + out.WriteByte(ch) + continue + } + c, err := pickRandom(set) + if err != nil { + return "", err + } + out.WriteByte(c) + } + pwd := out.String() + if opt.noRepeat && containsRun(pwd, 1) { + return generateFromTemplate(opt, ls, us, ds, ss) + } + if opt.noSeq && hasSeq(pwd, 3) { + return generateFromTemplate(opt, ls, us, ds, ss) + } + return pwd, nil +} + +func bytesContains(b []byte, c byte) bool { + for _, x := range b { + if x == c { + return true + } + } + return false +} + +// entropy helpers +func entropyForTemplate(tpl string, ls, us, ds, ss string) float64 { + bits := 0.0 + for i := 0; i < len(tpl); i++ { + ch := tpl[i] + size := 1 + switch ch { + case 'l': + size = len(ls) + case 'L': + size = len(us) + case 'd': + size = len(ds) + case 's': + size = len(ss) + case 'a': + size = len(ls) + len(us) + len(ds) + case 'A': + size = len(ls) + len(us) + len(ds) + len(ss) + default: + size = 1 + } + bits += math.Log2(float64(size)) + } + return bits +} + +func entropyForCombined(length int, poolSize int) float64 { + return float64(length) * math.Log2(float64(poolSize)) +} + +// sendToClipboard posts to Virtual Clipboard (preferred) or legacy custom endpoint +func sendToClipboard(opt options, pwd string) (bool, error) { + // Preferred: Virtual Clipboard + if opt.clipBase != "" && opt.clipRoom != "" { + url := strings.TrimRight(opt.clipBase, "/") + "/api/" + opt.clipRoom + "/clip" + payload := map[string]string{ + "type": opt.clipType, + "content": pwd, + } + if opt.clipAuthor != "" { + payload["author"] = opt.clipAuthor + } + body, _ := json.Marshal(payload) + if opt.dryRun { + log.Printf("[dry-run] VC POST %s -> %s", url, string(body)) + return false, nil + } + client := &http.Client{Timeout: opt.timeout} + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return false, err + } + req.Header.Set("Content-Type", "application/json") + if tok := opt.clipToken; tok != "" { + req.Header.Set("X-Token", tok) + } + resp, err := client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + io.ReadAll(resp.Body) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return true, nil + } + return false, fmt.Errorf("clipboard API responded %s", resp.Status) + } + + // Fallback: legacy/custom + if opt.sendURL == "" { + return false, nil + } + method := strings.ToUpper(opt.sendMethod) + if method == "" { + method = "POST" + } + payload := map[string]string{opt.sendField: pwd} + body, _ := json.Marshal(payload) + if opt.dryRun { + log.Printf("[dry-run] Custom %s %s -> %s (headers=%v)", method, opt.sendURL, string(body), opt.sendHeaders) + return false, nil + } + client := &http.Client{Timeout: opt.timeout} + req, err := http.NewRequest(method, opt.sendURL, bytes.NewReader(body)) + if err != nil { + return false, err + } + req.Header.Set("Content-Type", "application/json") + for _, kv := range opt.sendHeaders { + parts := strings.SplitN(kv, ":", 2) + if len(parts) == 2 { + req.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])) + } + } + resp, err := client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + io.ReadAll(resp.Body) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return true, nil + } + return false, fmt.Errorf("clipboard API responded %s", resp.Status) +} + +// ==== Web UI ==== + +var page = template.Must(template.New("idx").Parse(` + +
+ + +