initial commit
This commit is contained in:
@@ -0,0 +1,596 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/owen/vibedns/internal/auditlog"
|
||||
"github.com/owen/vibedns/internal/auth"
|
||||
"github.com/owen/vibedns/internal/backup"
|
||||
"github.com/owen/vibedns/internal/config"
|
||||
"github.com/owen/vibedns/internal/database"
|
||||
)
|
||||
|
||||
// --- config -------------------------------------------------------------
|
||||
|
||||
func configCommand() *Command {
|
||||
return &Command{
|
||||
Name: "config",
|
||||
Summary: "inspect and validate configuration (check, show)",
|
||||
Usage: "Usage: vibedns config <check|show> [flags]\n\n" +
|
||||
" check validate the bootstrap and stored configuration\n" +
|
||||
" show print the effective configuration",
|
||||
Run: runConfig,
|
||||
}
|
||||
}
|
||||
|
||||
func runConfig(ctx context.Context, env *Env, args []string) error {
|
||||
sub, rest := splitSub(args)
|
||||
switch sub {
|
||||
case "check", "":
|
||||
return runConfigCheck(ctx, env, rest)
|
||||
case "show":
|
||||
return runConfigShow(ctx, env, rest)
|
||||
default:
|
||||
return Exit(2, "unknown subcommand %q; expected check or show", sub)
|
||||
}
|
||||
}
|
||||
|
||||
// runConfigCheck validates everything it can without binding a port, so it is
|
||||
// safe to run on a live server and useful in a deployment pipeline.
|
||||
func runConfigCheck(ctx context.Context, env *Env, args []string) error {
|
||||
c := configCommand()
|
||||
fs := newFlagSet(env, c)
|
||||
env.Boot.BindFlags(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w := env.Stdout
|
||||
problems := 0
|
||||
report := func(ok bool, label, detail string) {
|
||||
if ok {
|
||||
fmt.Fprintf(w, " ok %-28s %s\n", label, detail)
|
||||
return
|
||||
}
|
||||
problems++
|
||||
fmt.Fprintf(w, " FAIL %-28s %s\n", label, detail)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "Configuration check\n\n")
|
||||
|
||||
if err := env.Boot.Validate(); err != nil {
|
||||
report(false, "startup options", err.Error())
|
||||
} else {
|
||||
report(true, "startup options", "valid")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(env.Boot.DBPath); err != nil {
|
||||
report(true, "database", fmt.Sprintf("%s (will be created)", env.Boot.DBPath))
|
||||
fmt.Fprintf(w, "\n%d problem(s) found.\n", problems)
|
||||
if problems > 0 {
|
||||
return Exit(1, "configuration check failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
db, err := database.Open(env.Boot.DBPath)
|
||||
if err != nil {
|
||||
report(false, "database", err.Error())
|
||||
return Exit(1, "configuration check failed")
|
||||
}
|
||||
defer db.Close()
|
||||
report(true, "database", env.Boot.DBPath)
|
||||
|
||||
statuses, err := db.MigrationStatuses(ctx)
|
||||
if err != nil {
|
||||
report(false, "migrations", err.Error())
|
||||
} else {
|
||||
pending, drift := 0, 0
|
||||
for _, m := range statuses {
|
||||
if !m.Applied {
|
||||
pending++
|
||||
}
|
||||
if m.Drifted {
|
||||
drift++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case drift > 0:
|
||||
report(false, "migrations", fmt.Sprintf("%d applied migration(s) no longer match the embedded files", drift))
|
||||
case pending > 0:
|
||||
report(true, "migrations", fmt.Sprintf("%d pending, will apply on start", pending))
|
||||
default:
|
||||
report(true, "migrations", fmt.Sprintf("all %d applied", len(statuses)))
|
||||
}
|
||||
}
|
||||
|
||||
stored, err := db.Settings(ctx)
|
||||
if err != nil {
|
||||
report(false, "settings", err.Error())
|
||||
return Exit(1, "configuration check failed")
|
||||
}
|
||||
settings := config.LoadSettings(stored)
|
||||
if err := settings.Validate(); err != nil {
|
||||
report(false, "settings", err.Error())
|
||||
} else {
|
||||
report(true, "settings", "valid")
|
||||
}
|
||||
|
||||
// The open-resolver check is the one worth being loud about.
|
||||
if settings.DNS.Recursion {
|
||||
wide := widestAllowance(settings.Resolver.AllowNetworks)
|
||||
if wide != "" {
|
||||
report(false, "recursion ACL",
|
||||
fmt.Sprintf("%s allows the entire Internet to use this server as a resolver", wide))
|
||||
} else {
|
||||
report(true, "recursion ACL",
|
||||
fmt.Sprintf("%d network(s) permitted", len(settings.Resolver.AllowNetworks)))
|
||||
}
|
||||
} else {
|
||||
report(true, "recursion", "disabled")
|
||||
}
|
||||
|
||||
if _, err := db.Admin(ctx); errors.Is(err, database.ErrNotFound) {
|
||||
report(true, "administrator", "not created yet, will be generated on first start")
|
||||
} else if err != nil {
|
||||
report(false, "administrator", err.Error())
|
||||
} else {
|
||||
report(true, "administrator", "present")
|
||||
}
|
||||
|
||||
if settings.Backup.Enabled {
|
||||
dir := settings.Backup.Directory
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
report(false, "backup directory", err.Error())
|
||||
} else {
|
||||
report(true, "backup directory", dir)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\n%d problem(s) found.\n", problems)
|
||||
if problems > 0 {
|
||||
return Exit(1, "configuration check failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// widestAllowance returns the first ACL entry that covers the whole Internet.
|
||||
func widestAllowance(allow []string) string {
|
||||
for _, a := range allow {
|
||||
switch strings.TrimSpace(a) {
|
||||
case "0.0.0.0/0", "::/0":
|
||||
return a
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func runConfigShow(ctx context.Context, env *Env, args []string) error {
|
||||
c := configCommand()
|
||||
fs := newFlagSet(env, c)
|
||||
env.Boot.BindFlags(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := database.Open(env.Boot.DBPath)
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
stored, err := db.Settings(ctx)
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
settings := config.LoadSettings(stored)
|
||||
|
||||
keys := settings.ToMap()
|
||||
names := make([]string, 0, len(keys))
|
||||
for k := range keys {
|
||||
names = append(names, k)
|
||||
}
|
||||
sortStrings(names)
|
||||
|
||||
fmt.Fprintf(env.Stdout, "# effective configuration for %s\n", env.Boot.DBPath)
|
||||
for _, k := range names {
|
||||
v := keys[k]
|
||||
if strings.Contains(v, "\n") {
|
||||
v = strings.ReplaceAll(v, "\n", ",")
|
||||
}
|
||||
fmt.Fprintf(env.Stdout, "%-32s %s\n", k, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- admin --------------------------------------------------------------
|
||||
|
||||
func adminCommand() *Command {
|
||||
return &Command{
|
||||
Name: "admin",
|
||||
Summary: "manage the administrator account (reset-password, show)",
|
||||
Usage: "Usage: vibedns admin <reset-password|show> [flags]\n\n" +
|
||||
" reset-password generate or set a new administrator password\n" +
|
||||
" show print the administrator username",
|
||||
Run: runAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
func runAdmin(ctx context.Context, env *Env, args []string) error {
|
||||
sub, rest := splitSub(args)
|
||||
switch sub {
|
||||
case "reset-password":
|
||||
return runResetPassword(ctx, env, rest)
|
||||
case "show":
|
||||
return runAdminShow(ctx, env, rest)
|
||||
default:
|
||||
return Exit(2, "unknown subcommand %q; expected reset-password or show", sub)
|
||||
}
|
||||
}
|
||||
|
||||
// runResetPassword is the recovery path for a lost password. It requires
|
||||
// filesystem access to the database, which is the only credential it can
|
||||
// sensibly demand.
|
||||
func runResetPassword(ctx context.Context, env *Env, args []string) error {
|
||||
c := adminCommand()
|
||||
fs := newFlagSet(env, c)
|
||||
env.Boot.BindFlags(fs)
|
||||
password := fs.String("password", "",
|
||||
"new password (omit to generate one; prefer omitting, since arguments are visible in the process list)")
|
||||
username := fs.String("username", "", "also change the username")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := database.Open(env.Boot.DBPath)
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.Migrate(ctx); err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
|
||||
admin, err := db.Admin(ctx)
|
||||
if errors.Is(err, database.ErrNotFound) {
|
||||
return Exit(1, "no administrator account exists yet; start the server once to create one")
|
||||
}
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
|
||||
newPassword := *password
|
||||
generated := false
|
||||
if newPassword == "" {
|
||||
newPassword, err = auth.GeneratePassword(20)
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
generated = true
|
||||
}
|
||||
if err := auth.ValidatePassword(newPassword); err != nil {
|
||||
return Exit(2, "%v", err)
|
||||
}
|
||||
|
||||
name := admin.Username
|
||||
if *username != "" {
|
||||
if err := config.ValidateUsername(*username); err != nil {
|
||||
return Exit(2, "%v", err)
|
||||
}
|
||||
name = *username
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
// The reset flag is set so the UI keeps prompting until a human picks a
|
||||
// password of their own.
|
||||
if err := db.UpdateAdminCredentials(ctx, name, hash, generated); err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
|
||||
log := newLogger("error", "text", env.Stderr)
|
||||
audit := auditlog.New(db, log)
|
||||
audit.Record(ctx, auditlog.CLIActor(), "admin.password_reset", auditlog.ObjectAdmin, "1", name,
|
||||
"password reset from the command line")
|
||||
|
||||
fmt.Fprintf(env.Stdout, "\nAdministrator credentials updated.\n\n")
|
||||
fmt.Fprintf(env.Stdout, " Username: %s\n", name)
|
||||
if generated {
|
||||
fmt.Fprintf(env.Stdout, " Password: %s\n", newPassword)
|
||||
fmt.Fprintf(env.Stdout, "\nThis password will not be displayed again.\n")
|
||||
} else {
|
||||
fmt.Fprintf(env.Stdout, " Password: (as supplied)\n")
|
||||
}
|
||||
fmt.Fprintf(env.Stdout, "\nRestart the server, or wait a few minutes, for the change to take effect\n"+
|
||||
"on sessions that authenticated with the old password.\n\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAdminShow(ctx context.Context, env *Env, args []string) error {
|
||||
c := adminCommand()
|
||||
fs := newFlagSet(env, c)
|
||||
env.Boot.BindFlags(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := database.Open(env.Boot.DBPath)
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
admin, err := db.Admin(ctx)
|
||||
if errors.Is(err, database.ErrNotFound) {
|
||||
return Exit(1, "no administrator account exists yet")
|
||||
}
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(env.Stdout, "Username: %s\n", admin.Username)
|
||||
fmt.Fprintf(env.Stdout, "Created: %s\n", admin.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(env.Stdout, "Updated: %s\n", admin.UpdatedAt.Format("2006-01-02 15:04:05"))
|
||||
if admin.LastLoginAt != nil {
|
||||
fmt.Fprintf(env.Stdout, "Last sign-in: %s\n", admin.LastLoginAt.Format("2006-01-02 15:04:05"))
|
||||
} else {
|
||||
fmt.Fprintf(env.Stdout, "Last sign-in: never\n")
|
||||
}
|
||||
fmt.Fprintf(env.Stdout, "Must change password: %t\n", admin.MustChangePassword)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- database -----------------------------------------------------------
|
||||
|
||||
func databaseCommand() *Command {
|
||||
return &Command{
|
||||
Name: "database",
|
||||
Summary: "database maintenance (migrate, backup, restore, vacuum, stats)",
|
||||
Usage: "Usage: vibedns database <migrate|backup|restore|vacuum|stats> [flags]\n\n" +
|
||||
" migrate apply pending schema migrations\n" +
|
||||
" backup write a consistent backup copy\n" +
|
||||
" restore stage a backup to be applied on the next start\n" +
|
||||
" vacuum reclaim space after large deletions\n" +
|
||||
" stats print size and row counts",
|
||||
Run: runDatabase,
|
||||
}
|
||||
}
|
||||
|
||||
func runDatabase(ctx context.Context, env *Env, args []string) error {
|
||||
sub, rest := splitSub(args)
|
||||
switch sub {
|
||||
case "migrate":
|
||||
return runMigrate(ctx, env, rest)
|
||||
case "backup":
|
||||
return runBackup(ctx, env, rest)
|
||||
case "restore":
|
||||
return runRestore(ctx, env, rest)
|
||||
case "vacuum":
|
||||
return runVacuum(ctx, env, rest)
|
||||
case "stats":
|
||||
return runDBStats(ctx, env, rest)
|
||||
default:
|
||||
return Exit(2, "unknown subcommand %q; expected migrate, backup, restore, vacuum or stats", sub)
|
||||
}
|
||||
}
|
||||
|
||||
func openDB(env *Env, fs interface{ Parse([]string) error }, args []string) (*database.DB, error) {
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := database.Open(env.Boot.DBPath)
|
||||
if err != nil {
|
||||
return nil, Exit(1, "%v", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func runMigrate(ctx context.Context, env *Env, args []string) error {
|
||||
c := databaseCommand()
|
||||
fs := newFlagSet(env, c)
|
||||
env.Boot.BindFlags(fs)
|
||||
db, err := openDB(env, fs, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
before, _ := db.SchemaVersion(ctx)
|
||||
applied, err := db.Migrate(ctx)
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
after, _ := db.SchemaVersion(ctx)
|
||||
|
||||
if applied == 0 {
|
||||
fmt.Fprintf(env.Stdout, "Database is already at schema version %d; nothing to do.\n", after)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(env.Stdout, "Applied %d migration(s): schema version %d to %d.\n", applied, before, after)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runBackup(ctx context.Context, env *Env, args []string) error {
|
||||
c := databaseCommand()
|
||||
fs := newFlagSet(env, c)
|
||||
env.Boot.BindFlags(fs)
|
||||
output := fs.String("output", "", "directory to write the backup into (defaults to the configured one)")
|
||||
db, err := openDB(env, fs, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.Migrate(ctx); err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
|
||||
stored, err := db.Settings(ctx)
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
settings := config.LoadSettings(stored)
|
||||
|
||||
dir := *output
|
||||
if dir == "" {
|
||||
dir = settings.Backup.Directory
|
||||
}
|
||||
if dir == "" {
|
||||
dir = filepath.Join(filepath.Dir(env.Boot.DBPath), "backups")
|
||||
}
|
||||
|
||||
log := newLogger("error", "text", env.Stderr)
|
||||
mgr := backup.New(db, log, backup.Config{
|
||||
Enabled: true,
|
||||
Directory: dir,
|
||||
IntervalHours: settings.Backup.IntervalHours,
|
||||
Retention: settings.Backup.Retention,
|
||||
})
|
||||
|
||||
info, err := mgr.Run(ctx)
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
|
||||
audit := auditlog.New(db, log)
|
||||
audit.Record(ctx, auditlog.CLIActor(), "backup.create", auditlog.ObjectBackup, info.Name, info.Name,
|
||||
fmt.Sprintf("bytes=%d", info.SizeBytes))
|
||||
|
||||
fmt.Fprintf(env.Stdout, "Backup written to %s (%.1f MB).\n", info.Path, info.SizeMB())
|
||||
return nil
|
||||
}
|
||||
|
||||
func runRestore(ctx context.Context, env *Env, args []string) error {
|
||||
c := databaseCommand()
|
||||
fs := newFlagSet(env, c)
|
||||
env.Boot.BindFlags(fs)
|
||||
file := fs.String("file", "", "backup file to restore from (required)")
|
||||
yes := fs.Bool("yes", false, "skip the interactive confirmation")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *file == "" {
|
||||
return Exit(2, "--file is required; pass the path to a backup produced by `database backup`")
|
||||
}
|
||||
|
||||
if err := backup.Verify(*file); err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(
|
||||
"Restoring %s will replace every zone, record, policy and setting in %s.\n"+
|
||||
"The current database is preserved alongside it. Continue?",
|
||||
*file, env.Boot.DBPath)
|
||||
if !*yes && !confirm(env, prompt) {
|
||||
fmt.Fprintln(env.Stdout, "Restore cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := backup.StageRestore(env.Boot.DBPath, *file); err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
fmt.Fprintf(env.Stdout,
|
||||
"Restore staged. It is applied the next time the server starts.\n"+
|
||||
"Restart the service now to complete it.\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runVacuum(ctx context.Context, env *Env, args []string) error {
|
||||
c := databaseCommand()
|
||||
fs := newFlagSet(env, c)
|
||||
env.Boot.BindFlags(fs)
|
||||
db, err := openDB(env, fs, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
before, _ := db.Stats(ctx)
|
||||
if err := db.Checkpoint(ctx); err != nil {
|
||||
return Exit(1, "checkpoint the write-ahead log: %v", err)
|
||||
}
|
||||
if err := db.Vacuum(ctx); err != nil {
|
||||
return Exit(1, "vacuum: %v", err)
|
||||
}
|
||||
after, _ := db.Stats(ctx)
|
||||
|
||||
fmt.Fprintf(env.Stdout, "Vacuum complete: %s reclaimed (%s to %s).\n",
|
||||
humanSize(before.SizeBytes-after.SizeBytes),
|
||||
humanSize(before.SizeBytes), humanSize(after.SizeBytes))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDBStats(ctx context.Context, env *Env, args []string) error {
|
||||
c := databaseCommand()
|
||||
fs := newFlagSet(env, c)
|
||||
env.Boot.BindFlags(fs)
|
||||
db, err := openDB(env, fs, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
s, err := db.Stats(ctx)
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(env.Stdout, "Path: %s\n", s.Path)
|
||||
fmt.Fprintf(env.Stdout, "Size: %s\n", humanSize(s.SizeBytes))
|
||||
fmt.Fprintf(env.Stdout, "Write-ahead log: %s\n", humanSize(s.WALBytes))
|
||||
fmt.Fprintf(env.Stdout, "Schema version: %d\n", s.SchemaVer)
|
||||
fmt.Fprintf(env.Stdout, "Free pages: %d of %d\n", s.FreePages, s.PageCount)
|
||||
fmt.Fprintf(env.Stdout, "\nZones: %d\n", s.Zones)
|
||||
fmt.Fprintf(env.Stdout, "Records: %d\n", s.Records)
|
||||
fmt.Fprintf(env.Stdout, "List domains: %d\n", s.Domains)
|
||||
fmt.Fprintf(env.Stdout, "Query log rows: %d\n", s.QueryLogs)
|
||||
fmt.Fprintf(env.Stdout, "Audit log rows: %d\n", s.AuditLogs)
|
||||
fmt.Fprintf(env.Stdout, "API tokens: %d\n", s.APITokens)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
// splitSub pulls the second-level subcommand out of the argument list.
|
||||
func splitSub(args []string) (string, []string) {
|
||||
for i, a := range args {
|
||||
if !strings.HasPrefix(a, "-") {
|
||||
return a, append(append([]string{}, args[:i]...), args[i+1:]...)
|
||||
}
|
||||
}
|
||||
return "", args
|
||||
}
|
||||
|
||||
func sortStrings(s []string) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
||||
s[j], s[j-1] = s[j-1], s[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func humanSize(b int64) string {
|
||||
const unit = 1024
|
||||
if b < 0 {
|
||||
return "0 B"
|
||||
}
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
f := float64(b)
|
||||
for _, u := range []string{"KB", "MB", "GB", "TB"} {
|
||||
f /= unit
|
||||
if f < unit {
|
||||
return fmt.Sprintf("%.1f %s", f, u)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%.1f PB", f)
|
||||
}
|
||||
Reference in New Issue
Block a user