Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement custom domain #6

Merged
merged 6 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ PAGESHIP_TOKEN_AUTHORITY=http://api.localtest.me:8001
PAGESHIP_CLEANUP_EXPIRED_CRONTAB=* * * * *
# PAGESHIP_HOST_ID_SCHEME=suffix

# PAGESHIP_CUSTOM_DOMAIN_MESSAGE=
31 changes: 22 additions & 9 deletions cmd/controller/app/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import (
"os"
"time"

"github.com/carlmjohnson/versioninfo"
"github.com/dustin/go-humanize"
"github.com/oursky/pageship/internal/command"
"github.com/oursky/pageship/internal/config"
"github.com/oursky/pageship/internal/cron"
"github.com/oursky/pageship/internal/db"
_ "github.com/oursky/pageship/internal/db/postgres"
_ "github.com/oursky/pageship/internal/db/sqlite"
domaindb "github.com/oursky/pageship/internal/domain/db"
"github.com/oursky/pageship/internal/handler/controller"
"github.com/oursky/pageship/internal/handler/site"
"github.com/oursky/pageship/internal/handler/site/middleware"
Expand Down Expand Up @@ -58,6 +60,8 @@ func init() {
startCmd.PersistentFlags().String("token-authority", "pageship", "auth token authority")
startCmd.PersistentFlags().String("token-signing-key", "", "auth token signing key")

startCmd.PersistentFlags().String("custom-domain-message", "", "message for custom domain users")

startCmd.PersistentFlags().String("cleanup-expired-crontab", "", "cleanup expired schedule")
startCmd.PersistentFlags().Duration("keep-after-expired", time.Hour*24, "keep-after-expired")

Expand Down Expand Up @@ -100,6 +104,8 @@ type StartControllerConfig struct {
TokenAuthority string `mapstructure:"token-authority"`
ReservedApps []string `mapstructure:"reserved-apps"`
APIACLFile string `mapstructure:"api-acl" validate:"omitempty,filepath"`

CustomDomainMessage string `mapstructure:"custom-domain-message"`
}

type StartCronConfig struct {
Expand Down Expand Up @@ -128,15 +134,20 @@ func (s *setup) checkDomain(name string) error {
}

func (s *setup) sites(conf StartSitesConfig) error {
resolver := &sitedb.Resolver{
domainResolver := &domaindb.Resolver{
HostIDScheme: conf.HostIDScheme,
DB: s.database,
}
siteResolver := &sitedb.Resolver{
HostIDScheme: conf.HostIDScheme,
DB: s.database,
Storage: s.storage,
}
handler, err := site.NewHandler(
s.ctx,
logger.Named("site"),
resolver,
domainResolver,
siteResolver,
site.HandlerConfig{
HostPattern: conf.HostPattern,
Middlewares: middleware.Default,
Expand Down Expand Up @@ -165,13 +176,15 @@ func (s *setup) controller(domain string, conf StartControllerConfig, sitesConf
}

controllerConf := controller.Config{
MaxDeploymentSize: int64(maxDeploymentSize),
StorageKeyPrefix: conf.StorageKeyPrefix,
HostIDScheme: sitesConf.HostIDScheme,
HostPattern: config.NewHostPattern(sitesConf.HostPattern),
ReservedApps: reservedApps,
TokenSigningKey: []byte(tokenSigningKey),
TokenAuthority: conf.TokenAuthority,
MaxDeploymentSize: int64(maxDeploymentSize),
StorageKeyPrefix: conf.StorageKeyPrefix,
HostIDScheme: sitesConf.HostIDScheme,
HostPattern: config.NewHostPattern(sitesConf.HostPattern),
ReservedApps: reservedApps,
TokenSigningKey: []byte(tokenSigningKey),
TokenAuthority: conf.TokenAuthority,
ServerVersion: versioninfo.Short(),
CustomDomainMessage: conf.CustomDomainMessage,
}

if conf.APIACLFile != "" {
Expand Down
12 changes: 12 additions & 0 deletions cmd/pageship/app/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,23 @@ var appsConfigureCmd = &cobra.Command{
return fmt.Errorf("failed to get app: %w", err)
}

oldConfig := app.Config

app, err = API().ConfigureApp(cmd.Context(), app.ID, &conf.App)
if err != nil {
return fmt.Errorf("failed to configure app: %w", err)
}

for _, dconf := range conf.App.Domains {
if _, exists := oldConfig.ResolveDomain(dconf.Domain); !exists {
Info("Activating custom domain %q...", dconf.Domain)
_, err = API().CreateDomain(cmd.Context(), app.ID, dconf.Domain, "")
if err != nil {
Warn("Activation of custom domain %q failed: %s", dconf.Domain, err)
}
}
}

Info("Configured app %q.", app.ID)
return nil
},
Expand Down
205 changes: 205 additions & 0 deletions cmd/pageship/app/domains.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package app

import (
"context"
"fmt"
"net/http"
"os"
"text/tabwriter"
"time"

"github.com/manifoldco/promptui"
"github.com/oursky/pageship/internal/api"
"github.com/oursky/pageship/internal/models"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

func init() {
rootCmd.AddCommand(domainsCmd)
domainsCmd.PersistentFlags().String("app", "", "app ID")

domainsCmd.AddCommand(domainsActivateCmd)
domainsCmd.AddCommand(domainsDeactivateCmd)
}

var domainsCmd = &cobra.Command{
Use: "domains",
Short: "Manage custom domains",
RunE: func(cmd *cobra.Command, args []string) error {
appID := viper.GetString("app")
if appID == "" {
appID = tryLoadAppID()
}
if appID == "" {
return fmt.Errorf("app ID is not set")
}

manifest, err := API().GetManifest(cmd.Context())
if err != nil {
return fmt.Errorf("failed to get manifest: %w", err)
}

app, err := API().GetApp(cmd.Context(), appID)
if err != nil {
return fmt.Errorf("failed to get app: %w", err)
}

type domainEntry struct {
name string
site string
model *api.APIDomain
}
domains := map[string]domainEntry{}
for _, dconf := range app.Config.Domains {
domains[dconf.Domain] = domainEntry{
name: dconf.Domain,
site: dconf.Site,
model: nil,
}
}

apiDomains, err := API().ListDomains(cmd.Context(), appID)
if err != nil {
return fmt.Errorf("failed to list domains: %w", err)
}

for _, d := range apiDomains {
dd := d
domains[d.Domain.Domain] = domainEntry{
name: d.Domain.Domain,
site: d.Domain.SiteName,
model: &dd,
}
}

w := tabwriter.NewWriter(os.Stdout, 1, 4, 4, ' ', 0)
fmt.Fprintln(w, "NAME\tSITE\tCREATED AT\tSTATUS")
for _, domain := range domains {
createdAt := "-"
site := "-"
if domain.model != nil {
createdAt = domain.model.CreatedAt.Local().Format(time.DateTime)
site = fmt.Sprintf("%s/%s", domain.model.AppID, domain.model.SiteName)
} else {
site = fmt.Sprintf("%s/%s", app.ID, domain.site)
}

var status string
switch {
case domain.model != nil && domain.model.AppID != app.ID:
status = "IN_USE"
case domain.model != nil && domain.model.AppID == app.ID:
status = "ACTIVE"
default:
status = "INACTIVE"
}

fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", domain.name, site, createdAt, status)
}
w.Flush()

if manifest.CustomDomainMessage != "" {
os.Stdout.WriteString("\n")
Info(manifest.CustomDomainMessage)
}

return nil
},
}

func promptDomainReplaceApp(ctx context.Context, appID string, domainName string) (replaceApp string, err error) {
domains, err := API().ListDomains(ctx, appID)
if err != nil {
return "", fmt.Errorf("failed list domain: %w", err)
}

appID = ""
for _, d := range domains {
if d.Domain.Domain == domainName {
appID = d.AppID
}
}

if appID == "" {
return "", models.ErrDomainUsedName
}

label := fmt.Sprintf("Domain %q is in use by app %q; activates the domain anyways", domainName, appID)

prompt := promptui.Prompt{Label: label, IsConfirm: true}
_, err = prompt.Run()
if err != nil {
Info("Cancelled.")
return "", ErrCancelled
}

return appID, nil
}

var domainsActivateCmd = &cobra.Command{
Use: "activate",
Short: "Activate domain for the app",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
domainName := args[0]

appID := viper.GetString("app")
if appID == "" {
appID = tryLoadAppID()
}
if appID == "" {
return fmt.Errorf("app ID is not set")
}

app, err := API().GetApp(cmd.Context(), appID)
if err != nil {
return fmt.Errorf("failed to get app: %w", err)
}
if _, ok := app.Config.ResolveDomain(domainName); !ok {
return fmt.Errorf("undefined domain")
}

_, err = API().CreateDomain(cmd.Context(), appID, domainName, "")
if code, ok := api.ErrorStatusCode(err); ok && code == http.StatusConflict {
var replaceApp string
replaceApp, err = promptDomainReplaceApp(cmd.Context(), appID, domainName)
if err != nil {
return err
}
_, err = API().CreateDomain(cmd.Context(), appID, domainName, replaceApp)
}

if err != nil {
return fmt.Errorf("failed to create domain: %w", err)
}

Info("Domain %q activated.", domainName)
return nil
},
}

var domainsDeactivateCmd = &cobra.Command{
Use: "deactivate",
Short: "Deactivate domain for the app",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
domainName := args[0]

appID := viper.GetString("app")
if appID == "" {
appID = tryLoadAppID()
}
if appID == "" {
return fmt.Errorf("app ID is not set")
}

_, err := API().DeleteDomain(cmd.Context(), appID, domainName)
if err != nil {
return fmt.Errorf("failed to delete domain: %w", err)
}

Info("Domain %q deactivated.", domainName)
return nil
},
}
32 changes: 21 additions & 11 deletions cmd/pageship/app/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import (
"github.com/caddyserver/certmagic"
"github.com/oursky/pageship/internal/command"
"github.com/oursky/pageship/internal/config"
"github.com/oursky/pageship/internal/domain"
domainlocal "github.com/oursky/pageship/internal/domain/local"
handler "github.com/oursky/pageship/internal/handler/site"
"github.com/oursky/pageship/internal/handler/site/middleware"
"github.com/oursky/pageship/internal/httputil"
"github.com/oursky/pageship/internal/site"
"github.com/oursky/pageship/internal/site/local"
sitelocal "github.com/oursky/pageship/internal/site/local"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
Expand Down Expand Up @@ -53,11 +55,13 @@ func makeHandler(prefix string, defaultSite string, hostPattern string) (*handle

fsys := os.DirFS(dir)

var resolver site.Resolver
resolver = local.NewSingleSiteResolver(fsys)
var siteResolver site.Resolver
siteResolver = sitelocal.NewSingleSiteResolver(fsys)
var domainResolver domain.Resolver
domainResolver = &domain.ResolverNull{}

// Check site on startup.
_, err = resolver.Resolve(context.Background(), defaultSite)
_, err = siteResolver.Resolve(context.Background(), defaultSite)
if errors.Is(err, config.ErrConfigNotFound) {
// continue in multi-site mode

Expand All @@ -72,17 +76,23 @@ func makeHandler(prefix string, defaultSite string, hostPattern string) (*handle
if sitesConf != nil {
sites = sitesConf.Sites
}
resolver = local.NewMultiSiteResolver(fsys, defaultSite, sites)
siteResolver = sitelocal.NewResolver(fsys, defaultSite, sites)
domainResolver, err = domainlocal.NewResolver(defaultSite, sites)
if err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}

Info("site resolution mode: %s", resolver.Kind())
Info("site resolution mode: %s", siteResolver.Kind())

handler, err := handler.NewHandler(context.Background(), zapLogger, resolver, handler.HandlerConfig{
HostPattern: hostPattern,
Middlewares: middleware.Default,
})
handler, err := handler.NewHandler(context.Background(), zapLogger,
domainResolver, siteResolver,
handler.HandlerConfig{
HostPattern: hostPattern,
Middlewares: middleware.Default,
})
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -127,7 +137,7 @@ var serveCmd = &cobra.Command{

if len(tlsDomain) > 0 {
tls.DomainNames = []string{tlsDomain}
} else if handler.AllowAnyDomain() {
} else if handler.AcceptsAllDomain() {
return fmt.Errorf("must provide domain name via --tls-domain to enable TLS")
}
}
Expand Down
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- [Automatic TLS](guides/features/automatic-tls.md)
- [GitHub Actions Integration](guides/features/github-actions-integration.md)
- [Access Control](guides/features/access-control.md)
- [Custom Domain](guides/features/custom-domain.md)

# References

Expand Down
Loading