RC-1.0
All checks were successful
release-tag / release-image (push) Successful in 1m29s
build-binaries / build (, amd64, linux) (push) Has been skipped
build-binaries / build (, arm, 7, linux) (push) Has been skipped
build-binaries / build (, arm64, linux) (push) Has been skipped
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
All checks were successful
release-tag / release-image (push) Successful in 1m29s
build-binaries / build (, amd64, linux) (push) Has been skipped
build-binaries / build (, arm, 7, linux) (push) Has been skipped
build-binaries / build (, arm64, linux) (push) Has been skipped
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
This commit is contained in:
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal file
@@ -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 }}
|
||||
124
.gitea/workflows/release.yml
Normal file
124
.gitea/workflows/release.yml
Normal file
@@ -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
|
||||
53
Dockerfile
Normal file
53
Dockerfile
Normal file
@@ -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"]
|
||||
3
go.mod
Normal file
3
go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module git.send.nrw/sendnrw/virtual-clipboard-generator
|
||||
|
||||
go 1.24.4
|
||||
732
main.go
Normal file
732
main.go
Normal file
@@ -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(`<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Passwort-Generator</title>
|
||||
<style>
|
||||
:root { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif; }
|
||||
.container { max-width: 720px; margin: 5vh auto; padding: 24px; border-radius: 16px; box-shadow: 0 8px 30px rgba(0,0,0,.08); }
|
||||
.h1 { font-size: 1.5rem; margin-bottom: 8px; }
|
||||
.sub { color: #555; margin-bottom: 16px; }
|
||||
.btn { display: inline-flex; align-items:center; gap:10px; padding: 12px 16px; border:0; border-radius: 12px; cursor:pointer; font-weight: 600; }
|
||||
.btn-primary { background: #111; color:#fff; }
|
||||
.row { display:flex; gap:12px; align-items:center; }
|
||||
.out { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; padding: 10px 12px; border:1px solid #e3e3e3; border-radius: 10px; flex:1; overflow:auto; white-space:pre-wrap; }
|
||||
.badge { display:inline-block; padding:.25rem .5rem; border-radius:999px; background:#f2f2f2; }
|
||||
.footer { margin-top:16px; color:#666; font-size:.9rem; }
|
||||
label { font-size:.9rem; color:#444; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="h1">🔐 Passwort-Generator</div>
|
||||
<div class="sub">Ein Klick – generieren & an den <em>Virtual Clipboard</em>-Server posten.</div>
|
||||
<div class="row" style="margin-bottom:12px;">
|
||||
<label>Count <input id="count" type="number" min="1" max="20" value="1" style="width:90px; padding:8px; border-radius:10px; border:1px solid #e3e3e3" /></label>
|
||||
<button id="go" class="btn btn-primary">Passwort generieren</button>
|
||||
<button id="copy" class="btn" title="in die lokale Browser-Zwischenablage kopieren">Kopieren</button>
|
||||
</div>
|
||||
<div class="out" id="out" placeholder="Hier erscheint das Passwort…"></div>
|
||||
<div style="margin-top:8px; display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
|
||||
<span class="badge" id="entropy">Entropy: –</span>
|
||||
<span class="badge" id="sent">API: –</span>
|
||||
<span class="badge" id="room">Room: {{.Room}}</span>
|
||||
<span class="badge" id="base">Base: {{.Base}}</span>
|
||||
</div>
|
||||
<div class="footer">Server POST: <code>{{.Base}}/api/{{.Room}}/clip</code> · Steuerung per <code>PWGEN_*</code>-ENV.</div>
|
||||
</div>
|
||||
<script>
|
||||
async function gen(count){
|
||||
const res = await fetch('/api/generate?count='+encodeURIComponent(count||1), {method:'POST', headers:{'X-Requested-With':'fetch'}});
|
||||
if(!res.ok){ const t = await res.text(); throw new Error('HTTP '+res.status+' '+t); }
|
||||
return res.json();
|
||||
}
|
||||
const go = document.getElementById('go');
|
||||
const out = document.getElementById('out');
|
||||
const ent = document.getElementById('entropy');
|
||||
const sent = document.getElementById('sent');
|
||||
const cnt = document.getElementById('count');
|
||||
const copy = document.getElementById('copy');
|
||||
|
||||
go.addEventListener('click', async ()=>{
|
||||
go.disabled = true; go.textContent='…';
|
||||
try{
|
||||
const data = await gen(cnt.value);
|
||||
if(Array.isArray(data) && data.length){
|
||||
out.textContent = data.map(r=>r.password).join('\n');
|
||||
const median = [...data.map(r=>r.entropy_bits)].sort((a,b)=>a-b)[Math.floor(data.length/2)];
|
||||
ent.textContent = 'Entropy: '+median.toFixed(1)+' bits';
|
||||
sent.textContent = 'API: '+(data.some(r=>r.sent)?'gesendet':'nicht gesendet');
|
||||
}
|
||||
}catch(e){ out.textContent = 'Fehler: '+e.message; }
|
||||
finally{ go.disabled=false; go.textContent='Passwort generieren'; }
|
||||
});
|
||||
|
||||
copy.addEventListener('click', async ()=>{
|
||||
try{ await navigator.clipboard.writeText(out.textContent); sent.textContent='API: lokal kopiert'; }catch(e){ alert('Kopieren fehlgeschlagen: '+e.message); }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
func basicAuth(user, pass string, next http.Handler) http.Handler {
|
||||
if user == "" && pass == "" {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok || u != user || p != pass {
|
||||
w.Header().Set("WWW-Authenticate", "Basic realm=restricted")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("Unauthorized"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func withSecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func startWeb(opt options) error {
|
||||
ls, us, ds, ss, anyPool := buildSets(opt)
|
||||
sets := [5]string{ls, us, ds, ss, anyPool}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = page.Execute(w, map[string]string{"Room": opt.clipRoom, "Base": opt.clipBase})
|
||||
})
|
||||
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
count := 1
|
||||
if c := r.URL.Query().Get("count"); c != "" {
|
||||
if n, err := strconv.Atoi(c); err == nil && n > 0 && n <= 50 {
|
||||
count = n
|
||||
}
|
||||
}
|
||||
res := make([]map[string]interface{}, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
pwd, err := generateOne(opt, sets)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
var bits float64
|
||||
if opt.template != "" {
|
||||
bits = entropyForTemplate(opt.template, ls, us, ds, ss)
|
||||
} else {
|
||||
bits = entropyForCombined(len(pwd), len(anyPool))
|
||||
}
|
||||
sent, err := sendToClipboard(opt, pwd)
|
||||
if err != nil {
|
||||
log.Printf("send error: %v", err)
|
||||
}
|
||||
res = append(res, map[string]interface{}{
|
||||
"password": pwd,
|
||||
"entropy_bits": bits,
|
||||
"sent": sent,
|
||||
"send_to": strings.TrimRight(opt.clipBase, "/") + "/api/" + opt.clipRoom + "/clip",
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(res)
|
||||
})
|
||||
|
||||
h := withSecurityHeaders(basicAuth(opt.webUser, opt.webPass, mux))
|
||||
log.Printf("Web UI listening on %s", opt.webAddr)
|
||||
return http.ListenAndServe(opt.webAddr, h)
|
||||
}
|
||||
|
||||
// ==== CLI ====
|
||||
|
||||
type result struct {
|
||||
Password string `json:"password"`
|
||||
Entropy float64 `json:"entropy_bits"`
|
||||
Sent bool `json:"sent"`
|
||||
SendTo string `json:"send_to,omitempty"`
|
||||
}
|
||||
|
||||
func runCLI(opt options) int {
|
||||
ls, us, ds, ss, anyPool := buildSets(opt)
|
||||
sets := [5]string{ls, us, ds, ss, anyPool}
|
||||
results := make([]result, 0, opt.count)
|
||||
|
||||
// Resolve effective target URL for reporting
|
||||
sendTo := ""
|
||||
if opt.clipBase != "" && opt.clipRoom != "" {
|
||||
sendTo = strings.TrimRight(opt.clipBase, "/") + "/api/" + opt.clipRoom + "/clip"
|
||||
} else if opt.sendURL != "" {
|
||||
sendTo = opt.sendURL
|
||||
}
|
||||
|
||||
for i := 0; i < opt.count; i++ {
|
||||
pwd, err := generateOne(opt, sets)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
var bits float64
|
||||
if opt.template != "" {
|
||||
bits = entropyForTemplate(opt.template, ls, us, ds, ss)
|
||||
} else {
|
||||
bits = entropyForCombined(len(pwd), len(anyPool))
|
||||
}
|
||||
sent, err := sendToClipboard(opt, pwd)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "send error:", err)
|
||||
}
|
||||
results = append(results, result{Password: pwd, Entropy: bits, Sent: sent, SendTo: sendTo})
|
||||
}
|
||||
|
||||
if opt.jsonOut {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(results)
|
||||
return 0
|
||||
}
|
||||
|
||||
if opt.count == 1 {
|
||||
fmt.Println(results[0].Password)
|
||||
fmt.Fprintf(os.Stderr, "entropy≈%.1f bits\n", results[0].Entropy)
|
||||
} else {
|
||||
for idx, r := range results {
|
||||
fmt.Printf("%2d: %s\n", idx+1, r.Password)
|
||||
}
|
||||
vals := make([]float64, 0, len(results))
|
||||
for _, r := range results {
|
||||
vals = append(vals, r.Entropy)
|
||||
}
|
||||
sort.Float64s(vals)
|
||||
fmt.Fprintf(os.Stderr, "entropy median≈%.1f bits\n", vals[len(vals)/2])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ==== main ====
|
||||
|
||||
func main() {
|
||||
opt := parseEnv()
|
||||
if opt.webEnabled {
|
||||
if err := startWeb(opt); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
os.Exit(runCLI(opt))
|
||||
}
|
||||
Reference in New Issue
Block a user