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

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