package ntfy import ( "bytes" "context" "errors" "fmt" "os" "os/exec" "regexp" "strings" "time" ) type Client struct { Bin string Config string Timeout time.Duration } type User struct { Username string Role string Tier string Access []AccessEntry } type AccessEntry struct { Topic string Perm string // read-write, read-only, write-only, deny, deny-all? etc. } type Token struct { Token string Label string Expiry string } var ( reUserLine = regexp.MustCompile(`^user\s+(\S+)\s+\(role:\s*([^,]+),\s*tier:\s*([^)]+)\)`) reAccessLine = regexp.MustCompile(`^\s*-\s+(.+?)\s+access\s+to\s+topic\s+(.+)$`) // e.g. "- read-only access to topic test" ) // Run executes `ntfy ...` with --config. func (c *Client) Run(ctx context.Context, args []string, env map[string]string, stdin string) (string, string, int, error) { if c.Bin == "" { return "", "", 0, errors.New("ntfy binary not set") } // Many (newer) ntfy versions support `--config/-c` for server-side commands // (serve/user/access/token). Some older builds do not. We try with --config // first (if configured) and fall back to running without it if the binary // rejects the flag. withConfig := args if c.Config != "" { withConfig = append([]string{"--config", c.Config}, args...) } tctx := ctx var cancel context.CancelFunc if c.Timeout > 0 { tctx, cancel = context.WithTimeout(ctx, c.Timeout) defer cancel() } // helper to execute once runOnce := func(a []string) (string, string, int, error) { cmd := exec.CommandContext(tctx, c.Bin, a...) if stdin != "" { cmd.Stdin = strings.NewReader(stdin) } var outb, errb bytes.Buffer cmd.Stdout = &outb cmd.Stderr = &errb if env != nil { // inherit env + add/override provided vars cmd.Env = append([]string{}, os.Environ()...) for k, v := range env { cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) } } err := cmd.Run() exit := 0 if err != nil { var ee *exec.ExitError if errors.As(err, &ee) { exit = ee.ExitCode() } else if errors.Is(err, context.DeadlineExceeded) { return outb.String(), errb.String(), -1, fmt.Errorf("ntfy timeout") } else { return outb.String(), errb.String(), -1, err } } return outb.String(), errb.String(), exit, nil } // first attempt (maybe with --config) out, errOut, exit, err := runOnce(withConfig) if c.Config == "" { return out, errOut, exit, err } // fallback for older ntfy binaries that don't know --config if exit != 0 { errTrim := strings.TrimSpace(errOut) if strings.Contains(errTrim, "flag provided but not defined: -config") || strings.Contains(errTrim, "unknown flag") && strings.Contains(errTrim, "config") { return runOnce(args) } } return out, errOut, exit, err } func (c *Client) ListUsers(ctx context.Context) ([]User, error) { out, errOut, exit, err := c.Run(ctx, []string{"user", "list"}, nil, "") if err != nil || exit != 0 { return nil, fmt.Errorf("ntfy user list failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut)) } return parseUsers(out), nil } func parseUsers(out string) []User { lines := strings.Split(out, "\n") var users []User var cur *User for _, ln := range lines { ln = strings.TrimRight(ln, "\r") if m := reUserLine.FindStringSubmatch(ln); m != nil { u := User{ Username: m[1], Role: strings.TrimSpace(m[2]), Tier: strings.TrimSpace(m[3]), } users = append(users, u) cur = &users[len(users)-1] continue } if cur != nil { if strings.HasPrefix(strings.TrimSpace(ln), "-") { // Try parse access entry line // Example: "- read-only access to topic test" // We also accept: "- read-write access to all topics (admin role)" -> store as Topic="*" if strings.Contains(ln, "access to all topics") { cur.Access = append(cur.Access, AccessEntry{Topic: "*", Perm: "read-write"}) continue } m := reAccessLine.FindStringSubmatch(ln) if m != nil { perm := strings.TrimSpace(m[1]) topic := strings.TrimSpace(m[2]) cur.Access = append(cur.Access, AccessEntry{Topic: topic, Perm: perm}) } } } } return users } func (c *Client) AddUser(ctx context.Context, username, role, tier, password string) error { args := []string{"user", "add"} if role != "" { args = append(args, "--role="+role) } if tier != "" && tier != "none" { args = append(args, "--tier="+tier) } args = append(args, username) env := map[string]string{} if password != "" { env["NTFY_PASSWORD"] = password } _, errOut, exit, err := c.Run(ctx, args, env, "") if err != nil || exit != 0 { return fmt.Errorf("ntfy user add failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut)) } return nil } func (c *Client) DelUser(ctx context.Context, username string) error { _, errOut, exit, err := c.Run(ctx, []string{"user", "del", username}, nil, "") if err != nil || exit != 0 { return fmt.Errorf("ntfy user del failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut)) } return nil } func (c *Client) ChangePass(ctx context.Context, username, password string) error { env := map[string]string{"NTFY_PASSWORD": password} _, errOut, exit, err := c.Run(ctx, []string{"user", "change-pass", username}, env, "") if err != nil || exit != 0 { return fmt.Errorf("ntfy user change-pass failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut)) } return nil } func (c *Client) ChangeRole(ctx context.Context, username, role string) error { _, errOut, exit, err := c.Run(ctx, []string{"user", "change-role", username, role}, nil, "") if err != nil || exit != 0 { return fmt.Errorf("ntfy user change-role failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut)) } return nil } func (c *Client) ChangeTier(ctx context.Context, username, tier string) error { _, errOut, exit, err := c.Run(ctx, []string{"user", "change-tier", username, tier}, nil, "") if err != nil || exit != 0 { return fmt.Errorf("ntfy user change-tier failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut)) } return nil } func (c *Client) GrantAccess(ctx context.Context, username, topic, perm string) error { _, errOut, exit, err := c.Run(ctx, []string{"access", username, topic, perm}, nil, "") if err != nil || exit != 0 { return fmt.Errorf("ntfy access failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut)) } return nil } func (c *Client) ResetAccess(ctx context.Context, username string) error { _, errOut, exit, err := c.Run(ctx, []string{"access", "--reset", username}, nil, "") if err != nil || exit != 0 { return fmt.Errorf("ntfy access --reset failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut)) } return nil } func (c *Client) TokenList(ctx context.Context, username string) ([]Token, error) { out, errOut, exit, err := c.Run(ctx, []string{"token", "list", username}, nil, "") if err != nil || exit != 0 { return nil, fmt.Errorf("ntfy token list failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut)) } return parseTokens(out), nil } func (c *Client) TokenAdd(ctx context.Context, username, label, expires string) (string, error) { args := []string{"token", "add"} if expires != "" { args = append(args, "--expires="+expires) } if label != "" { args = append(args, "--label="+label) } args = append(args, username) out, errOut, exit, err := c.Run(ctx, args, nil, "") if err != nil || exit != 0 { return "", fmt.Errorf("ntfy token add failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut)) } // output contains token, usually like: "token tk_xxx added for user xyz" tok := extractToken(out) if tok == "" { // sometimes printed on stderr; try there tok = extractToken(errOut) } if tok == "" { return "", fmt.Errorf("token added but could not parse token from output") } return tok, nil } func (c *Client) TokenRemove(ctx context.Context, username, token string) error { _, errOut, exit, err := c.Run(ctx, []string{"token", "remove", username, token}, nil, "") if err != nil || exit != 0 { return fmt.Errorf("ntfy token remove failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut)) } return nil } var reToken = regexp.MustCompile(`\b(tk_[A-Za-z0-9]+)\b`) func extractToken(s string) string { m := reToken.FindStringSubmatch(s) if m == nil { return "" } return m[1] } func parseTokens(out string) []Token { lines := strings.Split(out, "\n") var toks []Token // token list output varies; do best-effort parse: each line containing tk_... for _, ln := range lines { ln = strings.TrimSpace(strings.TrimRight(ln, "\r")) if ln == "" { continue } if !strings.Contains(ln, "tk_") { continue } // naive: first token is token; remainder may have label/expiry m := reToken.FindStringSubmatch(ln) if m == nil { continue } t := Token{Token: m[1]} rest := strings.TrimSpace(strings.Replace(ln, t.Token, "", 1)) // attempt label=... exp=... if strings.Contains(rest, "label:") { if i := strings.Index(rest, "label:"); i >= 0 { t.Label = strings.TrimSpace(rest[i+6:]) } } toks = append(toks, t) } return toks }