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

This commit is contained in:
2025-09-06 17:18:43 +02:00
parent ca958f239c
commit 67269fd91e
5 changed files with 963 additions and 0 deletions

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

View File

@@ -0,0 +1,124 @@
# Git(tea) Actions workflow: Build and publish standalone binaries **plus** bundled `static/` assets
# ────────────────────────────────────────────────────────────────────
# ✧ Builds the Gobased 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 BootstrapAssets & 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 TagPush (vX.Y.Z) als ReleaseAssets.
#
# Secrets/variables:
# GITEA_TOKEN optional, falls default token keine ReleaseRechte 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 TagPushes
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
View 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
View File

@@ -0,0 +1,3 @@
module git.send.nrw/sendnrw/virtual-clipboard-generator
go 1.24.4

732
main.go Normal file
View 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))
}