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(`