183 lines
5.3 KiB
Go
183 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
|
|
"felis.lolicon.best/internal/cfsetup"
|
|
"felis.lolicon.best/internal/config"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
)
|
|
|
|
const podSetupConfigPath = "/etc/felis/felis.pod.toml"
|
|
const cloudflaredFelisUnit = "/etc/systemd/system/cloudflared-felis.service"
|
|
|
|
func applyCloudflareEdge(ctx context.Context, result *cfsetup.Result, panelHost, adminHost, cloudflaredBin string) error {
|
|
if result == nil || result.AccessAud == "" {
|
|
return fmt.Errorf("edge result did not include an Access audience")
|
|
}
|
|
if adminHost == "" {
|
|
return fmt.Errorf("admin hostname is required")
|
|
}
|
|
if err := writeConnectionConfig(panelHost, adminHost, result.AccessAud); err != nil {
|
|
return err
|
|
}
|
|
if err := applyFelisConfigSecret(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := kubectl(ctx, "-n", "felis", "rollout", "restart", "deployment/felis-api"); err != nil {
|
|
return err
|
|
}
|
|
if err := installCloudflaredService(ctx, cloudflaredBin, result.ConfigPath); err != nil {
|
|
return err
|
|
}
|
|
return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s")
|
|
}
|
|
|
|
// applyReverseProxy records the operator's chosen public hostnames and rolls the
|
|
// API so the panel serves them. No Access audience is set: the admin console is
|
|
// gated by the Owner's local-password session, and the operator's own reverse
|
|
// proxy (Caddy/nginx/Traefik/…) terminates TLS in front of the NodePort origin.
|
|
func applyReverseProxy(ctx context.Context, panelHost, adminHost string) error {
|
|
if adminHost == "" {
|
|
return fmt.Errorf("admin hostname is required")
|
|
}
|
|
if err := writeConnectionConfig(panelHost, adminHost, ""); err != nil {
|
|
return err
|
|
}
|
|
if err := applyFelisConfigSecret(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := kubectl(ctx, "-n", "felis", "rollout", "restart", "deployment/felis-api"); err != nil {
|
|
return err
|
|
}
|
|
return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s")
|
|
}
|
|
|
|
// writeConnectionConfig stamps the chosen hostnames (and optional Access audience)
|
|
// into both the host and pod config files. An empty aud clears any prior
|
|
// Cloudflare audience, which is correct when switching to a non-Access front.
|
|
func writeConnectionConfig(panelHost, adminHost, aud string) error {
|
|
for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} {
|
|
if err := updateAuthConfig(path, panelHost, adminHost, aud); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func updateAuthConfig(path, panelHost, adminHost, aud string) error {
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if panelHost != "" {
|
|
cfg.Auth.PanelHostname = panelHost
|
|
}
|
|
cfg.Auth.AdminHostname = adminHost
|
|
cfg.Auth.AccessJWTAud = aud
|
|
return writeConfig(path, cfg)
|
|
}
|
|
|
|
func writeConfig(path string, cfg *config.Config) error {
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".felis-*.toml")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer os.Remove(tmpPath)
|
|
|
|
if err := toml.NewEncoder(tmp).Encode(cfg); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmpPath, path)
|
|
}
|
|
|
|
func applyFelisConfigSecret(ctx context.Context) error {
|
|
out, err := kubectlOutput(ctx,
|
|
"-n", "felis", "create", "secret", "generic", "felis-config",
|
|
"--from-file=felis.toml="+podSetupConfigPath,
|
|
"--dry-run=client", "-o", "yaml",
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return kubectlWithInput(ctx, out, "apply", "-f", "-")
|
|
}
|
|
|
|
func installCloudflaredService(ctx context.Context, cloudflaredBin, configPath string) error {
|
|
if cloudflaredBin == "" {
|
|
return fmt.Errorf("cloudflared binary path is empty")
|
|
}
|
|
if configPath == "" {
|
|
return fmt.Errorf("cloudflared config path is empty")
|
|
}
|
|
unit := fmt.Sprintf(`[Unit]
|
|
Description=Felis Cloudflare Tunnel
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
ExecStart=%s --config %s tunnel run
|
|
Restart=on-failure
|
|
RestartSec=5s
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`, cloudflaredBin, configPath)
|
|
if err := os.WriteFile(cloudflaredFelisUnit, []byte(unit), 0o644); err != nil {
|
|
return err
|
|
}
|
|
if err := systemctl(ctx, "daemon-reload"); err != nil {
|
|
return err
|
|
}
|
|
return systemctl(ctx, "enable", "--now", "cloudflared-felis.service")
|
|
}
|
|
|
|
func kubectl(ctx context.Context, args ...string) error {
|
|
_, err := kubectlOutput(ctx, args...)
|
|
return err
|
|
}
|
|
|
|
func kubectlWithInput(ctx context.Context, input []byte, args ...string) error {
|
|
_, err := runK3sKubectl(ctx, input, args...)
|
|
return err
|
|
}
|
|
|
|
func kubectlOutput(ctx context.Context, args ...string) ([]byte, error) {
|
|
return runK3sKubectl(ctx, nil, args...)
|
|
}
|
|
|
|
func runK3sKubectl(ctx context.Context, input []byte, args ...string) ([]byte, error) {
|
|
fullArgs := append([]string{"kubectl"}, args...)
|
|
cmd := exec.CommandContext(ctx, "k3s", fullArgs...)
|
|
cmd.Env = append(os.Environ(), "KUBECONFIG="+hostBootstrapKubeconfigPath)
|
|
if input != nil {
|
|
cmd.Stdin = bytes.NewReader(input)
|
|
}
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("k3s %v: %w: %s", fullArgs, err, string(out))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func systemctl(ctx context.Context, args ...string) error {
|
|
cmd := exec.CommandContext(ctx, "systemctl", args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("systemctl %v: %w: %s", args, err, string(out))
|
|
}
|
|
return nil
|
|
}
|