Files
virtual-clipboard-generator/main.go
jbergner bbc1a53a8a
All checks were successful
release-tag / release-image (push) Successful in 1m35s
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
Switched to default port 8080
2025-09-06 20:28:17 +02:00

733 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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", ":8080")
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))
}