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(` Passwort-Generator
🔐 Passwort-Generator
Ein Klick – generieren & an den Virtual Clipboard-Server posten.
Entropy: – API: – Room: {{.Room}} Base: {{.Base}}
`)) 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)) }