Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c302b1d719 | ||
|
|
ee31dff01e | ||
|
|
bed563fd20 | ||
|
|
647fd26e60 | ||
|
|
ae4cc8b420 | ||
|
|
131d3e778a | ||
|
|
b8dc39fd12 | ||
|
|
26f397afc0 | ||
|
|
28d6f9fa1f |
+5
-13
@@ -12,25 +12,17 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}"
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& addgroup -S metacrypt \
|
||||
&& adduser -S -G metacrypt -h /srv/metacrypt -s /sbin/nologin metacrypt \
|
||||
&& mkdir -p /srv/metacrypt && chown metacrypt:metacrypt /srv/metacrypt
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
COPY --from=builder /metacrypt /usr/local/bin/metacrypt
|
||||
|
||||
# /srv/metacrypt is the single volume mount point.
|
||||
# It must contain:
|
||||
# metacrypt.toml โ configuration file
|
||||
# certs/ โ TLS certificate and key
|
||||
# metacrypt.db โ created automatically on first run
|
||||
VOLUME /srv/metacrypt
|
||||
WORKDIR /srv/metacrypt
|
||||
# /srv/metacrypt (config, certs/, metacrypt.db) is bind-mounted at runtime by
|
||||
# MCP. No VOLUME / pre-created dir / USER โ the agent supplies --user 0:0, so
|
||||
# the image unpacks and runs cleanly under rootless podman.
|
||||
WORKDIR /
|
||||
|
||||
EXPOSE 8443
|
||||
EXPOSE 9443
|
||||
|
||||
USER metacrypt
|
||||
|
||||
ENTRYPOINT ["metacrypt"]
|
||||
CMD ["server", "--config", "/srv/metacrypt/metacrypt.toml"]
|
||||
|
||||
+6
-12
@@ -12,23 +12,17 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}"
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& addgroup -S metacrypt \
|
||||
&& adduser -S -G metacrypt -h /srv/metacrypt -s /sbin/nologin metacrypt \
|
||||
&& mkdir -p /srv/metacrypt && chown metacrypt:metacrypt /srv/metacrypt
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
COPY --from=builder /metacrypt-web /usr/local/bin/metacrypt-web
|
||||
|
||||
# /srv/metacrypt is the single volume mount point.
|
||||
# It must contain:
|
||||
# metacrypt.toml โ configuration file
|
||||
# certs/ โ TLS certificate and key
|
||||
VOLUME /srv/metacrypt
|
||||
WORKDIR /srv/metacrypt
|
||||
# /srv/metacrypt is bind-mounted at runtime by MCP (config + certs/). The
|
||||
# image deliberately does NOT declare a VOLUME or pre-create/chown the
|
||||
# directory, and does NOT set USER (the agent supplies --user 0:0), so it
|
||||
# unpacks and runs cleanly under rootless podman.
|
||||
WORKDIR /
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
USER metacrypt
|
||||
|
||||
ENTRYPOINT ["metacrypt-web"]
|
||||
CMD ["--config", "/srv/metacrypt/metacrypt.toml"]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.PHONY: build test vet lint proto-lint clean docker all devserver metacrypt metacrypt-web proto binaries
|
||||
.PHONY: build test vet lint proto-lint clean docker push all devserver metacrypt metacrypt-web proto binaries
|
||||
|
||||
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev)"
|
||||
MCR := mcr.svc.mcp.metacircular.net:8443
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(VERSION)"
|
||||
|
||||
binaries: metacrypt metacrypt-web
|
||||
|
||||
@@ -38,8 +40,12 @@ clean:
|
||||
rm -f metacrypt metacrypt-web
|
||||
|
||||
docker:
|
||||
docker build -t metacrypt -f Dockerfile.api .
|
||||
docker build -t metacrypt-web -f Dockerfile.web .
|
||||
docker build --build-arg VERSION=$(VERSION) -t $(MCR)/metacrypt:$(VERSION) -f Dockerfile.api .
|
||||
docker build --build-arg VERSION=$(VERSION) -t $(MCR)/metacrypt-web:$(VERSION) -f Dockerfile.web .
|
||||
|
||||
push: docker
|
||||
docker push $(MCR)/metacrypt:$(VERSION)
|
||||
docker push $(MCR)/metacrypt-web:$(VERSION)
|
||||
|
||||
docker-compose:
|
||||
docker compose -f deploy/docker/docker-compose.yml up --build
|
||||
|
||||
@@ -5,11 +5,10 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"git.wntrmute.dev/mc/mcdsl/terminal"
|
||||
"git.wntrmute.dev/mc/metacrypt/internal/barrier"
|
||||
"git.wntrmute.dev/mc/metacrypt/internal/config"
|
||||
"git.wntrmute.dev/mc/metacrypt/internal/crypto"
|
||||
@@ -59,16 +58,12 @@ func runInit(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("already initialized")
|
||||
}
|
||||
|
||||
fmt.Print("Enter seal password: ")
|
||||
pw1, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println()
|
||||
pw1, err := terminal.ReadPasswordBytes("Enter seal password: ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading password: %w", err)
|
||||
}
|
||||
|
||||
fmt.Print("Confirm seal password: ")
|
||||
pw2, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println()
|
||||
pw2, err := terminal.ReadPasswordBytes("Confirm seal password: ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading password: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,12 +4,10 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"git.wntrmute.dev/mc/mcdsl/terminal"
|
||||
"git.wntrmute.dev/mc/metacrypt/internal/config"
|
||||
"git.wntrmute.dev/mc/metacrypt/internal/crypto"
|
||||
"git.wntrmute.dev/mc/metacrypt/internal/db"
|
||||
@@ -52,9 +50,7 @@ func runMigrateAAD(cmd *cobra.Command, args []string) error {
|
||||
defer func() { _ = database.Close() }()
|
||||
|
||||
// Read unseal password.
|
||||
fmt.Fprint(os.Stderr, "Unseal password: ")
|
||||
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
passwordBytes, err := terminal.ReadPasswordBytes("Unseal password: ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,12 +4,10 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"git.wntrmute.dev/mc/mcdsl/terminal"
|
||||
"git.wntrmute.dev/mc/metacrypt/internal/barrier"
|
||||
"git.wntrmute.dev/mc/metacrypt/internal/config"
|
||||
"git.wntrmute.dev/mc/metacrypt/internal/crypto"
|
||||
@@ -62,9 +60,7 @@ func runMigrateBarrier(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Read unseal password.
|
||||
fmt.Fprint(os.Stderr, "Unseal password: ")
|
||||
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
passwordBytes, err := terminal.ReadPasswordBytes("Unseal password: ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
|
||||
"git.wntrmute.dev/mc/mcdsl/terminal"
|
||||
metacryptv1 "git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1"
|
||||
)
|
||||
|
||||
@@ -43,17 +43,15 @@ func runUnseal(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("one of --grpc-addr or --addr is required")
|
||||
}
|
||||
|
||||
fmt.Print("Unseal password: ")
|
||||
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec
|
||||
fmt.Println()
|
||||
password, err := terminal.ReadPassword("Unseal password: ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
|
||||
if unsealGRPCAddr != "" {
|
||||
return unsealViaGRPC(unsealGRPCAddr, unsealCACert, string(passwordBytes))
|
||||
return unsealViaGRPC(unsealGRPCAddr, unsealCACert, password)
|
||||
}
|
||||
return unsealViaREST(unsealAddr, unsealCACert, string(passwordBytes))
|
||||
return unsealViaREST(unsealAddr, unsealCACert, password)
|
||||
}
|
||||
|
||||
func buildTLSConfig(caCertPath string) (*tls.Config, error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/acme.proto
|
||||
|
||||
package metacryptv1
|
||||
@@ -600,7 +600,7 @@ const file_proto_metacrypt_v1_acme_proto_rawDesc = "" +
|
||||
"\tSetConfig\x12\x1e.metacrypt.v1.SetConfigRequest\x1a\x1f.metacrypt.v1.SetConfigResponse\x12U\n" +
|
||||
"\fListAccounts\x12!.metacrypt.v1.ListAccountsRequest\x1a\".metacrypt.v1.ListAccountsResponse\x12O\n" +
|
||||
"\n" +
|
||||
"ListOrders\x12\x1f.metacrypt.v1.ListOrdersRequest\x1a .metacrypt.v1.ListOrdersResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
"ListOrders\x12\x1f.metacrypt.v1.ListOrdersRequest\x1a .metacrypt.v1.ListOrdersResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v1_acme_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/acme.proto
|
||||
|
||||
package metacryptv1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/auth.proto
|
||||
|
||||
package metacryptv1
|
||||
@@ -324,7 +324,7 @@ const file_proto_metacrypt_v1_auth_proto_rawDesc = "" +
|
||||
"\vAuthService\x12@\n" +
|
||||
"\x05Login\x12\x1a.metacrypt.v1.LoginRequest\x1a\x1b.metacrypt.v1.LoginResponse\x12C\n" +
|
||||
"\x06Logout\x12\x1b.metacrypt.v1.LogoutRequest\x1a\x1c.metacrypt.v1.LogoutResponse\x12L\n" +
|
||||
"\tTokenInfo\x12\x1e.metacrypt.v1.TokenInfoRequest\x1a\x1f.metacrypt.v1.TokenInfoResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
"\tTokenInfo\x12\x1e.metacrypt.v1.TokenInfoRequest\x1a\x1f.metacrypt.v1.TokenInfoResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v1_auth_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/auth.proto
|
||||
|
||||
package metacryptv1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/barrier.proto
|
||||
|
||||
package metacryptv1
|
||||
@@ -455,7 +455,7 @@ const file_proto_metacrypt_v1_barrier_proto_rawDesc = "" +
|
||||
"\bListKeys\x12\x1d.metacrypt.v1.ListKeysRequest\x1a\x1e.metacrypt.v1.ListKeysResponse\x12L\n" +
|
||||
"\tRotateMEK\x12\x1e.metacrypt.v1.RotateMEKRequest\x1a\x1f.metacrypt.v1.RotateMEKResponse\x12L\n" +
|
||||
"\tRotateKey\x12\x1e.metacrypt.v1.RotateKeyRequest\x1a\x1f.metacrypt.v1.RotateKeyResponse\x12T\n" +
|
||||
"\aMigrate\x12#.metacrypt.v1.MigrateBarrierRequest\x1a$.metacrypt.v1.MigrateBarrierResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
"\aMigrate\x12#.metacrypt.v1.MigrateBarrierRequest\x1a$.metacrypt.v1.MigrateBarrierResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v1_barrier_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/barrier.proto
|
||||
|
||||
package metacryptv1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/common.proto
|
||||
|
||||
package metacryptv1
|
||||
@@ -24,7 +24,7 @@ var File_proto_metacrypt_v1_common_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_proto_metacrypt_v1_common_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x1fproto/metacrypt/v1/common.proto\x12\fmetacrypt.v1B>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
"\x1fproto/metacrypt/v1/common.proto\x12\fmetacrypt.v1B<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
|
||||
var file_proto_metacrypt_v1_common_proto_goTypes = []any{}
|
||||
var file_proto_metacrypt_v1_common_proto_depIdxs = []int32{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/engine.proto
|
||||
|
||||
package metacryptv1
|
||||
@@ -483,7 +483,7 @@ const file_proto_metacrypt_v1_engine_proto_rawDesc = "" +
|
||||
"\aUnmount\x12\x1c.metacrypt.v1.UnmountRequest\x1a\x1d.metacrypt.v1.UnmountResponse\x12O\n" +
|
||||
"\n" +
|
||||
"ListMounts\x12\x1f.metacrypt.v1.ListMountsRequest\x1a .metacrypt.v1.ListMountsResponse\x12F\n" +
|
||||
"\aExecute\x12\x1c.metacrypt.v1.ExecuteRequest\x1a\x1d.metacrypt.v1.ExecuteResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
"\aExecute\x12\x1c.metacrypt.v1.ExecuteRequest\x1a\x1d.metacrypt.v1.ExecuteResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v1_engine_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/engine.proto
|
||||
|
||||
package metacryptv1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/pki.proto
|
||||
|
||||
package metacryptv1
|
||||
@@ -324,7 +324,7 @@ const file_proto_metacrypt_v1_pki_proto_rawDesc = "" +
|
||||
"PKIService\x12R\n" +
|
||||
"\vGetRootCert\x12 .metacrypt.v1.GetRootCertRequest\x1a!.metacrypt.v1.GetRootCertResponse\x12I\n" +
|
||||
"\bGetChain\x12\x1d.metacrypt.v1.GetChainRequest\x1a\x1e.metacrypt.v1.GetChainResponse\x12X\n" +
|
||||
"\rGetIssuerCert\x12\".metacrypt.v1.GetIssuerCertRequest\x1a#.metacrypt.v1.GetIssuerCertResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
"\rGetIssuerCert\x12\".metacrypt.v1.GetIssuerCertRequest\x1a#.metacrypt.v1.GetIssuerCertResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v1_pki_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/pki.proto
|
||||
|
||||
package metacryptv1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/policy.proto
|
||||
|
||||
package metacryptv1
|
||||
@@ -481,7 +481,7 @@ const file_proto_metacrypt_v1_policy_proto_rawDesc = "" +
|
||||
"\fCreatePolicy\x12!.metacrypt.v1.CreatePolicyRequest\x1a\".metacrypt.v1.CreatePolicyResponse\x12U\n" +
|
||||
"\fListPolicies\x12!.metacrypt.v1.ListPoliciesRequest\x1a\".metacrypt.v1.ListPoliciesResponse\x12L\n" +
|
||||
"\tGetPolicy\x12\x1e.metacrypt.v1.GetPolicyRequest\x1a\x1f.metacrypt.v1.GetPolicyResponse\x12U\n" +
|
||||
"\fDeletePolicy\x12!.metacrypt.v1.DeletePolicyRequest\x1a\".metacrypt.v1.DeletePolicyResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
"\fDeletePolicy\x12!.metacrypt.v1.DeletePolicyRequest\x1a\".metacrypt.v1.DeletePolicyResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v1_policy_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/policy.proto
|
||||
|
||||
package metacryptv1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/system.proto
|
||||
|
||||
package metacryptv1
|
||||
@@ -380,7 +380,7 @@ const file_proto_metacrypt_v1_system_proto_rawDesc = "" +
|
||||
"\x06Status\x12\x1b.metacrypt.v1.StatusRequest\x1a\x1c.metacrypt.v1.StatusResponse\x12=\n" +
|
||||
"\x04Init\x12\x19.metacrypt.v1.InitRequest\x1a\x1a.metacrypt.v1.InitResponse\x12C\n" +
|
||||
"\x06Unseal\x12\x1b.metacrypt.v1.UnsealRequest\x1a\x1c.metacrypt.v1.UnsealResponse\x12=\n" +
|
||||
"\x04Seal\x12\x19.metacrypt.v1.SealRequest\x1a\x1a.metacrypt.v1.SealResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
"\x04Seal\x12\x19.metacrypt.v1.SealRequest\x1a\x1a.metacrypt.v1.SealResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v1;metacryptv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v1_system_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v1/system.proto
|
||||
|
||||
package metacryptv1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/acme.proto
|
||||
|
||||
package metacryptv2
|
||||
@@ -592,7 +592,7 @@ const file_proto_metacrypt_v2_acme_proto_rawDesc = "" +
|
||||
"\tSetConfig\x12\x1e.metacrypt.v2.SetConfigRequest\x1a\x1f.metacrypt.v2.SetConfigResponse\x12U\n" +
|
||||
"\fListAccounts\x12!.metacrypt.v2.ListAccountsRequest\x1a\".metacrypt.v2.ListAccountsResponse\x12O\n" +
|
||||
"\n" +
|
||||
"ListOrders\x12\x1f.metacrypt.v2.ListOrdersRequest\x1a .metacrypt.v2.ListOrdersResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
"ListOrders\x12\x1f.metacrypt.v2.ListOrdersRequest\x1a .metacrypt.v2.ListOrdersResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v2_acme_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/acme.proto
|
||||
|
||||
package metacryptv2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/auth.proto
|
||||
|
||||
package metacryptv2
|
||||
@@ -325,7 +325,7 @@ const file_proto_metacrypt_v2_auth_proto_rawDesc = "" +
|
||||
"\vAuthService\x12@\n" +
|
||||
"\x05Login\x12\x1a.metacrypt.v2.LoginRequest\x1a\x1b.metacrypt.v2.LoginResponse\x12C\n" +
|
||||
"\x06Logout\x12\x1b.metacrypt.v2.LogoutRequest\x1a\x1c.metacrypt.v2.LogoutResponse\x12L\n" +
|
||||
"\tTokenInfo\x12\x1e.metacrypt.v2.TokenInfoRequest\x1a\x1f.metacrypt.v2.TokenInfoResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
"\tTokenInfo\x12\x1e.metacrypt.v2.TokenInfoRequest\x1a\x1f.metacrypt.v2.TokenInfoResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v2_auth_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/auth.proto
|
||||
|
||||
package metacryptv2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/barrier.proto
|
||||
|
||||
package metacryptv2
|
||||
@@ -455,7 +455,7 @@ const file_proto_metacrypt_v2_barrier_proto_rawDesc = "" +
|
||||
"\bListKeys\x12\x1d.metacrypt.v2.ListKeysRequest\x1a\x1e.metacrypt.v2.ListKeysResponse\x12L\n" +
|
||||
"\tRotateMEK\x12\x1e.metacrypt.v2.RotateMEKRequest\x1a\x1f.metacrypt.v2.RotateMEKResponse\x12L\n" +
|
||||
"\tRotateKey\x12\x1e.metacrypt.v2.RotateKeyRequest\x1a\x1f.metacrypt.v2.RotateKeyResponse\x12T\n" +
|
||||
"\aMigrate\x12#.metacrypt.v2.MigrateBarrierRequest\x1a$.metacrypt.v2.MigrateBarrierResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
"\aMigrate\x12#.metacrypt.v2.MigrateBarrierRequest\x1a$.metacrypt.v2.MigrateBarrierResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v2_barrier_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/barrier.proto
|
||||
|
||||
package metacryptv2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/ca.proto
|
||||
|
||||
package metacryptv2
|
||||
@@ -2075,7 +2075,7 @@ const file_proto_metacrypt_v2_ca_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"RevokeCert\x12\x1f.metacrypt.v2.RevokeCertRequest\x1a .metacrypt.v2.RevokeCertResponse\x12O\n" +
|
||||
"\n" +
|
||||
"DeleteCert\x12\x1f.metacrypt.v2.DeleteCertRequest\x1a .metacrypt.v2.DeleteCertResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
"DeleteCert\x12\x1f.metacrypt.v2.DeleteCertRequest\x1a .metacrypt.v2.DeleteCertResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v2_ca_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/ca.proto
|
||||
|
||||
package metacryptv2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/common.proto
|
||||
|
||||
package metacryptv2
|
||||
@@ -24,7 +24,7 @@ var File_proto_metacrypt_v2_common_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_proto_metacrypt_v2_common_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x1fproto/metacrypt/v2/common.proto\x12\fmetacrypt.v2B>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
"\x1fproto/metacrypt/v2/common.proto\x12\fmetacrypt.v2B<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
|
||||
var file_proto_metacrypt_v2_common_proto_goTypes = []any{}
|
||||
var file_proto_metacrypt_v2_common_proto_depIdxs = []int32{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/engine.proto
|
||||
|
||||
package metacryptv2
|
||||
@@ -366,7 +366,7 @@ const file_proto_metacrypt_v2_engine_proto_rawDesc = "" +
|
||||
"\x05Mount\x12\x1a.metacrypt.v2.MountRequest\x1a\x1b.metacrypt.v2.MountResponse\x12F\n" +
|
||||
"\aUnmount\x12\x1c.metacrypt.v2.UnmountRequest\x1a\x1d.metacrypt.v2.UnmountResponse\x12O\n" +
|
||||
"\n" +
|
||||
"ListMounts\x12\x1f.metacrypt.v2.ListMountsRequest\x1a .metacrypt.v2.ListMountsResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
"ListMounts\x12\x1f.metacrypt.v2.ListMountsRequest\x1a .metacrypt.v2.ListMountsResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v2_engine_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/engine.proto
|
||||
|
||||
package metacryptv2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/pki.proto
|
||||
|
||||
package metacryptv2
|
||||
@@ -426,7 +426,7 @@ const file_proto_metacrypt_v2_pki_proto_rawDesc = "" +
|
||||
"\vGetRootCert\x12 .metacrypt.v2.GetRootCertRequest\x1a!.metacrypt.v2.GetRootCertResponse\x12I\n" +
|
||||
"\bGetChain\x12\x1d.metacrypt.v2.GetChainRequest\x1a\x1e.metacrypt.v2.GetChainResponse\x12X\n" +
|
||||
"\rGetIssuerCert\x12\".metacrypt.v2.GetIssuerCertRequest\x1a#.metacrypt.v2.GetIssuerCertResponse\x12C\n" +
|
||||
"\x06GetCRL\x12\x1b.metacrypt.v2.GetCRLRequest\x1a\x1c.metacrypt.v2.GetCRLResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
"\x06GetCRL\x12\x1b.metacrypt.v2.GetCRLRequest\x1a\x1c.metacrypt.v2.GetCRLResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v2_pki_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/pki.proto
|
||||
|
||||
package metacryptv2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/policy.proto
|
||||
|
||||
package metacryptv2
|
||||
@@ -481,7 +481,7 @@ const file_proto_metacrypt_v2_policy_proto_rawDesc = "" +
|
||||
"\fCreatePolicy\x12!.metacrypt.v2.CreatePolicyRequest\x1a\".metacrypt.v2.CreatePolicyResponse\x12U\n" +
|
||||
"\fListPolicies\x12!.metacrypt.v2.ListPoliciesRequest\x1a\".metacrypt.v2.ListPoliciesResponse\x12L\n" +
|
||||
"\tGetPolicy\x12\x1e.metacrypt.v2.GetPolicyRequest\x1a\x1f.metacrypt.v2.GetPolicyResponse\x12U\n" +
|
||||
"\fDeletePolicy\x12!.metacrypt.v2.DeletePolicyRequest\x1a\".metacrypt.v2.DeletePolicyResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
"\fDeletePolicy\x12!.metacrypt.v2.DeletePolicyRequest\x1a\".metacrypt.v2.DeletePolicyResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v2_policy_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/policy.proto
|
||||
|
||||
package metacryptv2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/sshca.proto
|
||||
|
||||
package metacryptv2
|
||||
@@ -1919,7 +1919,7 @@ const file_proto_metacrypt_v2_sshca_proto_rawDesc = "" +
|
||||
"RevokeCert\x12\".metacrypt.v2.SSHRevokeCertRequest\x1a#.metacrypt.v2.SSHRevokeCertResponse\x12U\n" +
|
||||
"\n" +
|
||||
"DeleteCert\x12\".metacrypt.v2.SSHDeleteCertRequest\x1a#.metacrypt.v2.SSHDeleteCertResponse\x12I\n" +
|
||||
"\x06GetKRL\x12\x1e.metacrypt.v2.SSHGetKRLRequest\x1a\x1f.metacrypt.v2.SSHGetKRLResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
"\x06GetKRL\x12\x1e.metacrypt.v2.SSHGetKRLRequest\x1a\x1f.metacrypt.v2.SSHGetKRLResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v2_sshca_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/sshca.proto
|
||||
|
||||
package metacryptv2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/system.proto
|
||||
|
||||
package metacryptv2
|
||||
@@ -380,7 +380,7 @@ const file_proto_metacrypt_v2_system_proto_rawDesc = "" +
|
||||
"\x06Status\x12\x1b.metacrypt.v2.StatusRequest\x1a\x1c.metacrypt.v2.StatusResponse\x12=\n" +
|
||||
"\x04Init\x12\x19.metacrypt.v2.InitRequest\x1a\x1a.metacrypt.v2.InitResponse\x12C\n" +
|
||||
"\x06Unseal\x12\x1b.metacrypt.v2.UnsealRequest\x1a\x1c.metacrypt.v2.UnsealResponse\x12=\n" +
|
||||
"\x04Seal\x12\x19.metacrypt.v2.SealRequest\x1a\x1a.metacrypt.v2.SealResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
"\x04Seal\x12\x19.metacrypt.v2.SealRequest\x1a\x1a.metacrypt.v2.SealResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v2_system_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/system.proto
|
||||
|
||||
package metacryptv2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/transit.proto
|
||||
|
||||
package metacryptv2
|
||||
@@ -2075,7 +2075,7 @@ const file_proto_metacrypt_v2_transit_proto_rawDesc = "" +
|
||||
"\x04Sign\x12 .metacrypt.v2.TransitSignRequest\x1a!.metacrypt.v2.TransitSignResponse\x12Q\n" +
|
||||
"\x06Verify\x12\".metacrypt.v2.TransitVerifyRequest\x1a#.metacrypt.v2.TransitVerifyResponse\x12K\n" +
|
||||
"\x04Hmac\x12 .metacrypt.v2.TransitHmacRequest\x1a!.metacrypt.v2.TransitHmacResponse\x12c\n" +
|
||||
"\fGetPublicKey\x12(.metacrypt.v2.GetTransitPublicKeyRequest\x1a).metacrypt.v2.GetTransitPublicKeyResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
"\fGetPublicKey\x12(.metacrypt.v2.GetTransitPublicKeyRequest\x1a).metacrypt.v2.GetTransitPublicKeyResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v2_transit_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/transit.proto
|
||||
|
||||
package metacryptv2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.20.3
|
||||
// protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/user.proto
|
||||
|
||||
package metacryptv2
|
||||
@@ -1023,7 +1023,7 @@ const file_proto_metacrypt_v2_user_proto_rawDesc = "" +
|
||||
"\tReEncrypt\x12\".metacrypt.v2.UserReEncryptRequest\x1a#.metacrypt.v2.UserReEncryptResponse\x12T\n" +
|
||||
"\tRotateKey\x12\".metacrypt.v2.UserRotateKeyRequest\x1a#.metacrypt.v2.UserRotateKeyResponse\x12W\n" +
|
||||
"\n" +
|
||||
"DeleteUser\x12#.metacrypt.v2.UserDeleteUserRequest\x1a$.metacrypt.v2.UserDeleteUserResponseB>Z<git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
"DeleteUser\x12#.metacrypt.v2.UserDeleteUserRequest\x1a$.metacrypt.v2.UserDeleteUserResponseB<Z:git.wntrmute.dev/mc/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_metacrypt_v2_user_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.20.3
|
||||
// - protoc v6.32.1
|
||||
// source: proto/metacrypt/v2/user.proto
|
||||
|
||||
package metacryptv2
|
||||
|
||||
@@ -4,12 +4,11 @@ go 1.25.7
|
||||
|
||||
require (
|
||||
git.wntrmute.dev/kyle/goutils v1.21.0
|
||||
git.wntrmute.dev/mc/mcdsl v1.2.0
|
||||
git.wntrmute.dev/mc/mcdsl v1.9.0
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/term v0.41.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
@@ -33,6 +32,7 @@ require (
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
git.wntrmute.dev/kyle/goutils v1.21.0 h1:ZR7ovV400hsF09zc8tkdHs6vyen8TDJ7flong/dnFXM=
|
||||
git.wntrmute.dev/kyle/goutils v1.21.0/go.mod h1:JQ8NL5lHSEYl719UMf20p4G1ei70RVGma0hjjNXCR2c=
|
||||
git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U=
|
||||
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM=
|
||||
git.wntrmute.dev/mc/mcdsl v1.9.0 h1:TGqVhf9uhhh5jpMhN+8eNtBPSi/wwNXQn/NFDAcU4wg=
|
||||
git.wntrmute.dev/mc/mcdsl v1.9.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
|
||||
@@ -10,13 +10,14 @@ import (
|
||||
|
||||
// Config is the top-level configuration for Metacrypt.
|
||||
type Config struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
Web WebConfig `toml:"web"`
|
||||
MCIAS MCIASConfig `toml:"mcias"`
|
||||
Server ServerConfig `toml:"server"`
|
||||
Web WebConfig `toml:"web"`
|
||||
MCIAS MCIASConfig `toml:"mcias"`
|
||||
SSO SSOConfig `toml:"sso"`
|
||||
Database mcdslconfig.DatabaseConfig `toml:"database"`
|
||||
Log mcdslconfig.LogConfig `toml:"log"`
|
||||
Seal SealConfig `toml:"seal"`
|
||||
Audit AuditConfig `toml:"audit"`
|
||||
Seal SealConfig `toml:"seal"`
|
||||
Audit AuditConfig `toml:"audit"`
|
||||
}
|
||||
|
||||
// ServerConfig holds HTTP/gRPC server settings. It embeds the standard
|
||||
@@ -33,6 +34,21 @@ type MCIASConfig struct {
|
||||
ServiceToken string `toml:"service_token"`
|
||||
}
|
||||
|
||||
// SSOConfig holds SSO redirect settings for the web UI.
|
||||
type SSOConfig struct {
|
||||
// RedirectURI is the callback URL that MCIAS redirects to after login.
|
||||
// Must exactly match the redirect_uri registered in MCIAS config. For
|
||||
// public (non-Tailnet) browser access this must be the public hostname.
|
||||
RedirectURI string `toml:"redirect_uri"`
|
||||
|
||||
// PublicURL is the browser-facing MCIAS base URL used to build the SSO
|
||||
// authorize redirect (e.g. "https://mcias.metacircular.net"). When empty,
|
||||
// the backend [mcias].server_url is used for the redirect too. Set this
|
||||
// when browsers cannot resolve the internal MCIAS name; the
|
||||
// server-to-server code exchange still uses [mcias].server_url.
|
||||
PublicURL string `toml:"public_url"`
|
||||
}
|
||||
|
||||
// WebConfig holds settings for the standalone web UI server (metacrypt-web).
|
||||
type WebConfig struct {
|
||||
// ListenAddr is the address the web server listens on (default: 127.0.0.1:8080).
|
||||
|
||||
@@ -160,7 +160,6 @@ func publicMethods() map[string]bool {
|
||||
// CA read-only โ public certificates and chains.
|
||||
"/metacrypt.v2.CAService/GetRoot": true,
|
||||
"/metacrypt.v2.CAService/GetIssuer": true,
|
||||
"/metacrypt.v2.CAService/ListIssuers": true,
|
||||
"/metacrypt.v2.CAService/GetChain": true,
|
||||
// SSH CA โ public key and key revocation list.
|
||||
"/metacrypt.v2.SSHCAService/GetCAPublicKey": true,
|
||||
@@ -175,6 +174,7 @@ func authRequiredMethods() map[string]bool {
|
||||
"/metacrypt.v2.AuthService/Logout": true,
|
||||
"/metacrypt.v2.AuthService/TokenInfo": true,
|
||||
"/metacrypt.v2.EngineService/ListMounts": true,
|
||||
"/metacrypt.v2.CAService/ListIssuers": true,
|
||||
"/metacrypt.v2.CAService/IssueCert": true,
|
||||
"/metacrypt.v2.CAService/GetCert": true,
|
||||
"/metacrypt.v2.CAService/ListCerts": true,
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
|
||||
"git.wntrmute.dev/mc/mcdsl/web"
|
||||
)
|
||||
|
||||
@@ -37,7 +38,14 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
|
||||
r.Get("/", ws.handleRoot)
|
||||
r.HandleFunc("/init", ws.handleInit)
|
||||
r.HandleFunc("/unseal", ws.handleUnseal)
|
||||
r.HandleFunc("/login", ws.handleLogin)
|
||||
if ws.ssoClient != nil {
|
||||
r.Get("/login", ws.handleSSOLogin)
|
||||
r.Get("/sso/redirect", ws.handleSSORedirect)
|
||||
r.Get("/sso/callback", ws.handleSSOCallback)
|
||||
} else {
|
||||
r.HandleFunc("/login", ws.handleLogin)
|
||||
}
|
||||
r.Get("/logout", ws.handleLogout)
|
||||
r.Get("/dashboard", ws.requireAuth(ws.handleDashboard))
|
||||
r.Post("/dashboard/mount-ca", ws.requireAuth(ws.handleDashboardMountCA))
|
||||
r.Post("/dashboard/mount-engine", ws.requireAuth(ws.handleDashboardMountEngine))
|
||||
@@ -50,6 +58,8 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
|
||||
|
||||
r.Route("/sshca", func(r chi.Router) {
|
||||
r.Get("/", ws.requireAuth(ws.handleSSHCA))
|
||||
r.Get("/ca", ws.requireAuth(ws.handleSSHCADownload))
|
||||
r.Get("/krl", ws.requireAuth(ws.handleSSHCAKRLDownload))
|
||||
r.Post("/sign-user", ws.requireAuth(ws.handleSSHCASignUser))
|
||||
r.Post("/sign-host", ws.requireAuth(ws.handleSSHCASignHost))
|
||||
r.Get("/cert/{serial}", ws.requireAuth(ws.handleSSHCACertDetail))
|
||||
@@ -91,6 +101,7 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
|
||||
|
||||
r.Route("/pki", func(r chi.Router) {
|
||||
r.Get("/", ws.requireAuth(ws.handlePKI))
|
||||
r.Get("/ca", ws.requireAuth(ws.handlePKIRootCA))
|
||||
r.Post("/import-root", ws.requireAuth(ws.handleImportRoot))
|
||||
r.Post("/create-issuer", ws.requireAuth(ws.handleCreateIssuer))
|
||||
r.Post("/issue", ws.requireAuth(ws.handleIssueCert))
|
||||
@@ -233,6 +244,43 @@ func (ws *WebServer) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleSSOLogin renders a landing page with a "Sign in with MCIAS" button.
|
||||
func (ws *WebServer) handleSSOLogin(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := ws.vault.Status(r.Context())
|
||||
if state != "unsealed" {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
ws.renderTemplate(w, "login.html", map[string]interface{}{"SSO": true})
|
||||
}
|
||||
|
||||
// handleSSORedirect initiates the SSO redirect to MCIAS.
|
||||
func (ws *WebServer) handleSSORedirect(w http.ResponseWriter, r *http.Request) {
|
||||
if err := mcdsso.RedirectToLogin(w, r, ws.ssoClient, "metacrypt"); err != nil {
|
||||
ws.logger.Error("sso: redirect to login", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSSOCallback exchanges the authorization code for a JWT and sets the session.
|
||||
func (ws *WebServer) handleSSOCallback(w http.ResponseWriter, r *http.Request) {
|
||||
token, returnTo, err := mcdsso.HandleCallback(w, r, ws.ssoClient, "metacrypt")
|
||||
if err != nil {
|
||||
ws.logger.Error("sso: callback", "error", err)
|
||||
http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
web.SetSessionCookie(w, "metacrypt_token", token)
|
||||
http.Redirect(w, r, returnTo, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleLogout clears the session and redirects to login.
|
||||
func (ws *WebServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
web.ClearSessionCookie(w, "metacrypt_token")
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
func (ws *WebServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
info := tokenInfoFromContext(r.Context())
|
||||
token := extractCookie(r)
|
||||
@@ -475,6 +523,25 @@ func (ws *WebServer) handleCreateIssuer(w http.ResponseWriter, r *http.Request)
|
||||
http.Redirect(w, r, "/pki", http.StatusFound)
|
||||
}
|
||||
|
||||
func (ws *WebServer) handlePKIRootCA(w http.ResponseWriter, r *http.Request) {
|
||||
token := extractCookie(r)
|
||||
mountName, err := ws.findCAMount(r, token)
|
||||
if err != nil {
|
||||
http.Error(w, "no CA engine mounted", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
certPEM, err := ws.vault.GetRootCert(r.Context(), mountName)
|
||||
if err != nil || len(certPEM) == 0 {
|
||||
http.Error(w, "root CA not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=root-ca.pem")
|
||||
_, _ = w.Write(certPEM) //nolint:gosec
|
||||
}
|
||||
|
||||
func (ws *WebServer) handlePKIIssuer(w http.ResponseWriter, r *http.Request) {
|
||||
token := extractCookie(r)
|
||||
mountName, err := ws.findCAMount(r, token)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
|
||||
"git.wntrmute.dev/mc/mcdsl/csrf"
|
||||
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
|
||||
"git.wntrmute.dev/mc/mcdsl/web"
|
||||
"git.wntrmute.dev/mc/metacrypt/internal/config"
|
||||
webui "git.wntrmute.dev/mc/metacrypt/web"
|
||||
@@ -115,10 +116,11 @@ type cachedUsername struct {
|
||||
type WebServer struct {
|
||||
cfg *config.Config
|
||||
vault vaultBackend
|
||||
logger *slog.Logger
|
||||
logger *slog.Logger
|
||||
httpSrv *http.Server
|
||||
staticFS fs.FS
|
||||
csrf *csrf.Protect
|
||||
ssoClient *mcdsso.Client
|
||||
tgzCache sync.Map // key: UUID string โ *tgzEntry
|
||||
userCache sync.Map // key: UUID string โ *cachedUsername
|
||||
}
|
||||
@@ -169,6 +171,27 @@ func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) {
|
||||
csrf: csrf.New(secret, "metacrypt_csrf", "csrf_token"),
|
||||
}
|
||||
|
||||
// Create SSO client if the service has an SSO redirect_uri configured.
|
||||
if cfg.SSO.RedirectURI != "" {
|
||||
ssoClient, ssoErr := mcdsso.New(mcdsso.Config{
|
||||
MciasURL: cfg.MCIAS.ServerURL,
|
||||
PublicURL: cfg.SSO.PublicURL,
|
||||
ClientID: "metacrypt",
|
||||
RedirectURI: cfg.SSO.RedirectURI,
|
||||
CACert: cfg.MCIAS.CACert,
|
||||
})
|
||||
if ssoErr != nil {
|
||||
return nil, fmt.Errorf("webserver: create SSO client: %w", ssoErr)
|
||||
}
|
||||
ws.ssoClient = ssoClient
|
||||
authorizeURL := cfg.SSO.PublicURL
|
||||
if authorizeURL == "" {
|
||||
authorizeURL = cfg.MCIAS.ServerURL
|
||||
}
|
||||
logger.Info("SSO enabled: redirecting to MCIAS for login",
|
||||
"authorize_url", authorizeURL, "exchange_url", cfg.MCIAS.ServerURL)
|
||||
}
|
||||
|
||||
if tok := cfg.MCIAS.ServiceToken; tok != "" {
|
||||
a, err := mcdslauth.New(mcdslauth.Config{
|
||||
ServerURL: cfg.MCIAS.ServerURL,
|
||||
|
||||
@@ -40,6 +40,44 @@ func (ws *WebServer) handleSSHCA(w http.ResponseWriter, r *http.Request) {
|
||||
ws.renderTemplate(w, "sshca.html", data)
|
||||
}
|
||||
|
||||
func (ws *WebServer) handleSSHCADownload(w http.ResponseWriter, r *http.Request) {
|
||||
token := extractCookie(r)
|
||||
mountName, err := ws.findSSHCAMount(r, token)
|
||||
if err != nil {
|
||||
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
pubkey, err := ws.vault.GetSSHCAPublicKey(r.Context(), mountName)
|
||||
if err != nil || pubkey == nil {
|
||||
http.Error(w, "CA public key not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=ca.pub")
|
||||
_, _ = w.Write([]byte(pubkey.PublicKey)) //nolint:gosec
|
||||
}
|
||||
|
||||
func (ws *WebServer) handleSSHCAKRLDownload(w http.ResponseWriter, r *http.Request) {
|
||||
token := extractCookie(r)
|
||||
mountName, err := ws.findSSHCAMount(r, token)
|
||||
if err != nil {
|
||||
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
krl, err := ws.vault.GetSSHCAKRL(r.Context(), mountName)
|
||||
if err != nil {
|
||||
http.Error(w, "KRL not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=krl.bin")
|
||||
_, _ = w.Write(krl) //nolint:gosec
|
||||
}
|
||||
|
||||
func (ws *WebServer) handleSSHCASignUser(w http.ResponseWriter, r *http.Request) {
|
||||
info := tokenInfoFromContext(r.Context())
|
||||
token := extractCookie(r)
|
||||
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
Copyright 2025 K. Isom <kyle@imap.cc>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
=======================================================================
|
||||
|
||||
The backoff package (written during my time at Cloudflare) is released
|
||||
under the following license:
|
||||
|
||||
Copyright (c) 2016 CloudFlare Inc.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
|
||||
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package certgen
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/lib"
|
||||
)
|
||||
|
||||
type KeySpec struct {
|
||||
Algorithm string `yaml:"algorithm"`
|
||||
Size int `yaml:"size"`
|
||||
}
|
||||
|
||||
func (ks KeySpec) String() string {
|
||||
if strings.ToLower(ks.Algorithm) == nameEd25519 {
|
||||
return nameEd25519
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s-%d", ks.Algorithm, ks.Size)
|
||||
}
|
||||
|
||||
func (ks KeySpec) Generate() (crypto.PublicKey, crypto.PrivateKey, error) {
|
||||
switch strings.ToLower(ks.Algorithm) {
|
||||
case "rsa":
|
||||
return GenerateKey(x509.RSA, ks.Size)
|
||||
case "ecdsa":
|
||||
return GenerateKey(x509.ECDSA, ks.Size)
|
||||
case nameEd25519:
|
||||
return GenerateKey(x509.Ed25519, 0)
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unknown key algorithm: %s", ks.Algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
func (ks KeySpec) SigningAlgorithm() (x509.SignatureAlgorithm, error) {
|
||||
switch strings.ToLower(ks.Algorithm) {
|
||||
case "rsa":
|
||||
return x509.SHA512WithRSAPSS, nil
|
||||
case "ecdsa":
|
||||
return x509.ECDSAWithSHA512, nil
|
||||
case nameEd25519:
|
||||
return x509.PureEd25519, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown key algorithm: %s", ks.Algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
type Subject struct {
|
||||
CommonName string `yaml:"common_name"`
|
||||
Country string `yaml:"country"`
|
||||
Locality string `yaml:"locality"`
|
||||
Province string `yaml:"province"`
|
||||
Organization string `yaml:"organization"`
|
||||
OrganizationalUnit string `yaml:"organizational_unit"`
|
||||
Email []string `yaml:"email"`
|
||||
DNSNames []string `yaml:"dns"`
|
||||
IPAddresses []string `yaml:"ips"`
|
||||
}
|
||||
|
||||
type CertificateRequest struct {
|
||||
KeySpec KeySpec `yaml:"key"`
|
||||
Subject Subject `yaml:"subject"`
|
||||
Profile Profile `yaml:"profile"`
|
||||
}
|
||||
|
||||
func (cs CertificateRequest) Request(priv crypto.PrivateKey) (*x509.CertificateRequest, error) {
|
||||
subject := pkix.Name{}
|
||||
subject.CommonName = cs.Subject.CommonName
|
||||
subject.Country = []string{cs.Subject.Country}
|
||||
subject.Locality = []string{cs.Subject.Locality}
|
||||
subject.Province = []string{cs.Subject.Province}
|
||||
subject.Organization = []string{cs.Subject.Organization}
|
||||
subject.OrganizationalUnit = []string{cs.Subject.OrganizationalUnit}
|
||||
|
||||
ipAddresses := make([]net.IP, 0, len(cs.Subject.IPAddresses))
|
||||
for i, ip := range cs.Subject.IPAddresses {
|
||||
ipAddresses = append(ipAddresses, net.ParseIP(ip))
|
||||
if ipAddresses[i] == nil {
|
||||
return nil, fmt.Errorf("invalid IP address: %s", ip)
|
||||
}
|
||||
}
|
||||
|
||||
dnsNames := cs.Subject.DNSNames
|
||||
if isFQDN(cs.Subject.CommonName) && !slices.Contains(dnsNames, cs.Subject.CommonName) {
|
||||
dnsNames = append(dnsNames, cs.Subject.CommonName)
|
||||
}
|
||||
|
||||
req := &x509.CertificateRequest{
|
||||
PublicKeyAlgorithm: 0,
|
||||
PublicKey: getPublic(priv),
|
||||
Subject: subject,
|
||||
EmailAddresses: cs.Subject.Email,
|
||||
DNSNames: dnsNames,
|
||||
IPAddresses: ipAddresses,
|
||||
}
|
||||
|
||||
reqBytes, err := x509.CreateCertificateRequest(rand.Reader, req, priv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create certificate request: %w", err)
|
||||
}
|
||||
|
||||
req, err = x509.ParseCertificateRequest(reqBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate request: %w", err)
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (cs CertificateRequest) Generate() (crypto.PrivateKey, *x509.CertificateRequest, error) {
|
||||
_, priv, err := cs.KeySpec.Generate()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
req, err := cs.Request(priv)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return priv, req, nil
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
IsCA bool `yaml:"is_ca"`
|
||||
PathLen int `yaml:"path_len"`
|
||||
KeyUse []string `yaml:"key_uses"`
|
||||
ExtKeyUsages []string `yaml:"ext_key_usages"`
|
||||
Expiry string `yaml:"expiry"`
|
||||
OCSPServer []string `yaml:"ocsp_server,omitempty"`
|
||||
IssuingCertificateURL []string `yaml:"issuing_certificate_url,omitempty"`
|
||||
}
|
||||
|
||||
func (p Profile) templateFromRequest(req *x509.CertificateRequest) (*x509.Certificate, error) {
|
||||
serial, err := SerialNumber()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate serial number: %w", err)
|
||||
}
|
||||
|
||||
expiry, err := lib.ParseDuration(p.Expiry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing expiry: %w", err)
|
||||
}
|
||||
|
||||
certTemplate := &x509.Certificate{
|
||||
SignatureAlgorithm: req.SignatureAlgorithm,
|
||||
PublicKeyAlgorithm: req.PublicKeyAlgorithm,
|
||||
PublicKey: req.PublicKey,
|
||||
SerialNumber: serial,
|
||||
Subject: req.Subject,
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(expiry),
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: p.IsCA,
|
||||
MaxPathLen: p.PathLen,
|
||||
DNSNames: req.DNSNames,
|
||||
IPAddresses: req.IPAddresses,
|
||||
}
|
||||
|
||||
for _, sku := range p.KeyUse {
|
||||
ku, ok := keyUsageStrings[sku]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid key usage: %s", p.KeyUse)
|
||||
}
|
||||
|
||||
certTemplate.KeyUsage |= ku
|
||||
}
|
||||
|
||||
for _, extKeyUsage := range p.ExtKeyUsages {
|
||||
eku, ok := extKeyUsageStrings[extKeyUsage]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid extended key usage: %s", extKeyUsage)
|
||||
}
|
||||
certTemplate.ExtKeyUsage = append(certTemplate.ExtKeyUsage, eku)
|
||||
}
|
||||
|
||||
if len(p.OCSPServer) > 0 {
|
||||
certTemplate.OCSPServer = p.OCSPServer
|
||||
}
|
||||
if len(p.IssuingCertificateURL) > 0 {
|
||||
certTemplate.IssuingCertificateURL = p.IssuingCertificateURL
|
||||
}
|
||||
|
||||
return certTemplate, nil
|
||||
}
|
||||
|
||||
func (p Profile) SignRequest(
|
||||
parent *x509.Certificate,
|
||||
req *x509.CertificateRequest,
|
||||
priv crypto.PrivateKey,
|
||||
) (*x509.Certificate, error) {
|
||||
tpl, err := p.templateFromRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create certificate template: %w", err)
|
||||
}
|
||||
|
||||
certBytes, err := x509.CreateCertificate(rand.Reader, tpl, parent, req.PublicKey, priv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create certificate: %w", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
func (p Profile) SelfSign(req *x509.CertificateRequest, priv crypto.PrivateKey) (*x509.Certificate, error) {
|
||||
certTemplate, err := p.templateFromRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create certificate template: %w", err)
|
||||
}
|
||||
|
||||
return p.SignRequest(certTemplate, req, priv)
|
||||
}
|
||||
|
||||
// isFQDN returns true if s looks like a fully-qualified domain name.
|
||||
func isFQDN(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// Must contain at least one dot and no spaces.
|
||||
if !strings.Contains(s, ".") || strings.ContainsAny(s, " \t") {
|
||||
return false
|
||||
}
|
||||
// Each label must be non-empty and consist of letters, digits, or hyphens.
|
||||
for label := range strings.SplitSeq(strings.TrimSuffix(s, "."), ".") {
|
||||
if label == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range label {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func SerialNumber() (*big.Int, error) {
|
||||
serialNumberBytes := make([]byte, 20)
|
||||
_, err := rand.Read(serialNumberBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate serial number: %w", err)
|
||||
}
|
||||
return new(big.Int).SetBytes(serialNumberBytes), nil
|
||||
}
|
||||
|
||||
// GenerateSelfSigned generates a self-signed certificate using the given certificate request.
|
||||
func GenerateSelfSigned(creq *CertificateRequest) (*x509.Certificate, crypto.PrivateKey, error) {
|
||||
priv, req, err := creq.Generate()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate certificate request: %w", err)
|
||||
}
|
||||
|
||||
cert, err := creq.Profile.SelfSign(req, priv)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to self-sign certificate: %w", err)
|
||||
}
|
||||
|
||||
return cert, priv, nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package certgen
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// var (
|
||||
// oidEd25519 = asn1.ObjectIdentifier{1, 3, 101, 110}
|
||||
//)
|
||||
|
||||
const (
|
||||
nameEd25519 = "ed25519"
|
||||
)
|
||||
|
||||
func GenerateKey(algorithm x509.PublicKeyAlgorithm, bitSize int) (crypto.PublicKey, crypto.PrivateKey, error) {
|
||||
var key crypto.PrivateKey
|
||||
var pub crypto.PublicKey
|
||||
var err error
|
||||
|
||||
switch algorithm {
|
||||
case x509.Ed25519:
|
||||
pub, key, err = ed25519.GenerateKey(rand.Reader)
|
||||
case x509.RSA:
|
||||
key, err = rsa.GenerateKey(rand.Reader, bitSize)
|
||||
if err == nil {
|
||||
rsaPriv, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
panic("failed to cast RSA private key to *rsa.PrivateKey")
|
||||
}
|
||||
|
||||
pub = rsaPriv.Public()
|
||||
}
|
||||
case x509.ECDSA:
|
||||
var curve elliptic.Curve
|
||||
|
||||
switch bitSize {
|
||||
case 256:
|
||||
curve = elliptic.P256()
|
||||
case 384:
|
||||
curve = elliptic.P384()
|
||||
case 521:
|
||||
curve = elliptic.P521()
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported curve size %d", bitSize)
|
||||
}
|
||||
|
||||
key, err = ecdsa.GenerateKey(curve, rand.Reader)
|
||||
if err == nil {
|
||||
ecPriv, ok := key.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
panic("failed to cast ECDSA private key to *ecdsa.PrivateKey")
|
||||
}
|
||||
|
||||
pub = ecPriv.Public()
|
||||
}
|
||||
case x509.DSA:
|
||||
fallthrough
|
||||
case x509.UnknownPublicKeyAlgorithm:
|
||||
fallthrough
|
||||
default:
|
||||
err = errors.New("unsupported algorithm")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return pub, key, nil
|
||||
}
|
||||
|
||||
func getPublic(priv crypto.PrivateKey) crypto.PublicKey {
|
||||
switch priv := priv.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
return &priv.PublicKey
|
||||
case *ecdsa.PrivateKey:
|
||||
return &priv.PublicKey
|
||||
case *ed25519.PrivateKey:
|
||||
return priv.Public()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package certgen
|
||||
|
||||
import "crypto/x509"
|
||||
|
||||
var keyUsageStrings = map[string]x509.KeyUsage{
|
||||
"signing": x509.KeyUsageDigitalSignature,
|
||||
"digital signature": x509.KeyUsageDigitalSignature,
|
||||
"content commitment": x509.KeyUsageContentCommitment,
|
||||
"key encipherment": x509.KeyUsageKeyEncipherment,
|
||||
"key agreement": x509.KeyUsageKeyAgreement,
|
||||
"data encipherment": x509.KeyUsageDataEncipherment,
|
||||
"cert sign": x509.KeyUsageCertSign,
|
||||
"crl sign": x509.KeyUsageCRLSign,
|
||||
"encipher only": x509.KeyUsageEncipherOnly,
|
||||
"decipher only": x509.KeyUsageDecipherOnly,
|
||||
}
|
||||
|
||||
var extKeyUsageStrings = map[string]x509.ExtKeyUsage{
|
||||
"any": x509.ExtKeyUsageAny,
|
||||
"server auth": x509.ExtKeyUsageServerAuth,
|
||||
"client auth": x509.ExtKeyUsageClientAuth,
|
||||
"code signing": x509.ExtKeyUsageCodeSigning,
|
||||
"email protection": x509.ExtKeyUsageEmailProtection,
|
||||
"s/mime": x509.ExtKeyUsageEmailProtection,
|
||||
"ipsec end system": x509.ExtKeyUsageIPSECEndSystem,
|
||||
"ipsec tunnel": x509.ExtKeyUsageIPSECTunnel,
|
||||
"ipsec user": x509.ExtKeyUsageIPSECUser,
|
||||
"timestamping": x509.ExtKeyUsageTimeStamping,
|
||||
"ocsp signing": x509.ExtKeyUsageOCSPSigning,
|
||||
"microsoft sgc": x509.ExtKeyUsageMicrosoftServerGatedCrypto,
|
||||
"netscape sgc": x509.ExtKeyUsageNetscapeServerGatedCrypto,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package lib
|
||||
|
||||
// Various constants used throughout the tools.
|
||||
|
||||
const (
|
||||
// ExitSuccess is the successful exit status.
|
||||
//
|
||||
// It should be called on successful exit.
|
||||
ExitSuccess = 0
|
||||
|
||||
// ExitFailure is the failing exit status.
|
||||
ExitFailure = 1
|
||||
)
|
||||
|
||||
const (
|
||||
OneTrueDateFormat = "2006-01-02T15:04:05-0700"
|
||||
DateShortFormat = "2006-01-02"
|
||||
TimeShortFormat = "15:04:05"
|
||||
TimeShorterFormat = "15:04"
|
||||
TimeStandardDateTime = "2006-01-02 15:04"
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build bsd
|
||||
|
||||
package lib
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// FileTime contains the changed, modified, and accessed timestamps
|
||||
// for a file.
|
||||
type FileTime struct {
|
||||
Changed time.Time
|
||||
Modified time.Time
|
||||
Accessed time.Time
|
||||
}
|
||||
|
||||
func timeSpecToTime(ts unix.Timespec) time.Time {
|
||||
return time.Unix(ts.Sec, ts.Nsec)
|
||||
}
|
||||
|
||||
// LoadFileTime returns a FileTime associated with the file.
|
||||
func LoadFileTime(path string) (FileTime, error) {
|
||||
var ft = FileTime{}
|
||||
var st = unix.Stat_t{}
|
||||
|
||||
err := unix.Stat(path, &st)
|
||||
if err != nil {
|
||||
return ft, err
|
||||
}
|
||||
|
||||
ft.Changed = timeSpecToTime(st.Ctimespec)
|
||||
ft.Modified = timeSpecToTime(st.Mtimespec)
|
||||
ft.Accessed = timeSpecToTime(st.Atimespec)
|
||||
return ft, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build unix || linux || openbsd || (darwin && amd64)
|
||||
|
||||
package lib
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// FileTime contains the changed, modified, and accessed timestamps
|
||||
// for a file.
|
||||
type FileTime struct {
|
||||
Changed time.Time
|
||||
Modified time.Time
|
||||
Accessed time.Time
|
||||
}
|
||||
|
||||
func timeSpecToTime(ts unix.Timespec) time.Time {
|
||||
// The casts to int64 are needed because on 386, these are int32s.
|
||||
return time.Unix(ts.Sec, ts.Nsec)
|
||||
}
|
||||
|
||||
// LoadFileTime returns a FileTime associated with the file.
|
||||
func LoadFileTime(path string) (FileTime, error) {
|
||||
var ft = FileTime{}
|
||||
var st = unix.Stat_t{}
|
||||
|
||||
err := unix.Stat(path, &st)
|
||||
if err != nil {
|
||||
return ft, err
|
||||
}
|
||||
|
||||
ft.Changed = timeSpecToTime(st.Ctim)
|
||||
ft.Modified = timeSpecToTime(st.Mtim)
|
||||
ft.Accessed = timeSpecToTime(st.Atim)
|
||||
return ft, nil
|
||||
}
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var progname = filepath.Base(os.Args[0])
|
||||
|
||||
const (
|
||||
daysInYear = 365
|
||||
digitWidth = 10
|
||||
hoursInQuarterDay = 6
|
||||
)
|
||||
|
||||
// ProgName returns what lib thinks the program name is, namely the
|
||||
// basename of argv0.
|
||||
//
|
||||
// It is similar to the Linux __progname function.
|
||||
func ProgName() string {
|
||||
return progname
|
||||
}
|
||||
|
||||
// Warnx displays a formatted error message to standard error, ร la
|
||||
// warnx(3).
|
||||
func Warnx(format string, a ...any) (int, error) {
|
||||
format = fmt.Sprintf("[%s] %s", progname, format)
|
||||
format += "\n"
|
||||
return fmt.Fprintf(os.Stderr, format, a...)
|
||||
}
|
||||
|
||||
// Warn displays a formatted error message to standard output,
|
||||
// appending the error string, ร la warn(3).
|
||||
func Warn(err error, format string, a ...any) (int, error) {
|
||||
format = fmt.Sprintf("[%s] %s", progname, format)
|
||||
format += ": %v\n"
|
||||
a = append(a, err)
|
||||
return fmt.Fprintf(os.Stderr, format, a...)
|
||||
}
|
||||
|
||||
// Errx displays a formatted error message to standard error and exits
|
||||
// with the status code from `exit`, ร la errx(3).
|
||||
func Errx(exit int, format string, a ...any) {
|
||||
format = fmt.Sprintf("[%s] %s", progname, format)
|
||||
format += "\n"
|
||||
fmt.Fprintf(os.Stderr, format, a...)
|
||||
os.Exit(exit)
|
||||
}
|
||||
|
||||
// Err displays a formatting error message to standard error,
|
||||
// appending the error string, and exits with the status code from
|
||||
// `exit`, ร la err(3).
|
||||
func Err(exit int, err error, format string, a ...any) {
|
||||
format = fmt.Sprintf("[%s] %s", progname, format)
|
||||
format += ": %v\n"
|
||||
a = append(a, err)
|
||||
fmt.Fprintf(os.Stderr, format, a...)
|
||||
os.Exit(exit)
|
||||
}
|
||||
|
||||
// Itoa provides cheap integer to fixed-width decimal ASCII. Give a
|
||||
// negative width to avoid zero-padding. Adapted from the 'itoa'
|
||||
// function in the log/log.go file in the standard library.
|
||||
func Itoa(i int, wid int) string {
|
||||
// Assemble decimal in reverse order.
|
||||
var b [20]byte
|
||||
bp := len(b) - 1
|
||||
for i >= digitWidth || wid > 1 {
|
||||
wid--
|
||||
q := i / digitWidth
|
||||
b[bp] = byte('0' + i - q*digitWidth)
|
||||
bp--
|
||||
i = q
|
||||
}
|
||||
|
||||
b[bp] = byte('0' + i)
|
||||
return string(b[bp:])
|
||||
}
|
||||
|
||||
var (
|
||||
dayDuration = 24 * time.Hour
|
||||
yearDuration = (daysInYear * dayDuration) + (hoursInQuarterDay * time.Hour)
|
||||
)
|
||||
|
||||
// Duration returns a prettier string for time.Durations.
|
||||
func Duration(d time.Duration) string {
|
||||
var s string
|
||||
if d >= yearDuration {
|
||||
years := int64(d / yearDuration)
|
||||
s += fmt.Sprintf("%dy", years)
|
||||
d -= time.Duration(years) * yearDuration
|
||||
}
|
||||
|
||||
if d >= dayDuration {
|
||||
days := d / dayDuration
|
||||
s += fmt.Sprintf("%dd", days)
|
||||
}
|
||||
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
|
||||
d %= 1 * time.Second
|
||||
hours := int64(d / time.Hour)
|
||||
d -= time.Duration(hours) * time.Hour
|
||||
s += fmt.Sprintf("%dh%s", hours, d)
|
||||
return s
|
||||
}
|
||||
|
||||
// IsDigit checks if a byte is a decimal digit.
|
||||
func IsDigit(b byte) bool {
|
||||
return b >= '0' && b <= '9'
|
||||
}
|
||||
|
||||
const signedaMask64 = 1<<63 - 1
|
||||
|
||||
// ParseDuration parses a duration string into a time.Duration.
|
||||
// It supports standard units (ns, us/ยตs, ms, s, m, h) plus extended units:
|
||||
// d (days, 24h), w (weeks, 7d), y (years, 365d).
|
||||
// Units can be combined without spaces, e.g., "1y2w3d4h5m6s".
|
||||
// Case-insensitive. Years and days are approximations (no leap seconds/months).
|
||||
// Returns an error for invalid input.
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
s = strings.ToLower(s) // Normalize to lowercase for case-insensitivity.
|
||||
if s == "" {
|
||||
return 0, errors.New("empty duration string")
|
||||
}
|
||||
|
||||
var total time.Duration
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
// Parse the number part.
|
||||
start := i
|
||||
for i < len(s) && IsDigit(s[i]) {
|
||||
i++
|
||||
}
|
||||
if start == i {
|
||||
return 0, fmt.Errorf("expected number at position %d", start)
|
||||
}
|
||||
numStr := s[start:i]
|
||||
num, err := strconv.ParseUint(numStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid number %q: %w", numStr, err)
|
||||
}
|
||||
|
||||
// Parse the unit part.
|
||||
if i >= len(s) {
|
||||
return 0, fmt.Errorf("expected unit after number %q", numStr)
|
||||
}
|
||||
unitStart := i
|
||||
i++ // Consume the first char of the unit.
|
||||
unit := s[unitStart:i]
|
||||
|
||||
// Handle potential two-char units like "ms".
|
||||
if unit == "m" && i < len(s) && s[i] == 's' {
|
||||
i++ // Consume the 's'.
|
||||
unit = "ms"
|
||||
}
|
||||
|
||||
// Convert to duration based on unit.
|
||||
var d time.Duration
|
||||
switch unit {
|
||||
case "ns":
|
||||
d = time.Nanosecond * time.Duration(num&signedaMask64) // #nosec G115 - masked off
|
||||
case "us", "ยตs":
|
||||
d = time.Microsecond * time.Duration(num&signedaMask64) // #nosec G115 - masked off
|
||||
case "ms":
|
||||
d = time.Millisecond * time.Duration(num&signedaMask64) // #nosec G115 - masked off
|
||||
case "s":
|
||||
d = time.Second * time.Duration(num&signedaMask64) // #nosec G115 - masked off
|
||||
case "m":
|
||||
d = time.Minute * time.Duration(num&signedaMask64) // #nosec G115 - masked off
|
||||
case "h":
|
||||
d = time.Hour * time.Duration(num&signedaMask64) // #nosec G115 - masked off
|
||||
case "d":
|
||||
d = 24 * time.Hour * time.Duration(num&signedaMask64) // #nosec G115 - masked off
|
||||
case "w":
|
||||
d = 7 * 24 * time.Hour * time.Duration(num&signedaMask64) // #nosec G115 - masked off
|
||||
case "y":
|
||||
// Approximate, non-leap year.
|
||||
d = 365 * 24 * time.Hour * time.Duration(num&signedaMask64) // #nosec G115 - masked off;
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown unit %q at position %d", s[unitStart:i], unitStart)
|
||||
}
|
||||
|
||||
total += d
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
type HexEncodeMode uint8
|
||||
|
||||
const (
|
||||
// HexEncodeLower prints the bytes as lowercase hexadecimal.
|
||||
HexEncodeLower HexEncodeMode = iota + 1
|
||||
// HexEncodeUpper prints the bytes as uppercase hexadecimal.
|
||||
HexEncodeUpper
|
||||
// HexEncodeLowerColon prints the bytes as lowercase hexadecimal
|
||||
// with colons between each pair of bytes.
|
||||
HexEncodeLowerColon
|
||||
// HexEncodeUpperColon prints the bytes as uppercase hexadecimal
|
||||
// with colons between each pair of bytes.
|
||||
HexEncodeUpperColon
|
||||
// HexEncodeBytes prints the string as a sequence of []byte.
|
||||
HexEncodeBytes
|
||||
// HexEncodeBase64 prints the string as a base64-encoded string.
|
||||
HexEncodeBase64
|
||||
)
|
||||
|
||||
func (m HexEncodeMode) String() string {
|
||||
switch m {
|
||||
case HexEncodeLower:
|
||||
return "lower"
|
||||
case HexEncodeUpper:
|
||||
return "upper"
|
||||
case HexEncodeLowerColon:
|
||||
return "lcolon"
|
||||
case HexEncodeUpperColon:
|
||||
return "ucolon"
|
||||
case HexEncodeBytes:
|
||||
return "bytes"
|
||||
case HexEncodeBase64:
|
||||
return "base64"
|
||||
default:
|
||||
panic("invalid hex encode mode")
|
||||
}
|
||||
}
|
||||
|
||||
func ParseHexEncodeMode(s string) HexEncodeMode {
|
||||
switch strings.ToLower(s) {
|
||||
case "lower":
|
||||
return HexEncodeLower
|
||||
case "upper":
|
||||
return HexEncodeUpper
|
||||
case "lcolon":
|
||||
return HexEncodeLowerColon
|
||||
case "ucolon":
|
||||
return HexEncodeUpperColon
|
||||
case "bytes":
|
||||
return HexEncodeBytes
|
||||
case "base64":
|
||||
return HexEncodeBase64
|
||||
}
|
||||
|
||||
panic("invalid hex encode mode")
|
||||
}
|
||||
|
||||
func hexColons(s string) string {
|
||||
if len(s)%2 != 0 {
|
||||
fmt.Fprintf(os.Stderr, "hex string: %s\n", s)
|
||||
fmt.Fprintf(os.Stderr, "hex length: %d\n", len(s))
|
||||
panic("invalid hex string length")
|
||||
}
|
||||
|
||||
n := len(s)
|
||||
if n <= 2 {
|
||||
return s
|
||||
}
|
||||
|
||||
pairCount := n / 2
|
||||
if n%2 != 0 {
|
||||
pairCount++
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(n + pairCount - 1)
|
||||
|
||||
for i := 0; i < n; i += 2 {
|
||||
b.WriteByte(s[i])
|
||||
|
||||
if i+1 < n {
|
||||
b.WriteByte(s[i+1])
|
||||
}
|
||||
|
||||
if i+2 < n {
|
||||
b.WriteByte(':')
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func hexEncode(b []byte) string {
|
||||
s := hex.EncodeToString(b)
|
||||
|
||||
if len(s)%2 != 0 {
|
||||
s = "0" + s
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func bytesAsByteSliceString(buf []byte) string {
|
||||
sb := &strings.Builder{}
|
||||
sb.WriteString("[]byte{")
|
||||
for i := range buf {
|
||||
fmt.Fprintf(sb, "0x%02x, ", buf[i])
|
||||
}
|
||||
sb.WriteString("}")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// HexEncode encodes the given bytes as a hexadecimal string. It
|
||||
// also supports a few other binary-encoding formats as well.
|
||||
func HexEncode(b []byte, mode HexEncodeMode) string {
|
||||
switch mode {
|
||||
case HexEncodeLower:
|
||||
return hexEncode(b)
|
||||
case HexEncodeUpper:
|
||||
return strings.ToUpper(hexEncode(b))
|
||||
case HexEncodeLowerColon:
|
||||
return hexColons(hexEncode(b))
|
||||
case HexEncodeUpperColon:
|
||||
return strings.ToUpper(hexColons(hexEncode(b)))
|
||||
case HexEncodeBytes:
|
||||
return bytesAsByteSliceString(b)
|
||||
case HexEncodeBase64:
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
default:
|
||||
panic("invalid hex encode mode")
|
||||
}
|
||||
}
|
||||
|
||||
// DummyWriteCloser wraps an io.Writer in a struct with a no-op Close.
|
||||
type DummyWriteCloser struct {
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
func WithCloser(w io.Writer) io.WriteCloser {
|
||||
return &DummyWriteCloser{w: w}
|
||||
}
|
||||
|
||||
func (dwc *DummyWriteCloser) Write(p []byte) (int, error) {
|
||||
return dwc.w.Write(p)
|
||||
}
|
||||
|
||||
func (dwc *DummyWriteCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
// Package auth provides MCIAS token validation with caching for
|
||||
// Metacircular services.
|
||||
//
|
||||
// Every Metacircular service delegates authentication to MCIAS. This
|
||||
// package handles the login flow, token validation (with a 30-second
|
||||
// SHA-256-keyed cache), and logout. It communicates directly with the
|
||||
// MCIAS REST API.
|
||||
//
|
||||
// Security: bearer tokens are never logged or included in error messages.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const cacheTTL = 30 * time.Second
|
||||
|
||||
// Errors returned by the Authenticator.
|
||||
var (
|
||||
// ErrInvalidToken indicates the token is expired, revoked, or otherwise
|
||||
// invalid.
|
||||
ErrInvalidToken = errors.New("auth: invalid token")
|
||||
|
||||
// ErrInvalidCredentials indicates that the username/password combination
|
||||
// was rejected by MCIAS.
|
||||
ErrInvalidCredentials = errors.New("auth: invalid credentials")
|
||||
|
||||
// ErrForbidden indicates that MCIAS login policy denied access to this
|
||||
// service (HTTP 403).
|
||||
ErrForbidden = errors.New("auth: forbidden by policy")
|
||||
|
||||
// ErrUnavailable indicates that MCIAS could not be reached.
|
||||
ErrUnavailable = errors.New("auth: MCIAS unavailable")
|
||||
)
|
||||
|
||||
// Config holds MCIAS connection settings. This matches the standard [mcias]
|
||||
// TOML section used by all Metacircular services.
|
||||
type Config struct {
|
||||
// ServerURL is the base URL of the MCIAS server
|
||||
// (e.g., "https://mcias.metacircular.net:8443").
|
||||
ServerURL string `toml:"server_url"`
|
||||
|
||||
// CACert is an optional path to a PEM-encoded CA certificate for
|
||||
// verifying the MCIAS server's TLS certificate.
|
||||
CACert string `toml:"ca_cert"`
|
||||
|
||||
// ServiceName is this service's identity as registered in MCIAS. It is
|
||||
// sent with every login request so MCIAS can evaluate service-context
|
||||
// login policy rules.
|
||||
ServiceName string `toml:"service_name"`
|
||||
|
||||
// Tags are sent with every login request. MCIAS evaluates auth:login
|
||||
// policy against these tags (e.g., ["env:restricted"]).
|
||||
Tags []string `toml:"tags"`
|
||||
}
|
||||
|
||||
// TokenInfo holds the validated identity of an authenticated caller.
|
||||
type TokenInfo struct {
|
||||
// Username is the MCIAS username (the "sub" claim).
|
||||
Username string
|
||||
|
||||
// AccountType is the MCIAS account type: "human" or "system".
|
||||
// Used by policy engines that need to distinguish interactive users
|
||||
// from service accounts.
|
||||
AccountType string
|
||||
|
||||
// Roles is the set of MCIAS roles assigned to the account.
|
||||
Roles []string
|
||||
|
||||
// IsAdmin is true if the account has the "admin" role.
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// Authenticator validates MCIAS bearer tokens with a short-lived cache.
|
||||
type Authenticator struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
serviceName string
|
||||
tags []string
|
||||
logger *slog.Logger
|
||||
cache *validationCache
|
||||
}
|
||||
|
||||
// New creates an Authenticator that talks to the MCIAS server described
|
||||
// by cfg. TLS 1.3 is required for all HTTPS connections. If cfg.CACert
|
||||
// is set, that CA certificate is added to the trust pool.
|
||||
//
|
||||
// For plain HTTP URLs (used in tests), TLS configuration is skipped.
|
||||
func New(cfg Config, logger *slog.Logger) (*Authenticator, error) {
|
||||
if cfg.ServerURL == "" {
|
||||
return nil, fmt.Errorf("auth: server_url is required")
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
|
||||
if !strings.HasPrefix(cfg.ServerURL, "http://") {
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
|
||||
if cfg.CACert != "" {
|
||||
pem, err := os.ReadFile(cfg.CACert) //nolint:gosec // CA cert path from operator config
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: read CA cert %s: %w", cfg.CACert, err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("auth: no valid certificates in %s", cfg.CACert)
|
||||
}
|
||||
tlsCfg.RootCAs = pool
|
||||
}
|
||||
|
||||
transport.TLSClientConfig = tlsCfg
|
||||
}
|
||||
|
||||
return &Authenticator{
|
||||
httpClient: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
baseURL: strings.TrimRight(cfg.ServerURL, "/"),
|
||||
serviceName: cfg.ServiceName,
|
||||
tags: cfg.Tags,
|
||||
logger: logger,
|
||||
cache: newCache(cacheTTL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Login authenticates a user against MCIAS and returns a bearer token.
|
||||
// totpCode may be empty for accounts without TOTP configured.
|
||||
//
|
||||
// The service name and tags from Config are included in the login request
|
||||
// so MCIAS can evaluate service-context login policy.
|
||||
func (a *Authenticator) Login(username, password, totpCode string) (token string, expiresAt time.Time, err error) {
|
||||
reqBody := map[string]interface{}{
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
if totpCode != "" {
|
||||
reqBody["totp_code"] = totpCode
|
||||
}
|
||||
if a.serviceName != "" {
|
||||
reqBody["service_name"] = a.serviceName
|
||||
}
|
||||
if len(a.tags) > 0 {
|
||||
reqBody["tags"] = a.tags
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
status, err := a.doJSON(http.MethodPost, "/v1/auth/login", reqBody, &resp)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("auth: MCIAS login: %w", ErrUnavailable)
|
||||
}
|
||||
|
||||
switch status {
|
||||
case http.StatusOK:
|
||||
// Parse the expiry time.
|
||||
exp, parseErr := time.Parse(time.RFC3339, resp.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
exp = time.Now().Add(1 * time.Hour) // fallback
|
||||
}
|
||||
return resp.Token, exp, nil
|
||||
case http.StatusForbidden:
|
||||
return "", time.Time{}, ErrForbidden
|
||||
default:
|
||||
return "", time.Time{}, ErrInvalidCredentials
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateToken checks a bearer token against MCIAS. Results are cached
|
||||
// by the SHA-256 hash of the token for 30 seconds.
|
||||
//
|
||||
// Returns ErrInvalidToken if the token is expired, revoked, or otherwise
|
||||
// not valid.
|
||||
func (a *Authenticator) ValidateToken(token string) (*TokenInfo, error) {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
tokenHash := hex.EncodeToString(h[:])
|
||||
|
||||
if info, ok := a.cache.get(tokenHash); ok {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Valid bool `json:"valid"`
|
||||
Sub string `json:"sub"`
|
||||
Username string `json:"username"`
|
||||
AccountType string `json:"account_type"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
status, err := a.doJSON(http.MethodPost, "/v1/token/validate",
|
||||
map[string]string{"token": token}, &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: MCIAS validate: %w", ErrUnavailable)
|
||||
}
|
||||
|
||||
if status != http.StatusOK || !resp.Valid {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
info := &TokenInfo{
|
||||
Username: resp.Username,
|
||||
AccountType: resp.AccountType,
|
||||
Roles: resp.Roles,
|
||||
IsAdmin: hasRole(resp.Roles, "admin"),
|
||||
}
|
||||
if info.Username == "" {
|
||||
info.Username = resp.Sub
|
||||
}
|
||||
|
||||
a.cache.put(tokenHash, info)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ClearCache removes all cached token validation results. This should be
|
||||
// called when the service transitions to a state where cached tokens may
|
||||
// no longer be valid (e.g., Metacrypt sealing).
|
||||
func (a *Authenticator) ClearCache() {
|
||||
a.cache.clear()
|
||||
}
|
||||
|
||||
// Logout revokes a token on the MCIAS server.
|
||||
func (a *Authenticator) Logout(token string) error {
|
||||
req, err := http.NewRequestWithContext(context.Background(),
|
||||
http.MethodPost, a.baseURL+"/v1/auth/logout", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: build logout request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: MCIAS logout: %w", ErrUnavailable)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// doJSON makes a JSON request to the MCIAS server and decodes the response.
|
||||
// It returns the HTTP status code and any transport error.
|
||||
func (a *Authenticator) doJSON(method, path string, body, out interface{}) (int, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(),
|
||||
method, a.baseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if out != nil && resp.StatusCode == http.StatusOK {
|
||||
respBytes, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return resp.StatusCode, fmt.Errorf("read response: %w", readErr)
|
||||
}
|
||||
if len(respBytes) > 0 {
|
||||
if decErr := json.Unmarshal(respBytes, out); decErr != nil {
|
||||
return resp.StatusCode, fmt.Errorf("decode response: %w", decErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func hasRole(roles []string, target string) bool {
|
||||
for _, r := range roles {
|
||||
if r == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cacheEntry holds a cached TokenInfo and its expiration time.
|
||||
type cacheEntry struct {
|
||||
info *TokenInfo
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// validationCache provides a concurrency-safe, TTL-based cache for token
|
||||
// validation results. Tokens are keyed by their SHA-256 hex digest.
|
||||
type validationCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]cacheEntry
|
||||
ttl time.Duration
|
||||
now func() time.Time // injectable clock for testing
|
||||
}
|
||||
|
||||
// newCache creates a validationCache with the given TTL.
|
||||
func newCache(ttl time.Duration) *validationCache {
|
||||
return &validationCache{
|
||||
entries: make(map[string]cacheEntry),
|
||||
ttl: ttl,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// get returns cached TokenInfo for the given token hash, or false if
|
||||
// the entry is missing or expired. Expired entries are lazily evicted.
|
||||
func (c *validationCache) get(tokenHash string) (*TokenInfo, bool) {
|
||||
c.mu.RLock()
|
||||
entry, ok := c.entries[tokenHash]
|
||||
c.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if c.now().After(entry.expiresAt) {
|
||||
// Lazy evict the expired entry.
|
||||
c.mu.Lock()
|
||||
if e, exists := c.entries[tokenHash]; exists && c.now().After(e.expiresAt) {
|
||||
delete(c.entries, tokenHash)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entry.info, true
|
||||
}
|
||||
|
||||
// clear removes all entries from the cache.
|
||||
func (c *validationCache) clear() {
|
||||
c.mu.Lock()
|
||||
c.entries = make(map[string]cacheEntry)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// put stores TokenInfo in the cache with an expiration of now + TTL.
|
||||
func (c *validationCache) put(tokenHash string, info *TokenInfo) {
|
||||
c.mu.Lock()
|
||||
c.entries[tokenHash] = cacheEntry{
|
||||
info: info,
|
||||
expiresAt: c.now().Add(c.ttl),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package auth
|
||||
|
||||
import "context"
|
||||
|
||||
// contextKey is an unexported type used as the context key for TokenInfo,
|
||||
// preventing collisions with keys from other packages.
|
||||
type contextKey struct{}
|
||||
|
||||
// ContextWithTokenInfo returns a new context carrying the given TokenInfo.
|
||||
func ContextWithTokenInfo(ctx context.Context, info *TokenInfo) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, info)
|
||||
}
|
||||
|
||||
// TokenInfoFromContext extracts TokenInfo from the context. It returns nil
|
||||
// if no TokenInfo is present.
|
||||
func TokenInfoFromContext(ctx context.Context) *TokenInfo {
|
||||
info, _ := ctx.Value(contextKey{}).(*TokenInfo)
|
||||
return info
|
||||
}
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
// Package config provides TOML configuration loading with environment
|
||||
// variable overrides for Metacircular services.
|
||||
//
|
||||
// Services define their own config struct embedding [Base], which provides
|
||||
// the standard sections (Server, Database, MCIAS, Log). Use [Load] to
|
||||
// parse a TOML file, apply environment overrides, set defaults, and
|
||||
// validate required fields.
|
||||
//
|
||||
// # Duration fields
|
||||
//
|
||||
// Timeout fields in [ServerConfig] use the [Duration] type rather than
|
||||
// [time.Duration] because go-toml v2 does not natively decode strings
|
||||
// (e.g., "30s") into time.Duration. Access the underlying value via
|
||||
// the embedded field:
|
||||
//
|
||||
// cfg.Server.ReadTimeout.Duration // time.Duration
|
||||
//
|
||||
// In TOML files, durations are written as Go duration strings:
|
||||
//
|
||||
// read_timeout = "30s"
|
||||
// idle_timeout = "2m"
|
||||
//
|
||||
// Environment variable overrides also use this format:
|
||||
//
|
||||
// MCR_SERVER_READ_TIMEOUT=30s
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
|
||||
"git.wntrmute.dev/mc/mcdsl/auth"
|
||||
)
|
||||
|
||||
// Base contains the configuration sections common to all Metacircular
|
||||
// services. Services embed this in their own config struct and add
|
||||
// service-specific sections.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type MyConfig struct {
|
||||
// config.Base
|
||||
// MyService MyServiceSection `toml:"my_service"`
|
||||
// }
|
||||
type Base struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
Database DatabaseConfig `toml:"database"`
|
||||
MCIAS auth.Config `toml:"mcias"`
|
||||
Log LogConfig `toml:"log"`
|
||||
}
|
||||
|
||||
// ServerConfig holds TLS server settings.
|
||||
type ServerConfig struct {
|
||||
// ListenAddr is the HTTPS listen address (e.g., ":8443"). Required.
|
||||
ListenAddr string `toml:"listen_addr"`
|
||||
|
||||
// GRPCAddr is the gRPC listen address (e.g., ":9443"). Optional;
|
||||
// gRPC is disabled if empty.
|
||||
GRPCAddr string `toml:"grpc_addr"`
|
||||
|
||||
// TLSCert is the path to the TLS certificate file (PEM). Required.
|
||||
TLSCert string `toml:"tls_cert"`
|
||||
|
||||
// TLSKey is the path to the TLS private key file (PEM). Required.
|
||||
TLSKey string `toml:"tls_key"`
|
||||
|
||||
// ReadTimeout is the maximum duration for reading the entire request.
|
||||
// Defaults to 30s.
|
||||
ReadTimeout Duration `toml:"read_timeout"`
|
||||
|
||||
// WriteTimeout is the maximum duration before timing out writes.
|
||||
// Defaults to 30s.
|
||||
WriteTimeout Duration `toml:"write_timeout"`
|
||||
|
||||
// IdleTimeout is the maximum time to wait for the next request on
|
||||
// a keep-alive connection. Defaults to 120s.
|
||||
IdleTimeout Duration `toml:"idle_timeout"`
|
||||
|
||||
// ShutdownTimeout is the maximum time to wait for in-flight requests
|
||||
// to drain during graceful shutdown. Defaults to 60s.
|
||||
ShutdownTimeout Duration `toml:"shutdown_timeout"`
|
||||
}
|
||||
|
||||
// DatabaseConfig holds SQLite database settings.
|
||||
type DatabaseConfig struct {
|
||||
// Path is the path to the SQLite database file. Required.
|
||||
Path string `toml:"path"`
|
||||
}
|
||||
|
||||
// LogConfig holds logging settings.
|
||||
type LogConfig struct {
|
||||
// Level is the log level (debug, info, warn, error). Defaults to "info".
|
||||
Level string `toml:"level"`
|
||||
}
|
||||
|
||||
// WebConfig holds web UI server settings. This is not part of Base because
|
||||
// not all services have a web UI โ services that do can add it to their
|
||||
// own config struct.
|
||||
type WebConfig struct {
|
||||
// ListenAddr is the web UI listen address (e.g., "127.0.0.1:8080").
|
||||
ListenAddr string `toml:"listen_addr"`
|
||||
|
||||
// GRPCAddr is the gRPC address of the API server that the web UI
|
||||
// connects to.
|
||||
GRPCAddr string `toml:"grpc_addr"`
|
||||
|
||||
// CACert is an optional CA certificate for verifying the API server's
|
||||
// TLS certificate.
|
||||
CACert string `toml:"ca_cert"`
|
||||
}
|
||||
|
||||
// Validator is an optional interface that config structs can implement
|
||||
// to add service-specific validation. If the config type implements
|
||||
// Validator, its Validate method is called after defaults and env
|
||||
// overrides are applied.
|
||||
type Validator interface {
|
||||
Validate() error
|
||||
}
|
||||
|
||||
// Load reads a TOML config file at path, applies environment variable
|
||||
// overrides using envPrefix (e.g., "MCR" maps MCR_SERVER_LISTEN_ADDR to
|
||||
// Server.ListenAddr), sets defaults for unset optional fields, and
|
||||
// validates required fields.
|
||||
//
|
||||
// If T implements [Validator], its Validate method is called after all
|
||||
// other processing.
|
||||
func Load[T any](path string, envPrefix string) (*T, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // config path is operator-supplied
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg T
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("config: parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
if envPrefix != "" {
|
||||
applyEnvToStruct(reflect.ValueOf(&cfg).Elem(), envPrefix)
|
||||
}
|
||||
|
||||
applyPortEnv(&cfg)
|
||||
|
||||
applyBaseDefaults(&cfg)
|
||||
|
||||
if err := validateBase(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if v, ok := any(&cfg).(Validator); ok {
|
||||
if err := v.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// applyBaseDefaults sets defaults on the embedded Base struct if present.
|
||||
func applyBaseDefaults(cfg any) {
|
||||
base := findBase(cfg)
|
||||
if base == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if base.Server.ReadTimeout.Duration == 0 {
|
||||
base.Server.ReadTimeout.Duration = 30 * time.Second
|
||||
}
|
||||
if base.Server.WriteTimeout.Duration == 0 {
|
||||
base.Server.WriteTimeout.Duration = 30 * time.Second
|
||||
}
|
||||
if base.Server.IdleTimeout.Duration == 0 {
|
||||
base.Server.IdleTimeout.Duration = 120 * time.Second
|
||||
}
|
||||
if base.Server.ShutdownTimeout.Duration == 0 {
|
||||
base.Server.ShutdownTimeout.Duration = 60 * time.Second
|
||||
}
|
||||
if base.Log.Level == "" {
|
||||
base.Log.Level = "info"
|
||||
}
|
||||
}
|
||||
|
||||
// validateBase checks required fields on the embedded Base struct if present.
|
||||
func validateBase(cfg any) error {
|
||||
base := findBase(cfg)
|
||||
if base == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
required := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"server.listen_addr", base.Server.ListenAddr},
|
||||
{"server.tls_cert", base.Server.TLSCert},
|
||||
{"server.tls_key", base.Server.TLSKey},
|
||||
}
|
||||
|
||||
for _, r := range required {
|
||||
if r.value == "" {
|
||||
return fmt.Errorf("config: required field %q is missing", r.name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findBase returns a pointer to the embedded Base struct, or nil if the
|
||||
// config type does not embed Base.
|
||||
func findBase(cfg any) *Base {
|
||||
v := reflect.ValueOf(cfg)
|
||||
if v.Kind() == reflect.Ptr {
|
||||
v = v.Elem()
|
||||
}
|
||||
if v.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if cfg *is* a Base.
|
||||
if b, ok := v.Addr().Interface().(*Base); ok {
|
||||
return b
|
||||
}
|
||||
|
||||
// Check embedded fields.
|
||||
t := v.Type()
|
||||
for i := range t.NumField() {
|
||||
field := t.Field(i)
|
||||
if field.Anonymous && field.Type == reflect.TypeOf(Base{}) {
|
||||
b, ok := v.Field(i).Addr().Interface().(*Base)
|
||||
if ok {
|
||||
return b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyPortEnv overrides ServerConfig.ListenAddr and ServerConfig.GRPCAddr
|
||||
// from $PORT and $PORT_GRPC respectively. These environment variables are
|
||||
// set by the MCP agent to assign authoritative port bindings, so they take
|
||||
// precedence over both TOML values and generic env overrides.
|
||||
func applyPortEnv(cfg any) {
|
||||
sc := findServerConfig(cfg)
|
||||
if sc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if port, ok := os.LookupEnv("PORT"); ok {
|
||||
sc.ListenAddr = ":" + port
|
||||
}
|
||||
if port, ok := os.LookupEnv("PORT_GRPC"); ok {
|
||||
sc.GRPCAddr = ":" + port
|
||||
}
|
||||
}
|
||||
|
||||
// findServerConfig returns a pointer to the ServerConfig in the config
|
||||
// struct. It first checks for an embedded Base (which contains Server),
|
||||
// then walks the struct tree via reflection to find any ServerConfig field
|
||||
// directly (e.g., the Metacrypt pattern where ServerConfig is embedded
|
||||
// without Base).
|
||||
func findServerConfig(cfg any) *ServerConfig {
|
||||
if base := findBase(cfg); base != nil {
|
||||
return &base.Server
|
||||
}
|
||||
|
||||
return findServerConfigReflect(reflect.ValueOf(cfg))
|
||||
}
|
||||
|
||||
// findServerConfigReflect walks the struct tree to find a ServerConfig field.
|
||||
func findServerConfigReflect(v reflect.Value) *ServerConfig {
|
||||
if v.Kind() == reflect.Ptr {
|
||||
v = v.Elem()
|
||||
}
|
||||
if v.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
|
||||
scType := reflect.TypeOf(ServerConfig{})
|
||||
t := v.Type()
|
||||
for i := range t.NumField() {
|
||||
field := t.Field(i)
|
||||
fv := v.Field(i)
|
||||
|
||||
if field.Type == scType {
|
||||
sc, ok := fv.Addr().Interface().(*ServerConfig)
|
||||
if ok {
|
||||
return sc
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into embedded or nested structs.
|
||||
if fv.Kind() == reflect.Struct && field.Type != scType {
|
||||
if sc := findServerConfigReflect(fv); sc != nil {
|
||||
return sc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyEnvToStruct recursively walks a struct and overrides field values
|
||||
// from environment variables. The env variable name is built from the
|
||||
// prefix and the toml tag: PREFIX_SECTION_FIELD (uppercased).
|
||||
//
|
||||
// Supported field types: string, time.Duration (as int64), []string
|
||||
// (comma-separated), bool, int.
|
||||
func applyEnvToStruct(v reflect.Value, prefix string) {
|
||||
if v.Kind() == reflect.Ptr {
|
||||
v = v.Elem()
|
||||
}
|
||||
t := v.Type()
|
||||
|
||||
for i := range t.NumField() {
|
||||
field := t.Field(i)
|
||||
fv := v.Field(i)
|
||||
|
||||
// For anonymous (embedded) fields, recurse with the same prefix.
|
||||
if field.Anonymous {
|
||||
applyEnvToStruct(fv, prefix)
|
||||
continue
|
||||
}
|
||||
|
||||
tag := field.Tag.Get("toml")
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
envKey := prefix + "_" + strings.ToUpper(tag)
|
||||
|
||||
// Handle Duration wrapper before generic struct recursion.
|
||||
if field.Type == reflect.TypeOf(Duration{}) {
|
||||
envVal, ok := os.LookupEnv(envKey)
|
||||
if ok {
|
||||
d, parseErr := time.ParseDuration(envVal)
|
||||
if parseErr == nil {
|
||||
fv.Set(reflect.ValueOf(Duration{d}))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if field.Type.Kind() == reflect.Struct {
|
||||
applyEnvToStruct(fv, envKey)
|
||||
continue
|
||||
}
|
||||
|
||||
envVal, ok := os.LookupEnv(envKey)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch fv.Kind() {
|
||||
case reflect.String:
|
||||
fv.SetString(envVal)
|
||||
case reflect.Bool:
|
||||
fv.SetBool(envVal == "true" || envVal == "1")
|
||||
case reflect.Slice:
|
||||
if field.Type.Elem().Kind() == reflect.String {
|
||||
parts := strings.Split(envVal, ",")
|
||||
for j := range parts {
|
||||
parts[j] = strings.TrimSpace(parts[j])
|
||||
}
|
||||
fv.Set(reflect.ValueOf(parts))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Duration is a [time.Duration] that can be unmarshalled from a TOML string
|
||||
// (e.g., "30s", "5m"). go-toml v2 does not natively decode strings into
|
||||
// time.Duration, so this wrapper implements [encoding.TextUnmarshaler].
|
||||
//
|
||||
// Access the underlying time.Duration via the embedded field:
|
||||
//
|
||||
// cfg.Server.ReadTimeout.Duration // time.Duration value
|
||||
//
|
||||
// Duration values work directly with time functions that accept
|
||||
// time.Duration because of the embedding:
|
||||
//
|
||||
// time.After(cfg.Server.ReadTimeout.Duration)
|
||||
type Duration struct {
|
||||
time.Duration
|
||||
}
|
||||
|
||||
// UnmarshalText implements encoding.TextUnmarshaler for TOML string decoding.
|
||||
func (d *Duration) UnmarshalText(text []byte) error {
|
||||
parsed, err := time.ParseDuration(string(text))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration %q: %w", string(text), err)
|
||||
}
|
||||
d.Duration = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalText implements encoding.TextMarshaler for TOML string encoding.
|
||||
func (d Duration) MarshalText() ([]byte, error) {
|
||||
return []byte(d.String()), nil
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
// Package csrf provides HMAC-SHA256 double-submit cookie CSRF protection
|
||||
// for Metacircular web UIs.
|
||||
//
|
||||
// The token format is base64(nonce) + "." + base64(HMAC-SHA256(secret, nonce)).
|
||||
// A fresh token is set as a cookie on each page load. Mutating requests
|
||||
// (POST, PUT, PATCH, DELETE) must include the token as a form field that
|
||||
// matches the cookie value. Both the match and the HMAC signature are
|
||||
// verified.
|
||||
package csrf
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Protect provides CSRF token generation, validation, and middleware.
|
||||
type Protect struct {
|
||||
secret [32]byte
|
||||
cookieName string
|
||||
fieldName string
|
||||
}
|
||||
|
||||
// New creates a Protect with the given secret, cookie name, and form
|
||||
// field name. The secret must be 32 bytes from crypto/rand and should
|
||||
// be unique per service instance.
|
||||
//
|
||||
// Typical usage:
|
||||
//
|
||||
// secret := make([]byte, 32)
|
||||
// crypto_rand.Read(secret)
|
||||
// csrf := csrf.New(secret, "myservice_csrf", "csrf_token")
|
||||
func New(secret []byte, cookieName, fieldName string) *Protect {
|
||||
p := &Protect{
|
||||
cookieName: cookieName,
|
||||
fieldName: fieldName,
|
||||
}
|
||||
copy(p.secret[:], secret)
|
||||
return p
|
||||
}
|
||||
|
||||
// Middleware validates CSRF tokens on mutating requests (POST, PUT,
|
||||
// PATCH, DELETE). Safe methods (GET, HEAD, OPTIONS) pass through.
|
||||
// Returns 403 Forbidden if the token is missing, mismatched, or invalid.
|
||||
func (p *Protect) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
formToken := r.FormValue(p.fieldName) //nolint:gosec // form size is bounded by the http.Server's MaxBytesReader or ReadTimeout
|
||||
cookie, err := r.Cookie(p.cookieName)
|
||||
if err != nil || cookie.Value == "" || formToken == "" {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if formToken != cookie.Value {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !p.validateToken(formToken) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// SetToken generates a new CSRF token, sets it as a cookie on the
|
||||
// response, and returns the token string. Call this when rendering
|
||||
// pages that contain forms.
|
||||
func (p *Protect) SetToken(w http.ResponseWriter) string {
|
||||
token := p.generateToken()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: p.cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
// TemplateFunc returns a [template.FuncMap] containing a "csrfField"
|
||||
// function that renders a hidden input with the CSRF token. It calls
|
||||
// SetToken to set the cookie. Use in template rendering:
|
||||
//
|
||||
// tmpl.Funcs(csrf.TemplateFunc(w))
|
||||
//
|
||||
// In templates:
|
||||
//
|
||||
// <form method="POST">
|
||||
// {{ csrfField }}
|
||||
// ...
|
||||
// </form>
|
||||
func (p *Protect) TemplateFunc(w http.ResponseWriter) template.FuncMap {
|
||||
token := p.SetToken(w)
|
||||
return template.FuncMap{
|
||||
"csrfField": func() template.HTML {
|
||||
return template.HTML(fmt.Sprintf( //nolint:gosec // output is escaped field name + validated token
|
||||
`<input type="hidden" name="%s" value="%s">`,
|
||||
template.HTMLEscapeString(p.fieldName),
|
||||
template.HTMLEscapeString(token),
|
||||
))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Protect) generateToken() string {
|
||||
nonce := make([]byte, 32)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
panic("csrf: failed to read random bytes: " + err.Error())
|
||||
}
|
||||
mac := hmac.New(sha256.New, p.secret[:])
|
||||
mac.Write(nonce)
|
||||
sig := mac.Sum(nil)
|
||||
return base64.StdEncoding.EncodeToString(nonce) + "." + base64.StdEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
func (p *Protect) validateToken(token string) bool {
|
||||
parts := strings.SplitN(token, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
nonce, err := base64.StdEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, p.secret[:])
|
||||
mac.Write(nonce)
|
||||
return hmac.Equal(sig, mac.Sum(nil))
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
// Package db provides SQLite database setup, migrations, and snapshots
|
||||
// for Metacircular services.
|
||||
//
|
||||
// All databases are opened with the standard Metacircular pragmas (WAL mode,
|
||||
// foreign keys, busy timeout) and restrictive file permissions (0600).
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // SQLite driver (pure Go, no CGo).
|
||||
)
|
||||
|
||||
// Open opens or creates a SQLite database at path with the standard
|
||||
// Metacircular pragmas:
|
||||
//
|
||||
// PRAGMA journal_mode = WAL;
|
||||
// PRAGMA foreign_keys = ON;
|
||||
// PRAGMA busy_timeout = 5000;
|
||||
//
|
||||
// The file is created with 0600 permissions (owner read/write only).
|
||||
// The parent directory is created if it does not exist.
|
||||
//
|
||||
// Open returns a standard [*sql.DB] โ no wrapper types. Services use it
|
||||
// directly with database/sql.
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("db: create directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
// Pre-create the file with restrictive permissions if it does not exist.
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
f, createErr := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) //nolint:gosec // path is caller-provided config, not user input
|
||||
if createErr != nil {
|
||||
return nil, fmt.Errorf("db: create file %s: %w", path, createErr)
|
||||
}
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
database, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: open %s: %w", path, err)
|
||||
}
|
||||
|
||||
pragmas := []string{
|
||||
"PRAGMA journal_mode = WAL",
|
||||
"PRAGMA foreign_keys = ON",
|
||||
"PRAGMA busy_timeout = 5000",
|
||||
}
|
||||
for _, p := range pragmas {
|
||||
if _, execErr := database.Exec(p); execErr != nil {
|
||||
_ = database.Close()
|
||||
return nil, fmt.Errorf("db: %s: %w", p, execErr)
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite supports concurrent readers but only one writer. With WAL mode,
|
||||
// reads don't block writes, but multiple Go connections competing for
|
||||
// the write lock causes SQLITE_BUSY under concurrent load. Limit to one
|
||||
// connection to serialize all access and eliminate busy errors.
|
||||
database.SetMaxOpenConns(1)
|
||||
|
||||
// Best-effort permissions tightening. This may fail inside rootless
|
||||
// podman containers where fchmod is denied in the user namespace.
|
||||
// The database still functions correctly without it.
|
||||
// See: log/2026-04-03-uid-incident.md
|
||||
_ = os.Chmod(path, 0600)
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
// Migration is a numbered, named schema change. Services define their
|
||||
// migrations as a []Migration slice โ the slice is the schema history.
|
||||
type Migration struct {
|
||||
// Version is the migration number. Must be unique and should be
|
||||
// sequential starting from 1.
|
||||
Version int
|
||||
|
||||
// Name is a short human-readable description (e.g., "initial schema").
|
||||
Name string
|
||||
|
||||
// SQL is the DDL/DML to execute. Multiple statements are allowed
|
||||
// (separated by semicolons). Each migration runs in a transaction.
|
||||
SQL string
|
||||
}
|
||||
|
||||
// Migrate applies all pending migrations from the given slice. It creates
|
||||
// the schema_migrations tracking table if it does not exist.
|
||||
//
|
||||
// Each migration runs in its own transaction. Already-applied migrations
|
||||
// (identified by version number) are skipped. Timestamps are stored as
|
||||
// RFC 3339 UTC.
|
||||
func Migrate(database *sql.DB, migrations []Migration) error {
|
||||
_, err := database.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
applied_at TEXT NOT NULL DEFAULT ''
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
applied, checkErr := migrationApplied(database, m.Version)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
|
||||
tx, txErr := database.Begin()
|
||||
if txErr != nil {
|
||||
return fmt.Errorf("db: begin migration %d (%s): %w", m.Version, m.Name, txErr)
|
||||
}
|
||||
|
||||
if _, execErr := tx.Exec(m.SQL); execErr != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("db: migration %d (%s): %w", m.Version, m.Name, execErr)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, execErr := tx.Exec(
|
||||
`INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)`,
|
||||
m.Version, m.Name, now,
|
||||
); execErr != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("db: record migration %d: %w", m.Version, execErr)
|
||||
}
|
||||
|
||||
if commitErr := tx.Commit(); commitErr != nil {
|
||||
return fmt.Errorf("db: commit migration %d: %w", m.Version, commitErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SchemaVersion returns the highest applied migration version, or 0 if
|
||||
// no migrations have been applied.
|
||||
func SchemaVersion(database *sql.DB) (int, error) {
|
||||
var version sql.NullInt64
|
||||
err := database.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&version)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: schema version: %w", err)
|
||||
}
|
||||
if !version.Valid {
|
||||
return 0, nil
|
||||
}
|
||||
return int(version.Int64), nil
|
||||
}
|
||||
|
||||
// Snapshot creates a consistent backup of the database at destPath using
|
||||
// SQLite's VACUUM INTO. The destination file is created with 0600
|
||||
// permissions.
|
||||
func Snapshot(database *sql.DB, destPath string) error {
|
||||
dir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return fmt.Errorf("db: create snapshot directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
if _, err := database.Exec("VACUUM INTO ?", destPath); err != nil {
|
||||
return fmt.Errorf("db: snapshot: %w", err)
|
||||
}
|
||||
|
||||
_ = os.Chmod(destPath, 0600) // best-effort; may fail in rootless containers
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrationApplied(database *sql.DB, version int) (bool, error) {
|
||||
var count int
|
||||
err := database.QueryRow(
|
||||
`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, version,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("db: check migration %d: %w", version, err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Package grpcserver provides gRPC server setup with TLS, interceptor
|
||||
// chain, and method-map authentication for Metacircular services.
|
||||
//
|
||||
// Access control is enforced via a [MethodMap] that classifies each RPC
|
||||
// as public, auth-required, or admin-required. Methods not listed in any
|
||||
// map are denied by default โ forgetting to register a new RPC results
|
||||
// in a denied request, not an open one.
|
||||
package grpcserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"git.wntrmute.dev/mc/mcdsl/auth"
|
||||
)
|
||||
|
||||
// MethodMap classifies gRPC methods for access control.
|
||||
type MethodMap struct {
|
||||
// Public methods require no authentication.
|
||||
Public map[string]bool
|
||||
|
||||
// AuthRequired methods require a valid MCIAS bearer token.
|
||||
AuthRequired map[string]bool
|
||||
|
||||
// AdminRequired methods require a valid token with the admin role.
|
||||
AdminRequired map[string]bool
|
||||
}
|
||||
|
||||
// Server wraps a grpc.Server with Metacircular auth interceptors.
|
||||
type Server struct {
|
||||
// GRPCServer is the underlying grpc.Server. Services register their
|
||||
// implementations on it before calling Serve.
|
||||
GRPCServer *grpc.Server
|
||||
|
||||
// Logger is used by the logging interceptor.
|
||||
Logger *slog.Logger
|
||||
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// Options configures optional behavior for the gRPC server.
|
||||
type Options struct {
|
||||
// PreInterceptors run before the logging and auth interceptors.
|
||||
// Use for lifecycle gates like seal checks that should reject
|
||||
// requests before any auth validation occurs.
|
||||
PreInterceptors []grpc.UnaryServerInterceptor
|
||||
|
||||
// PostInterceptors run after auth but before the handler.
|
||||
// Use for audit logging, rate limiting, or other cross-cutting
|
||||
// concerns that need access to the authenticated identity.
|
||||
PostInterceptors []grpc.UnaryServerInterceptor
|
||||
}
|
||||
|
||||
// New creates a gRPC server with TLS (if certFile and keyFile are
|
||||
// non-empty) and an interceptor chain:
|
||||
//
|
||||
// [pre-interceptors] โ logging โ auth โ [post-interceptors] โ handler
|
||||
//
|
||||
// The auth interceptor uses methods to determine the access level for
|
||||
// each RPC. Methods not in any map are denied by default.
|
||||
//
|
||||
// If certFile and keyFile are empty, TLS is skipped (for testing).
|
||||
// opts is optional; pass nil for the default chain (logging + auth only).
|
||||
func New(certFile, keyFile string, authenticator *auth.Authenticator, methods MethodMap, logger *slog.Logger, opts *Options) (*Server, error) {
|
||||
var interceptors []grpc.UnaryServerInterceptor
|
||||
if opts != nil {
|
||||
interceptors = append(interceptors, opts.PreInterceptors...)
|
||||
}
|
||||
interceptors = append(interceptors,
|
||||
loggingInterceptor(logger),
|
||||
authInterceptor(authenticator, methods),
|
||||
)
|
||||
if opts != nil {
|
||||
interceptors = append(interceptors, opts.PostInterceptors...)
|
||||
}
|
||||
chain := grpc.ChainUnaryInterceptor(interceptors...)
|
||||
|
||||
var serverOpts []grpc.ServerOption
|
||||
serverOpts = append(serverOpts, chain)
|
||||
|
||||
if certFile != "" && keyFile != "" {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grpcserver: load TLS cert: %w", err)
|
||||
}
|
||||
tlsCfg := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
serverOpts = append(serverOpts, grpc.Creds(credentials.NewTLS(tlsCfg)))
|
||||
}
|
||||
|
||||
return &Server{
|
||||
GRPCServer: grpc.NewServer(serverOpts...),
|
||||
Logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Serve starts the gRPC server on the given address. It blocks until
|
||||
// the server is stopped.
|
||||
func (s *Server) Serve(addr string) error {
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grpcserver: listen %s: %w", addr, err)
|
||||
}
|
||||
s.listener = lis
|
||||
s.Logger.Info("starting gRPC server", "addr", addr)
|
||||
return s.GRPCServer.Serve(lis)
|
||||
}
|
||||
|
||||
// Stop gracefully stops the gRPC server, waiting for in-flight RPCs
|
||||
// to complete.
|
||||
func (s *Server) Stop() {
|
||||
s.GRPCServer.GracefulStop()
|
||||
}
|
||||
|
||||
// TokenInfoFromContext extracts [auth.TokenInfo] from a gRPC request
|
||||
// context. Returns nil if no token info is present (e.g., for public
|
||||
// methods).
|
||||
func TokenInfoFromContext(ctx context.Context) *auth.TokenInfo {
|
||||
return auth.TokenInfoFromContext(ctx)
|
||||
}
|
||||
|
||||
// loggingInterceptor logs each RPC after it completes.
|
||||
func loggingInterceptor(logger *slog.Logger) grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
start := time.Now()
|
||||
resp, err := handler(ctx, req)
|
||||
code := status.Code(err)
|
||||
logger.Info("grpc",
|
||||
"method", info.FullMethod,
|
||||
"code", code.String(),
|
||||
"duration", time.Since(start),
|
||||
)
|
||||
return resp, err
|
||||
}
|
||||
}
|
||||
|
||||
// authInterceptor enforces access control based on the MethodMap.
|
||||
//
|
||||
// Evaluation order:
|
||||
// 1. Public โ pass through, no auth.
|
||||
// 2. AdminRequired โ validate token, require IsAdmin.
|
||||
// 3. AuthRequired โ validate token.
|
||||
// 4. Not in any map โ deny (default deny).
|
||||
func authInterceptor(authenticator *auth.Authenticator, methods MethodMap) grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
method := info.FullMethod
|
||||
|
||||
// Public methods: no auth.
|
||||
if methods.Public[method] {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
// All other methods require a valid token.
|
||||
tokenInfo, err := extractAndValidate(ctx, authenticator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Admin-required methods: check admin role.
|
||||
if methods.AdminRequired[method] {
|
||||
if !tokenInfo.IsAdmin {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "admin role required")
|
||||
}
|
||||
ctx = auth.ContextWithTokenInfo(ctx, tokenInfo)
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
// Auth-required methods: token is sufficient.
|
||||
if methods.AuthRequired[method] {
|
||||
ctx = auth.ContextWithTokenInfo(ctx, tokenInfo)
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
// Default deny: method not in any map.
|
||||
return nil, status.Errorf(codes.PermissionDenied, "method not authorized")
|
||||
}
|
||||
}
|
||||
|
||||
// extractAndValidate extracts the bearer token from gRPC metadata and
|
||||
// validates it via the Authenticator.
|
||||
func extractAndValidate(ctx context.Context, authenticator *auth.Authenticator) (*auth.TokenInfo, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "missing metadata")
|
||||
}
|
||||
|
||||
vals := md.Get("authorization")
|
||||
if len(vals) == 0 {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "missing authorization header")
|
||||
}
|
||||
|
||||
token := vals[0]
|
||||
const bearerPrefix = "Bearer "
|
||||
if len(token) > len(bearerPrefix) && token[:len(bearerPrefix)] == bearerPrefix {
|
||||
token = token[len(bearerPrefix):]
|
||||
}
|
||||
|
||||
info, err := authenticator.ValidateToken(token)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid token")
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Package health provides standard health check implementations for
|
||||
// Metacircular services, supporting both REST and gRPC.
|
||||
package health
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// Handler returns an http.HandlerFunc that checks database connectivity.
|
||||
// It returns 200 {"status":"ok"} if the database is reachable, or
|
||||
// 503 {"status":"unhealthy","error":"..."} if the ping fails.
|
||||
//
|
||||
// Mount it on whatever path the service uses (typically /healthz or
|
||||
// /v1/health).
|
||||
func Handler(database *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := database.Ping(); err != nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "unhealthy",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterGRPC registers the standard gRPC health checking service
|
||||
// (grpc.health.v1.Health) on the given gRPC server. The health server
|
||||
// is set to SERVING status immediately.
|
||||
func RegisterGRPC(srv *grpc.Server) {
|
||||
hs := health.NewServer()
|
||||
hs.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
|
||||
healthpb.RegisterHealthServer(srv, hs)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package httpserver provides TLS HTTP server setup with chi, standard
|
||||
// middleware, and graceful shutdown for Metacircular services.
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.wntrmute.dev/mc/mcdsl/config"
|
||||
)
|
||||
|
||||
// Server wraps a chi router and an http.Server with the standard
|
||||
// Metacircular TLS configuration.
|
||||
type Server struct {
|
||||
// Router is the chi router. Services register their routes on it.
|
||||
Router *chi.Mux
|
||||
|
||||
// Logger is used by the logging middleware.
|
||||
Logger *slog.Logger
|
||||
|
||||
httpSrv *http.Server
|
||||
cfg config.ServerConfig
|
||||
}
|
||||
|
||||
// New creates a Server configured from cfg. The underlying http.Server
|
||||
// is configured with TLS 1.3 minimum and timeouts from the config.
|
||||
// Services access s.Router to register routes before calling
|
||||
// ListenAndServeTLS.
|
||||
func New(cfg config.ServerConfig, logger *slog.Logger) *Server {
|
||||
r := chi.NewRouter()
|
||||
|
||||
s := &Server{
|
||||
Router: r,
|
||||
Logger: logger,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
s.httpSrv = &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: r,
|
||||
TLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
ReadTimeout: cfg.ReadTimeout.Duration,
|
||||
WriteTimeout: cfg.WriteTimeout.Duration,
|
||||
IdleTimeout: cfg.IdleTimeout.Duration,
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// ListenAndServeTLS starts the HTTPS server using the TLS certificate and
|
||||
// key from the config. It blocks until the server is shut down. Returns
|
||||
// nil if the server was shut down gracefully via [Server.Shutdown].
|
||||
func (s *Server) ListenAndServeTLS() error {
|
||||
s.Logger.Info("starting server", "addr", s.cfg.ListenAddr)
|
||||
err := s.httpSrv.ListenAndServeTLS(s.cfg.TLSCert, s.cfg.TLSKey)
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("httpserver: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the server, waiting for in-flight
|
||||
// requests to complete. The provided context controls the shutdown
|
||||
// timeout.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
return s.httpSrv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// LoggingMiddleware logs each HTTP request after it completes, including
|
||||
// method, path, status code, duration, and remote address.
|
||||
func (s *Server) LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &StatusWriter{ResponseWriter: w, Status: http.StatusOK}
|
||||
next.ServeHTTP(sw, r)
|
||||
s.Logger.Info("http",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", sw.Status,
|
||||
"duration", time.Since(start),
|
||||
"remote", r.RemoteAddr,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// StatusWriter wraps an http.ResponseWriter to capture the status code.
|
||||
// It is exported for use in custom middleware.
|
||||
type StatusWriter struct {
|
||||
http.ResponseWriter
|
||||
// Status is the HTTP status code written to the response.
|
||||
Status int
|
||||
}
|
||||
|
||||
// WriteHeader captures the status code and delegates to the underlying
|
||||
// ResponseWriter.
|
||||
func (w *StatusWriter) WriteHeader(code int) {
|
||||
w.Status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// WriteJSON writes v as JSON with the given HTTP status code.
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// WriteError writes a standard Metacircular error response:
|
||||
// {"error": "message"}.
|
||||
func WriteError(w http.ResponseWriter, status int, message string) {
|
||||
WriteJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
// Package sso provides an SSO redirect client for Metacircular web services.
|
||||
//
|
||||
// Services redirect unauthenticated users to MCIAS for login. After
|
||||
// authentication, MCIAS redirects back with an authorization code that
|
||||
// the service exchanges for a JWT token. This package handles the
|
||||
// redirect, state management, and code exchange.
|
||||
//
|
||||
// Security design:
|
||||
// - State cookies use SameSite=Lax (not Strict) because the redirect from
|
||||
// MCIAS back to the service is a cross-site navigation.
|
||||
// - State is a 256-bit random value stored in an HttpOnly cookie.
|
||||
// - Return-to URLs are stored in a separate cookie so MCIAS never sees them.
|
||||
// - The code exchange is a server-to-server HTTPS call (TLS 1.3 minimum).
|
||||
package sso
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
stateBytes = 32 // 256 bits
|
||||
stateCookieAge = 5 * 60 // 5 minutes in seconds
|
||||
)
|
||||
|
||||
// Config holds the SSO client configuration. The values must match the
|
||||
// SSO client registration in MCIAS config.
|
||||
type Config struct {
|
||||
// MciasURL is the base URL of the MCIAS server used for the
|
||||
// server-to-server authorization-code exchange. This is typically the
|
||||
// internal/Tailnet address so the exchange does not depend on the public
|
||||
// edge.
|
||||
MciasURL string
|
||||
|
||||
// PublicURL, when set, is the browser-facing MCIAS base URL used to build
|
||||
// the authorize redirect. It must be resolvable and reachable by end-user
|
||||
// browsers (e.g. the public hostname). When empty, MciasURL is used for
|
||||
// both, which only works when MciasURL is itself browser-reachable.
|
||||
PublicURL string
|
||||
|
||||
// ClientID is the registered SSO client identifier.
|
||||
ClientID string
|
||||
|
||||
// RedirectURI is the callback URL that MCIAS redirects to after login.
|
||||
// Must exactly match the redirect_uri registered in MCIAS config.
|
||||
RedirectURI string
|
||||
|
||||
// CACert is an optional path to a PEM-encoded CA certificate for
|
||||
// verifying the MCIAS server's TLS certificate.
|
||||
CACert string
|
||||
}
|
||||
|
||||
// Client handles the SSO redirect flow with MCIAS.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New creates an SSO client. TLS 1.3 is required for all HTTPS
|
||||
// connections to MCIAS.
|
||||
func New(cfg Config) (*Client, error) {
|
||||
if cfg.MciasURL == "" {
|
||||
return nil, fmt.Errorf("sso: mcias_url is required")
|
||||
}
|
||||
if cfg.ClientID == "" {
|
||||
return nil, fmt.Errorf("sso: client_id is required")
|
||||
}
|
||||
if cfg.RedirectURI == "" {
|
||||
return nil, fmt.Errorf("sso: redirect_uri is required")
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
|
||||
if !strings.HasPrefix(cfg.MciasURL, "http://") {
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
|
||||
if cfg.CACert != "" {
|
||||
pem, err := os.ReadFile(cfg.CACert) //nolint:gosec // CA cert path from operator config
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sso: read CA cert %s: %w", cfg.CACert, err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("sso: no valid certificates in %s", cfg.CACert)
|
||||
}
|
||||
tlsCfg.RootCAs = pool
|
||||
}
|
||||
|
||||
transport.TLSClientConfig = tlsCfg
|
||||
}
|
||||
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// authorizeBase returns the browser-facing MCIAS base URL: PublicURL when
|
||||
// configured, otherwise MciasURL.
|
||||
func (c *Client) authorizeBase() string {
|
||||
if c.cfg.PublicURL != "" {
|
||||
return c.cfg.PublicURL
|
||||
}
|
||||
return c.cfg.MciasURL
|
||||
}
|
||||
|
||||
// AuthorizeURL returns the MCIAS authorize URL (browser-facing) with the
|
||||
// given state parameter.
|
||||
func (c *Client) AuthorizeURL(state string) string {
|
||||
base := strings.TrimRight(c.authorizeBase(), "/")
|
||||
return base + "/sso/authorize?" + url.Values{
|
||||
"client_id": {c.cfg.ClientID},
|
||||
"redirect_uri": {c.cfg.RedirectURI},
|
||||
"state": {state},
|
||||
}.Encode()
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges an authorization code for a JWT token by calling
|
||||
// MCIAS POST /v1/sso/token.
|
||||
func (c *Client) ExchangeCode(ctx context.Context, code string) (token string, expiresAt time.Time, err error) {
|
||||
reqBody, _ := json.Marshal(map[string]string{
|
||||
"code": code,
|
||||
"client_id": c.cfg.ClientID,
|
||||
"redirect_uri": c.cfg.RedirectURI,
|
||||
})
|
||||
|
||||
base := strings.TrimRight(c.cfg.MciasURL, "/")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
base+"/v1/sso/token", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("sso: build exchange request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("sso: MCIAS exchange: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("sso: read exchange response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", time.Time{}, fmt.Errorf("sso: exchange failed (HTTP %d): %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("sso: decode exchange response: %w", err)
|
||||
}
|
||||
|
||||
exp, parseErr := time.Parse(time.RFC3339, result.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
exp = time.Now().Add(1 * time.Hour)
|
||||
}
|
||||
|
||||
return result.Token, exp, nil
|
||||
}
|
||||
|
||||
// GenerateState returns a cryptographically random hex-encoded state string.
|
||||
func GenerateState() (string, error) {
|
||||
raw := make([]byte, stateBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("sso: generate state: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
// StateCookieName returns the cookie name used for SSO state for a given
|
||||
// service cookie prefix (e.g., "mcr" โ "mcr_sso_state").
|
||||
func StateCookieName(prefix string) string {
|
||||
return prefix + "_sso_state"
|
||||
}
|
||||
|
||||
// ReturnToCookieName returns the cookie name used for SSO return-to URL
|
||||
// (e.g., "mcr" โ "mcr_sso_return").
|
||||
func ReturnToCookieName(prefix string) string {
|
||||
return prefix + "_sso_return"
|
||||
}
|
||||
|
||||
// SetStateCookie stores the SSO state in a short-lived cookie.
|
||||
//
|
||||
// Security: SameSite=Lax is required because the redirect from MCIAS back to
|
||||
// the service is a cross-site top-level navigation. SameSite=Strict cookies
|
||||
// would not be sent on that redirect.
|
||||
func SetStateCookie(w http.ResponseWriter, prefix, state string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: StateCookieName(prefix),
|
||||
Value: state,
|
||||
Path: "/",
|
||||
MaxAge: stateCookieAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateStateCookie compares the state query parameter against the state
|
||||
// cookie. If they match, the cookie is cleared and nil is returned.
|
||||
func ValidateStateCookie(w http.ResponseWriter, r *http.Request, prefix, queryState string) error {
|
||||
c, err := r.Cookie(StateCookieName(prefix))
|
||||
if err != nil || c.Value == "" {
|
||||
return fmt.Errorf("sso: missing state cookie")
|
||||
}
|
||||
|
||||
if c.Value != queryState {
|
||||
return fmt.Errorf("sso: state mismatch")
|
||||
}
|
||||
|
||||
// Clear the state cookie (single-use).
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: StateCookieName(prefix),
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetReturnToCookie stores the current request path so the service can
|
||||
// redirect back to it after SSO login completes.
|
||||
func SetReturnToCookie(w http.ResponseWriter, r *http.Request, prefix string) {
|
||||
path := r.URL.Path
|
||||
if path == "" || path == "/login" || strings.HasPrefix(path, "/sso/") {
|
||||
path = "/"
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: ReturnToCookieName(prefix),
|
||||
Value: path,
|
||||
Path: "/",
|
||||
MaxAge: stateCookieAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ConsumeReturnToCookie reads and clears the return-to cookie, returning
|
||||
// the path. Returns "/" if the cookie is missing or empty.
|
||||
func ConsumeReturnToCookie(w http.ResponseWriter, r *http.Request, prefix string) string {
|
||||
c, err := r.Cookie(ReturnToCookieName(prefix))
|
||||
path := "/"
|
||||
if err == nil && c.Value != "" {
|
||||
path = c.Value
|
||||
}
|
||||
|
||||
// Clear the cookie.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: ReturnToCookieName(prefix),
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// RedirectToLogin generates a state, sets the state and return-to cookies,
|
||||
// and redirects the user to the MCIAS authorize URL.
|
||||
//
|
||||
// The redirect is performed via a 200 response with an HTML meta-refresh
|
||||
// instead of a 302. Some browsers (notably Firefox) do not reliably store
|
||||
// Set-Cookie headers on 302 responses that redirect to a different origin,
|
||||
// even when the origins are same-site. Using a 200 response ensures the
|
||||
// cookies are stored before the browser navigates away.
|
||||
func RedirectToLogin(w http.ResponseWriter, r *http.Request, client *Client, cookiePrefix string) error {
|
||||
state, err := GenerateState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
SetStateCookie(w, cookiePrefix, state)
|
||||
SetReturnToCookie(w, r, cookiePrefix)
|
||||
|
||||
authorizeURL := client.AuthorizeURL(state)
|
||||
escaped := html.EscapeString(authorizeURL)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html><head><meta http-equiv="refresh" content="0;url=%s"></head>
|
||||
<body><p>Redirecting to <a href="%s">MCIAS</a>...</p></body></html>`,
|
||||
escaped, escaped)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleCallback validates the state, exchanges the authorization code for
|
||||
// a JWT, and returns the token and the return-to path. The caller should
|
||||
// set the session cookie with the returned token.
|
||||
func HandleCallback(w http.ResponseWriter, r *http.Request, client *Client, cookiePrefix string) (token, returnTo string, err error) {
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
if code == "" || state == "" {
|
||||
return "", "", fmt.Errorf("sso: missing code or state parameter")
|
||||
}
|
||||
|
||||
if err := ValidateStateCookie(w, r, cookiePrefix, state); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
token, _, err = client.ExchangeCode(r.Context(), code)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
returnTo = ConsumeReturnToCookie(w, r, cookiePrefix)
|
||||
return token, returnTo, nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Package terminal provides secure terminal input helpers for CLI tools.
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// ReadPassword prints the given prompt to stderr and reads a password
|
||||
// from the terminal with echo disabled. It prints a newline after the
|
||||
// input is complete so the cursor advances normally.
|
||||
func ReadPassword(prompt string) (string, error) {
|
||||
b, err := readRaw(prompt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// ReadPasswordBytes is like ReadPassword but returns a []byte so the
|
||||
// caller can zeroize the buffer after use.
|
||||
func ReadPasswordBytes(prompt string) ([]byte, error) {
|
||||
return readRaw(prompt)
|
||||
}
|
||||
|
||||
func readRaw(prompt string) ([]byte, error) {
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
b, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // fd fits in int
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
// Package web provides session cookie management, auth middleware, and
|
||||
// template rendering helpers for Metacircular web UIs built with htmx
|
||||
// and Go html/template.
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"git.wntrmute.dev/mc/mcdsl/auth"
|
||||
)
|
||||
|
||||
// SetSessionCookie sets a session cookie with the standard Metacircular
|
||||
// security flags: HttpOnly, Secure, SameSite=Strict.
|
||||
func SetSessionCookie(w http.ResponseWriter, name, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSessionCookie removes a session cookie by setting it to empty
|
||||
// with MaxAge=-1.
|
||||
func ClearSessionCookie(w http.ResponseWriter, name string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSessionToken extracts the session token from the named cookie.
|
||||
// Returns empty string if the cookie is missing or empty.
|
||||
func GetSessionToken(r *http.Request, name string) string {
|
||||
c, err := r.Cookie(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// RequireAuth returns middleware that validates the session token via
|
||||
// the Authenticator. If the token is missing or invalid, the user is
|
||||
// redirected to loginPath. On success, the [auth.TokenInfo] is stored
|
||||
// in the request context (retrievable via [auth.TokenInfoFromContext]).
|
||||
func RequireAuth(authenticator *auth.Authenticator, cookieName, loginPath string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := GetSessionToken(r, cookieName)
|
||||
if token == "" {
|
||||
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := authenticator.ValidateToken(token)
|
||||
if err != nil {
|
||||
ClearSessionCookie(w, cookieName)
|
||||
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := auth.ContextWithTokenInfo(r.Context(), info)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RenderTemplate parses and executes a template from an embedded FS.
|
||||
// It parses "templates/layout.html" and "templates/<name>", merges
|
||||
// any provided FuncMaps, and executes the "layout" template with data.
|
||||
//
|
||||
// This matches the layout + page block pattern used by all Metacircular
|
||||
// web UIs.
|
||||
func RenderTemplate(w http.ResponseWriter, fsys fs.FS, name string, data any, funcs ...template.FuncMap) {
|
||||
merged := template.FuncMap{}
|
||||
for _, fm := range funcs {
|
||||
for k, v := range fm {
|
||||
merged[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
tmpl, err := template.New("").Funcs(merged).ParseFS(fsys,
|
||||
"templates/layout.html",
|
||||
"templates/"+name,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
sudo: false
|
||||
language: go
|
||||
go_import_path: github.com/dustin/go-humanize
|
||||
go:
|
||||
- 1.13.x
|
||||
- 1.14.x
|
||||
- 1.15.x
|
||||
- 1.16.x
|
||||
- stable
|
||||
- master
|
||||
matrix:
|
||||
allow_failures:
|
||||
- go: master
|
||||
fast_finish: true
|
||||
install:
|
||||
- # Do nothing. This is needed to prevent default install action "go get -t -v ./..." from happening here (we want it to happen inside script step).
|
||||
script:
|
||||
- diff -u <(echo -n) <(gofmt -d -s .)
|
||||
- go vet .
|
||||
- go install -v -race ./...
|
||||
- go test -v -race ./...
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
Copyright (c) 2005-2008 Dustin Sallings <dustin@spy.net>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
<http://www.opensource.org/licenses/mit-license.php>
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# Humane Units [](https://travis-ci.org/dustin/go-humanize) [](https://godoc.org/github.com/dustin/go-humanize)
|
||||
|
||||
Just a few functions for helping humanize times and sizes.
|
||||
|
||||
`go get` it as `github.com/dustin/go-humanize`, import it as
|
||||
`"github.com/dustin/go-humanize"`, use it as `humanize`.
|
||||
|
||||
See [godoc](https://pkg.go.dev/github.com/dustin/go-humanize) for
|
||||
complete documentation.
|
||||
|
||||
## Sizes
|
||||
|
||||
This lets you take numbers like `82854982` and convert them to useful
|
||||
strings like, `83 MB` or `79 MiB` (whichever you prefer).
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
fmt.Printf("That file is %s.", humanize.Bytes(82854982)) // That file is 83 MB.
|
||||
```
|
||||
|
||||
## Times
|
||||
|
||||
This lets you take a `time.Time` and spit it out in relative terms.
|
||||
For example, `12 seconds ago` or `3 days from now`.
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
fmt.Printf("This was touched %s.", humanize.Time(someTimeInstance)) // This was touched 7 hours ago.
|
||||
```
|
||||
|
||||
Thanks to Kyle Lemons for the time implementation from an IRC
|
||||
conversation one day. It's pretty neat.
|
||||
|
||||
## Ordinals
|
||||
|
||||
From a [mailing list discussion][odisc] where a user wanted to be able
|
||||
to label ordinals.
|
||||
|
||||
0 -> 0th
|
||||
1 -> 1st
|
||||
2 -> 2nd
|
||||
3 -> 3rd
|
||||
4 -> 4th
|
||||
[...]
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
fmt.Printf("You're my %s best friend.", humanize.Ordinal(193)) // You are my 193rd best friend.
|
||||
```
|
||||
|
||||
## Commas
|
||||
|
||||
Want to shove commas into numbers? Be my guest.
|
||||
|
||||
0 -> 0
|
||||
100 -> 100
|
||||
1000 -> 1,000
|
||||
1000000000 -> 1,000,000,000
|
||||
-100000 -> -100,000
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
fmt.Printf("You owe $%s.\n", humanize.Comma(6582491)) // You owe $6,582,491.
|
||||
```
|
||||
|
||||
## Ftoa
|
||||
|
||||
Nicer float64 formatter that removes trailing zeros.
|
||||
|
||||
```go
|
||||
fmt.Printf("%f", 2.24) // 2.240000
|
||||
fmt.Printf("%s", humanize.Ftoa(2.24)) // 2.24
|
||||
fmt.Printf("%f", 2.0) // 2.000000
|
||||
fmt.Printf("%s", humanize.Ftoa(2.0)) // 2
|
||||
```
|
||||
|
||||
## SI notation
|
||||
|
||||
Format numbers with [SI notation][sinotation].
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
humanize.SI(0.00000000223, "M") // 2.23 nM
|
||||
```
|
||||
|
||||
## English-specific functions
|
||||
|
||||
The following functions are in the `humanize/english` subpackage.
|
||||
|
||||
### Plurals
|
||||
|
||||
Simple English pluralization
|
||||
|
||||
```go
|
||||
english.PluralWord(1, "object", "") // object
|
||||
english.PluralWord(42, "object", "") // objects
|
||||
english.PluralWord(2, "bus", "") // buses
|
||||
english.PluralWord(99, "locus", "loci") // loci
|
||||
|
||||
english.Plural(1, "object", "") // 1 object
|
||||
english.Plural(42, "object", "") // 42 objects
|
||||
english.Plural(2, "bus", "") // 2 buses
|
||||
english.Plural(99, "locus", "loci") // 99 loci
|
||||
```
|
||||
|
||||
### Word series
|
||||
|
||||
Format comma-separated words lists with conjuctions:
|
||||
|
||||
```go
|
||||
english.WordSeries([]string{"foo"}, "and") // foo
|
||||
english.WordSeries([]string{"foo", "bar"}, "and") // foo and bar
|
||||
english.WordSeries([]string{"foo", "bar", "baz"}, "and") // foo, bar and baz
|
||||
|
||||
english.OxfordWordSeries([]string{"foo", "bar", "baz"}, "and") // foo, bar, and baz
|
||||
```
|
||||
|
||||
[odisc]: https://groups.google.com/d/topic/golang-nuts/l8NhI74jl-4/discussion
|
||||
[sinotation]: http://en.wikipedia.org/wiki/Metric_prefix
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// order of magnitude (to a max order)
|
||||
func oomm(n, b *big.Int, maxmag int) (float64, int) {
|
||||
mag := 0
|
||||
m := &big.Int{}
|
||||
for n.Cmp(b) >= 0 {
|
||||
n.DivMod(n, b, m)
|
||||
mag++
|
||||
if mag == maxmag && maxmag >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag
|
||||
}
|
||||
|
||||
// total order of magnitude
|
||||
// (same as above, but with no upper limit)
|
||||
func oom(n, b *big.Int) (float64, int) {
|
||||
mag := 0
|
||||
m := &big.Int{}
|
||||
for n.Cmp(b) >= 0 {
|
||||
n.DivMod(n, b, m)
|
||||
mag++
|
||||
}
|
||||
return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var (
|
||||
bigIECExp = big.NewInt(1024)
|
||||
|
||||
// BigByte is one byte in bit.Ints
|
||||
BigByte = big.NewInt(1)
|
||||
// BigKiByte is 1,024 bytes in bit.Ints
|
||||
BigKiByte = (&big.Int{}).Mul(BigByte, bigIECExp)
|
||||
// BigMiByte is 1,024 k bytes in bit.Ints
|
||||
BigMiByte = (&big.Int{}).Mul(BigKiByte, bigIECExp)
|
||||
// BigGiByte is 1,024 m bytes in bit.Ints
|
||||
BigGiByte = (&big.Int{}).Mul(BigMiByte, bigIECExp)
|
||||
// BigTiByte is 1,024 g bytes in bit.Ints
|
||||
BigTiByte = (&big.Int{}).Mul(BigGiByte, bigIECExp)
|
||||
// BigPiByte is 1,024 t bytes in bit.Ints
|
||||
BigPiByte = (&big.Int{}).Mul(BigTiByte, bigIECExp)
|
||||
// BigEiByte is 1,024 p bytes in bit.Ints
|
||||
BigEiByte = (&big.Int{}).Mul(BigPiByte, bigIECExp)
|
||||
// BigZiByte is 1,024 e bytes in bit.Ints
|
||||
BigZiByte = (&big.Int{}).Mul(BigEiByte, bigIECExp)
|
||||
// BigYiByte is 1,024 z bytes in bit.Ints
|
||||
BigYiByte = (&big.Int{}).Mul(BigZiByte, bigIECExp)
|
||||
// BigRiByte is 1,024 y bytes in bit.Ints
|
||||
BigRiByte = (&big.Int{}).Mul(BigYiByte, bigIECExp)
|
||||
// BigQiByte is 1,024 r bytes in bit.Ints
|
||||
BigQiByte = (&big.Int{}).Mul(BigRiByte, bigIECExp)
|
||||
)
|
||||
|
||||
var (
|
||||
bigSIExp = big.NewInt(1000)
|
||||
|
||||
// BigSIByte is one SI byte in big.Ints
|
||||
BigSIByte = big.NewInt(1)
|
||||
// BigKByte is 1,000 SI bytes in big.Ints
|
||||
BigKByte = (&big.Int{}).Mul(BigSIByte, bigSIExp)
|
||||
// BigMByte is 1,000 SI k bytes in big.Ints
|
||||
BigMByte = (&big.Int{}).Mul(BigKByte, bigSIExp)
|
||||
// BigGByte is 1,000 SI m bytes in big.Ints
|
||||
BigGByte = (&big.Int{}).Mul(BigMByte, bigSIExp)
|
||||
// BigTByte is 1,000 SI g bytes in big.Ints
|
||||
BigTByte = (&big.Int{}).Mul(BigGByte, bigSIExp)
|
||||
// BigPByte is 1,000 SI t bytes in big.Ints
|
||||
BigPByte = (&big.Int{}).Mul(BigTByte, bigSIExp)
|
||||
// BigEByte is 1,000 SI p bytes in big.Ints
|
||||
BigEByte = (&big.Int{}).Mul(BigPByte, bigSIExp)
|
||||
// BigZByte is 1,000 SI e bytes in big.Ints
|
||||
BigZByte = (&big.Int{}).Mul(BigEByte, bigSIExp)
|
||||
// BigYByte is 1,000 SI z bytes in big.Ints
|
||||
BigYByte = (&big.Int{}).Mul(BigZByte, bigSIExp)
|
||||
// BigRByte is 1,000 SI y bytes in big.Ints
|
||||
BigRByte = (&big.Int{}).Mul(BigYByte, bigSIExp)
|
||||
// BigQByte is 1,000 SI r bytes in big.Ints
|
||||
BigQByte = (&big.Int{}).Mul(BigRByte, bigSIExp)
|
||||
)
|
||||
|
||||
var bigBytesSizeTable = map[string]*big.Int{
|
||||
"b": BigByte,
|
||||
"kib": BigKiByte,
|
||||
"kb": BigKByte,
|
||||
"mib": BigMiByte,
|
||||
"mb": BigMByte,
|
||||
"gib": BigGiByte,
|
||||
"gb": BigGByte,
|
||||
"tib": BigTiByte,
|
||||
"tb": BigTByte,
|
||||
"pib": BigPiByte,
|
||||
"pb": BigPByte,
|
||||
"eib": BigEiByte,
|
||||
"eb": BigEByte,
|
||||
"zib": BigZiByte,
|
||||
"zb": BigZByte,
|
||||
"yib": BigYiByte,
|
||||
"yb": BigYByte,
|
||||
"rib": BigRiByte,
|
||||
"rb": BigRByte,
|
||||
"qib": BigQiByte,
|
||||
"qb": BigQByte,
|
||||
// Without suffix
|
||||
"": BigByte,
|
||||
"ki": BigKiByte,
|
||||
"k": BigKByte,
|
||||
"mi": BigMiByte,
|
||||
"m": BigMByte,
|
||||
"gi": BigGiByte,
|
||||
"g": BigGByte,
|
||||
"ti": BigTiByte,
|
||||
"t": BigTByte,
|
||||
"pi": BigPiByte,
|
||||
"p": BigPByte,
|
||||
"ei": BigEiByte,
|
||||
"e": BigEByte,
|
||||
"z": BigZByte,
|
||||
"zi": BigZiByte,
|
||||
"y": BigYByte,
|
||||
"yi": BigYiByte,
|
||||
"r": BigRByte,
|
||||
"ri": BigRiByte,
|
||||
"q": BigQByte,
|
||||
"qi": BigQiByte,
|
||||
}
|
||||
|
||||
var ten = big.NewInt(10)
|
||||
|
||||
func humanateBigBytes(s, base *big.Int, sizes []string) string {
|
||||
if s.Cmp(ten) < 0 {
|
||||
return fmt.Sprintf("%d B", s)
|
||||
}
|
||||
c := (&big.Int{}).Set(s)
|
||||
val, mag := oomm(c, base, len(sizes)-1)
|
||||
suffix := sizes[mag]
|
||||
f := "%.0f %s"
|
||||
if val < 10 {
|
||||
f = "%.1f %s"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(f, val, suffix)
|
||||
|
||||
}
|
||||
|
||||
// BigBytes produces a human readable representation of an SI size.
|
||||
//
|
||||
// See also: ParseBigBytes.
|
||||
//
|
||||
// BigBytes(82854982) -> 83 MB
|
||||
func BigBytes(s *big.Int) string {
|
||||
sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB", "RB", "QB"}
|
||||
return humanateBigBytes(s, bigSIExp, sizes)
|
||||
}
|
||||
|
||||
// BigIBytes produces a human readable representation of an IEC size.
|
||||
//
|
||||
// See also: ParseBigBytes.
|
||||
//
|
||||
// BigIBytes(82854982) -> 79 MiB
|
||||
func BigIBytes(s *big.Int) string {
|
||||
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "RiB", "QiB"}
|
||||
return humanateBigBytes(s, bigIECExp, sizes)
|
||||
}
|
||||
|
||||
// ParseBigBytes parses a string representation of bytes into the number
|
||||
// of bytes it represents.
|
||||
//
|
||||
// See also: BigBytes, BigIBytes.
|
||||
//
|
||||
// ParseBigBytes("42 MB") -> 42000000, nil
|
||||
// ParseBigBytes("42 mib") -> 44040192, nil
|
||||
func ParseBigBytes(s string) (*big.Int, error) {
|
||||
lastDigit := 0
|
||||
hasComma := false
|
||||
for _, r := range s {
|
||||
if !(unicode.IsDigit(r) || r == '.' || r == ',') {
|
||||
break
|
||||
}
|
||||
if r == ',' {
|
||||
hasComma = true
|
||||
}
|
||||
lastDigit++
|
||||
}
|
||||
|
||||
num := s[:lastDigit]
|
||||
if hasComma {
|
||||
num = strings.Replace(num, ",", "", -1)
|
||||
}
|
||||
|
||||
val := &big.Rat{}
|
||||
_, err := fmt.Sscanf(num, "%f", val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
extra := strings.ToLower(strings.TrimSpace(s[lastDigit:]))
|
||||
if m, ok := bigBytesSizeTable[extra]; ok {
|
||||
mv := (&big.Rat{}).SetInt(m)
|
||||
val.Mul(val, mv)
|
||||
rv := &big.Int{}
|
||||
rv.Div(val.Num(), val.Denom())
|
||||
return rv, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unhandled size name: %v", extra)
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// IEC Sizes.
|
||||
// kibis of bits
|
||||
const (
|
||||
Byte = 1 << (iota * 10)
|
||||
KiByte
|
||||
MiByte
|
||||
GiByte
|
||||
TiByte
|
||||
PiByte
|
||||
EiByte
|
||||
)
|
||||
|
||||
// SI Sizes.
|
||||
const (
|
||||
IByte = 1
|
||||
KByte = IByte * 1000
|
||||
MByte = KByte * 1000
|
||||
GByte = MByte * 1000
|
||||
TByte = GByte * 1000
|
||||
PByte = TByte * 1000
|
||||
EByte = PByte * 1000
|
||||
)
|
||||
|
||||
var bytesSizeTable = map[string]uint64{
|
||||
"b": Byte,
|
||||
"kib": KiByte,
|
||||
"kb": KByte,
|
||||
"mib": MiByte,
|
||||
"mb": MByte,
|
||||
"gib": GiByte,
|
||||
"gb": GByte,
|
||||
"tib": TiByte,
|
||||
"tb": TByte,
|
||||
"pib": PiByte,
|
||||
"pb": PByte,
|
||||
"eib": EiByte,
|
||||
"eb": EByte,
|
||||
// Without suffix
|
||||
"": Byte,
|
||||
"ki": KiByte,
|
||||
"k": KByte,
|
||||
"mi": MiByte,
|
||||
"m": MByte,
|
||||
"gi": GiByte,
|
||||
"g": GByte,
|
||||
"ti": TiByte,
|
||||
"t": TByte,
|
||||
"pi": PiByte,
|
||||
"p": PByte,
|
||||
"ei": EiByte,
|
||||
"e": EByte,
|
||||
}
|
||||
|
||||
func logn(n, b float64) float64 {
|
||||
return math.Log(n) / math.Log(b)
|
||||
}
|
||||
|
||||
func humanateBytes(s uint64, base float64, sizes []string) string {
|
||||
if s < 10 {
|
||||
return fmt.Sprintf("%d B", s)
|
||||
}
|
||||
e := math.Floor(logn(float64(s), base))
|
||||
suffix := sizes[int(e)]
|
||||
val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10
|
||||
f := "%.0f %s"
|
||||
if val < 10 {
|
||||
f = "%.1f %s"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(f, val, suffix)
|
||||
}
|
||||
|
||||
// Bytes produces a human readable representation of an SI size.
|
||||
//
|
||||
// See also: ParseBytes.
|
||||
//
|
||||
// Bytes(82854982) -> 83 MB
|
||||
func Bytes(s uint64) string {
|
||||
sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"}
|
||||
return humanateBytes(s, 1000, sizes)
|
||||
}
|
||||
|
||||
// IBytes produces a human readable representation of an IEC size.
|
||||
//
|
||||
// See also: ParseBytes.
|
||||
//
|
||||
// IBytes(82854982) -> 79 MiB
|
||||
func IBytes(s uint64) string {
|
||||
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
|
||||
return humanateBytes(s, 1024, sizes)
|
||||
}
|
||||
|
||||
// ParseBytes parses a string representation of bytes into the number
|
||||
// of bytes it represents.
|
||||
//
|
||||
// See Also: Bytes, IBytes.
|
||||
//
|
||||
// ParseBytes("42 MB") -> 42000000, nil
|
||||
// ParseBytes("42 mib") -> 44040192, nil
|
||||
func ParseBytes(s string) (uint64, error) {
|
||||
lastDigit := 0
|
||||
hasComma := false
|
||||
for _, r := range s {
|
||||
if !(unicode.IsDigit(r) || r == '.' || r == ',') {
|
||||
break
|
||||
}
|
||||
if r == ',' {
|
||||
hasComma = true
|
||||
}
|
||||
lastDigit++
|
||||
}
|
||||
|
||||
num := s[:lastDigit]
|
||||
if hasComma {
|
||||
num = strings.Replace(num, ",", "", -1)
|
||||
}
|
||||
|
||||
f, err := strconv.ParseFloat(num, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
extra := strings.ToLower(strings.TrimSpace(s[lastDigit:]))
|
||||
if m, ok := bytesSizeTable[extra]; ok {
|
||||
f *= float64(m)
|
||||
if f >= math.MaxUint64 {
|
||||
return 0, fmt.Errorf("too large: %v", s)
|
||||
}
|
||||
return uint64(f), nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("unhandled size name: %v", extra)
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Comma produces a string form of the given number in base 10 with
|
||||
// commas after every three orders of magnitude.
|
||||
//
|
||||
// e.g. Comma(834142) -> 834,142
|
||||
func Comma(v int64) string {
|
||||
sign := ""
|
||||
|
||||
// Min int64 can't be negated to a usable value, so it has to be special cased.
|
||||
if v == math.MinInt64 {
|
||||
return "-9,223,372,036,854,775,808"
|
||||
}
|
||||
|
||||
if v < 0 {
|
||||
sign = "-"
|
||||
v = 0 - v
|
||||
}
|
||||
|
||||
parts := []string{"", "", "", "", "", "", ""}
|
||||
j := len(parts) - 1
|
||||
|
||||
for v > 999 {
|
||||
parts[j] = strconv.FormatInt(v%1000, 10)
|
||||
switch len(parts[j]) {
|
||||
case 2:
|
||||
parts[j] = "0" + parts[j]
|
||||
case 1:
|
||||
parts[j] = "00" + parts[j]
|
||||
}
|
||||
v = v / 1000
|
||||
j--
|
||||
}
|
||||
parts[j] = strconv.Itoa(int(v))
|
||||
return sign + strings.Join(parts[j:], ",")
|
||||
}
|
||||
|
||||
// Commaf produces a string form of the given number in base 10 with
|
||||
// commas after every three orders of magnitude.
|
||||
//
|
||||
// e.g. Commaf(834142.32) -> 834,142.32
|
||||
func Commaf(v float64) string {
|
||||
buf := &bytes.Buffer{}
|
||||
if v < 0 {
|
||||
buf.Write([]byte{'-'})
|
||||
v = 0 - v
|
||||
}
|
||||
|
||||
comma := []byte{','}
|
||||
|
||||
parts := strings.Split(strconv.FormatFloat(v, 'f', -1, 64), ".")
|
||||
pos := 0
|
||||
if len(parts[0])%3 != 0 {
|
||||
pos += len(parts[0]) % 3
|
||||
buf.WriteString(parts[0][:pos])
|
||||
buf.Write(comma)
|
||||
}
|
||||
for ; pos < len(parts[0]); pos += 3 {
|
||||
buf.WriteString(parts[0][pos : pos+3])
|
||||
buf.Write(comma)
|
||||
}
|
||||
buf.Truncate(buf.Len() - 1)
|
||||
|
||||
if len(parts) > 1 {
|
||||
buf.Write([]byte{'.'})
|
||||
buf.WriteString(parts[1])
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// CommafWithDigits works like the Commaf but limits the resulting
|
||||
// string to the given number of decimal places.
|
||||
//
|
||||
// e.g. CommafWithDigits(834142.32, 1) -> 834,142.3
|
||||
func CommafWithDigits(f float64, decimals int) string {
|
||||
return stripTrailingDigits(Commaf(f), decimals)
|
||||
}
|
||||
|
||||
// BigComma produces a string form of the given big.Int in base 10
|
||||
// with commas after every three orders of magnitude.
|
||||
func BigComma(b *big.Int) string {
|
||||
sign := ""
|
||||
if b.Sign() < 0 {
|
||||
sign = "-"
|
||||
b.Abs(b)
|
||||
}
|
||||
|
||||
athousand := big.NewInt(1000)
|
||||
c := (&big.Int{}).Set(b)
|
||||
_, m := oom(c, athousand)
|
||||
parts := make([]string, m+1)
|
||||
j := len(parts) - 1
|
||||
|
||||
mod := &big.Int{}
|
||||
for b.Cmp(athousand) >= 0 {
|
||||
b.DivMod(b, athousand, mod)
|
||||
parts[j] = strconv.FormatInt(mod.Int64(), 10)
|
||||
switch len(parts[j]) {
|
||||
case 2:
|
||||
parts[j] = "0" + parts[j]
|
||||
case 1:
|
||||
parts[j] = "00" + parts[j]
|
||||
}
|
||||
j--
|
||||
}
|
||||
parts[j] = strconv.Itoa(int(b.Int64()))
|
||||
return sign + strings.Join(parts[j:], ",")
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
//go:build go1.6
|
||||
// +build go1.6
|
||||
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BigCommaf produces a string form of the given big.Float in base 10
|
||||
// with commas after every three orders of magnitude.
|
||||
func BigCommaf(v *big.Float) string {
|
||||
buf := &bytes.Buffer{}
|
||||
if v.Sign() < 0 {
|
||||
buf.Write([]byte{'-'})
|
||||
v.Abs(v)
|
||||
}
|
||||
|
||||
comma := []byte{','}
|
||||
|
||||
parts := strings.Split(v.Text('f', -1), ".")
|
||||
pos := 0
|
||||
if len(parts[0])%3 != 0 {
|
||||
pos += len(parts[0]) % 3
|
||||
buf.WriteString(parts[0][:pos])
|
||||
buf.Write(comma)
|
||||
}
|
||||
for ; pos < len(parts[0]); pos += 3 {
|
||||
buf.WriteString(parts[0][pos : pos+3])
|
||||
buf.Write(comma)
|
||||
}
|
||||
buf.Truncate(buf.Len() - 1)
|
||||
|
||||
if len(parts) > 1 {
|
||||
buf.Write([]byte{'.'})
|
||||
buf.WriteString(parts[1])
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func stripTrailingZeros(s string) string {
|
||||
if !strings.ContainsRune(s, '.') {
|
||||
return s
|
||||
}
|
||||
offset := len(s) - 1
|
||||
for offset > 0 {
|
||||
if s[offset] == '.' {
|
||||
offset--
|
||||
break
|
||||
}
|
||||
if s[offset] != '0' {
|
||||
break
|
||||
}
|
||||
offset--
|
||||
}
|
||||
return s[:offset+1]
|
||||
}
|
||||
|
||||
func stripTrailingDigits(s string, digits int) string {
|
||||
if i := strings.Index(s, "."); i >= 0 {
|
||||
if digits <= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
i++
|
||||
if i+digits >= len(s) {
|
||||
return s
|
||||
}
|
||||
return s[:i+digits]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Ftoa converts a float to a string with no trailing zeros.
|
||||
func Ftoa(num float64) string {
|
||||
return stripTrailingZeros(strconv.FormatFloat(num, 'f', 6, 64))
|
||||
}
|
||||
|
||||
// FtoaWithDigits converts a float to a string but limits the resulting string
|
||||
// to the given number of decimal places, and no trailing zeros.
|
||||
func FtoaWithDigits(num float64, digits int) string {
|
||||
return stripTrailingZeros(stripTrailingDigits(strconv.FormatFloat(num, 'f', 6, 64), digits))
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Package humanize converts boring ugly numbers to human-friendly strings and back.
|
||||
|
||||
Durations can be turned into strings such as "3 days ago", numbers
|
||||
representing sizes like 82854982 into useful strings like, "83 MB" or
|
||||
"79 MiB" (whichever you prefer).
|
||||
*/
|
||||
package humanize
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
package humanize
|
||||
|
||||
/*
|
||||
Slightly adapted from the source to fit go-humanize.
|
||||
|
||||
Author: https://github.com/gorhill
|
||||
Source: https://gist.github.com/gorhill/5285193
|
||||
|
||||
*/
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var (
|
||||
renderFloatPrecisionMultipliers = [...]float64{
|
||||
1,
|
||||
10,
|
||||
100,
|
||||
1000,
|
||||
10000,
|
||||
100000,
|
||||
1000000,
|
||||
10000000,
|
||||
100000000,
|
||||
1000000000,
|
||||
}
|
||||
|
||||
renderFloatPrecisionRounders = [...]float64{
|
||||
0.5,
|
||||
0.05,
|
||||
0.005,
|
||||
0.0005,
|
||||
0.00005,
|
||||
0.000005,
|
||||
0.0000005,
|
||||
0.00000005,
|
||||
0.000000005,
|
||||
0.0000000005,
|
||||
}
|
||||
)
|
||||
|
||||
// FormatFloat produces a formatted number as string based on the following user-specified criteria:
|
||||
// * thousands separator
|
||||
// * decimal separator
|
||||
// * decimal precision
|
||||
//
|
||||
// Usage: s := RenderFloat(format, n)
|
||||
// The format parameter tells how to render the number n.
|
||||
//
|
||||
// See examples: http://play.golang.org/p/LXc1Ddm1lJ
|
||||
//
|
||||
// Examples of format strings, given n = 12345.6789:
|
||||
// "#,###.##" => "12,345.67"
|
||||
// "#,###." => "12,345"
|
||||
// "#,###" => "12345,678"
|
||||
// "#\u202F###,##" => "12โฏ345,68"
|
||||
// "#.###,###### => 12.345,678900
|
||||
// "" (aka default format) => 12,345.67
|
||||
//
|
||||
// The highest precision allowed is 9 digits after the decimal symbol.
|
||||
// There is also a version for integer number, FormatInteger(),
|
||||
// which is convenient for calls within template.
|
||||
func FormatFloat(format string, n float64) string {
|
||||
// Special cases:
|
||||
// NaN = "NaN"
|
||||
// +Inf = "+Infinity"
|
||||
// -Inf = "-Infinity"
|
||||
if math.IsNaN(n) {
|
||||
return "NaN"
|
||||
}
|
||||
if n > math.MaxFloat64 {
|
||||
return "Infinity"
|
||||
}
|
||||
if n < (0.0 - math.MaxFloat64) {
|
||||
return "-Infinity"
|
||||
}
|
||||
|
||||
// default format
|
||||
precision := 2
|
||||
decimalStr := "."
|
||||
thousandStr := ","
|
||||
positiveStr := ""
|
||||
negativeStr := "-"
|
||||
|
||||
if len(format) > 0 {
|
||||
format := []rune(format)
|
||||
|
||||
// If there is an explicit format directive,
|
||||
// then default values are these:
|
||||
precision = 9
|
||||
thousandStr = ""
|
||||
|
||||
// collect indices of meaningful formatting directives
|
||||
formatIndx := []int{}
|
||||
for i, char := range format {
|
||||
if char != '#' && char != '0' {
|
||||
formatIndx = append(formatIndx, i)
|
||||
}
|
||||
}
|
||||
|
||||
if len(formatIndx) > 0 {
|
||||
// Directive at index 0:
|
||||
// Must be a '+'
|
||||
// Raise an error if not the case
|
||||
// index: 0123456789
|
||||
// +0.000,000
|
||||
// +000,000.0
|
||||
// +0000.00
|
||||
// +0000
|
||||
if formatIndx[0] == 0 {
|
||||
if format[formatIndx[0]] != '+' {
|
||||
panic("RenderFloat(): invalid positive sign directive")
|
||||
}
|
||||
positiveStr = "+"
|
||||
formatIndx = formatIndx[1:]
|
||||
}
|
||||
|
||||
// Two directives:
|
||||
// First is thousands separator
|
||||
// Raise an error if not followed by 3-digit
|
||||
// 0123456789
|
||||
// 0.000,000
|
||||
// 000,000.00
|
||||
if len(formatIndx) == 2 {
|
||||
if (formatIndx[1] - formatIndx[0]) != 4 {
|
||||
panic("RenderFloat(): thousands separator directive must be followed by 3 digit-specifiers")
|
||||
}
|
||||
thousandStr = string(format[formatIndx[0]])
|
||||
formatIndx = formatIndx[1:]
|
||||
}
|
||||
|
||||
// One directive:
|
||||
// Directive is decimal separator
|
||||
// The number of digit-specifier following the separator indicates wanted precision
|
||||
// 0123456789
|
||||
// 0.00
|
||||
// 000,0000
|
||||
if len(formatIndx) == 1 {
|
||||
decimalStr = string(format[formatIndx[0]])
|
||||
precision = len(format) - formatIndx[0] - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generate sign part
|
||||
var signStr string
|
||||
if n >= 0.000000001 {
|
||||
signStr = positiveStr
|
||||
} else if n <= -0.000000001 {
|
||||
signStr = negativeStr
|
||||
n = -n
|
||||
} else {
|
||||
signStr = ""
|
||||
n = 0.0
|
||||
}
|
||||
|
||||
// split number into integer and fractional parts
|
||||
intf, fracf := math.Modf(n + renderFloatPrecisionRounders[precision])
|
||||
|
||||
// generate integer part string
|
||||
intStr := strconv.FormatInt(int64(intf), 10)
|
||||
|
||||
// add thousand separator if required
|
||||
if len(thousandStr) > 0 {
|
||||
for i := len(intStr); i > 3; {
|
||||
i -= 3
|
||||
intStr = intStr[:i] + thousandStr + intStr[i:]
|
||||
}
|
||||
}
|
||||
|
||||
// no fractional part, we can leave now
|
||||
if precision == 0 {
|
||||
return signStr + intStr
|
||||
}
|
||||
|
||||
// generate fractional part
|
||||
fracStr := strconv.Itoa(int(fracf * renderFloatPrecisionMultipliers[precision]))
|
||||
// may need padding
|
||||
if len(fracStr) < precision {
|
||||
fracStr = "000000000000000"[:precision-len(fracStr)] + fracStr
|
||||
}
|
||||
|
||||
return signStr + intStr + decimalStr + fracStr
|
||||
}
|
||||
|
||||
// FormatInteger produces a formatted number as string.
|
||||
// See FormatFloat.
|
||||
func FormatInteger(format string, n int) string {
|
||||
return FormatFloat(format, float64(n))
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package humanize
|
||||
|
||||
import "strconv"
|
||||
|
||||
// Ordinal gives you the input number in a rank/ordinal format.
|
||||
//
|
||||
// Ordinal(3) -> 3rd
|
||||
func Ordinal(x int) string {
|
||||
suffix := "th"
|
||||
switch x % 10 {
|
||||
case 1:
|
||||
if x%100 != 11 {
|
||||
suffix = "st"
|
||||
}
|
||||
case 2:
|
||||
if x%100 != 12 {
|
||||
suffix = "nd"
|
||||
}
|
||||
case 3:
|
||||
if x%100 != 13 {
|
||||
suffix = "rd"
|
||||
}
|
||||
}
|
||||
return strconv.Itoa(x) + suffix
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var siPrefixTable = map[float64]string{
|
||||
-30: "q", // quecto
|
||||
-27: "r", // ronto
|
||||
-24: "y", // yocto
|
||||
-21: "z", // zepto
|
||||
-18: "a", // atto
|
||||
-15: "f", // femto
|
||||
-12: "p", // pico
|
||||
-9: "n", // nano
|
||||
-6: "ยต", // micro
|
||||
-3: "m", // milli
|
||||
0: "",
|
||||
3: "k", // kilo
|
||||
6: "M", // mega
|
||||
9: "G", // giga
|
||||
12: "T", // tera
|
||||
15: "P", // peta
|
||||
18: "E", // exa
|
||||
21: "Z", // zetta
|
||||
24: "Y", // yotta
|
||||
27: "R", // ronna
|
||||
30: "Q", // quetta
|
||||
}
|
||||
|
||||
var revSIPrefixTable = revfmap(siPrefixTable)
|
||||
|
||||
// revfmap reverses the map and precomputes the power multiplier
|
||||
func revfmap(in map[float64]string) map[string]float64 {
|
||||
rv := map[string]float64{}
|
||||
for k, v := range in {
|
||||
rv[v] = math.Pow(10, k)
|
||||
}
|
||||
return rv
|
||||
}
|
||||
|
||||
var riParseRegex *regexp.Regexp
|
||||
|
||||
func init() {
|
||||
ri := `^([\-0-9.]+)\s?([`
|
||||
for _, v := range siPrefixTable {
|
||||
ri += v
|
||||
}
|
||||
ri += `]?)(.*)`
|
||||
|
||||
riParseRegex = regexp.MustCompile(ri)
|
||||
}
|
||||
|
||||
// ComputeSI finds the most appropriate SI prefix for the given number
|
||||
// and returns the prefix along with the value adjusted to be within
|
||||
// that prefix.
|
||||
//
|
||||
// See also: SI, ParseSI.
|
||||
//
|
||||
// e.g. ComputeSI(2.2345e-12) -> (2.2345, "p")
|
||||
func ComputeSI(input float64) (float64, string) {
|
||||