Skip to content

Instantly share code, notes, and snippets.

@mindon
Last active March 22, 2023 02:19
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mindon/e39f55e55dd7237b7bd199acfccb7554 to your computer and use it in GitHub Desktop.
Save mindon/e39f55e55dd7237b7bd199acfccb7554 to your computer and use it in GitHub Desktop.
Simple command tool to start|stop|reload CaddyServer in current directory without merging all configs into one (for Caddy2+)
package main
// cadder to start|stop|reload simple caddy servers in current directory, without merge all configs into one
// example: /site0/Caddyfile, /site1/caddy.json, `cd /site0/ && cadder start` then `cd /site1/ && cadder start``
//
// How to build cadder?
// you need download and install golang from <https://go.dev/dl/> then `go build cadder.go`
//
// author: mindon@live.com
// created: 2022-07-14
// updated: 2022-11-01
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
)
var ver = "0.2"
var major, midor, minor = 0, 0, 0
var caddybin = "caddy"
var caddyadmin = "http://localhost:2019"
var home, _ = os.UserHomeDir()
var winstyle = regexp.MustCompile(`^[a-zA-Z]:[\/\\]`)
var winos = runtime.GOOS == "windows"
var cachePath, _ = filepath.Abs(filepath.Join(home, ".cadder_for_caddy"))
var cadderCache = map[string]map[string]interface{}{}
var caddyver = ""
var abs = map[string]*regexp.Regexp{
"root": regexp.MustCompile(`("root":")([^/][^"]+)(")`),
"log": regexp.MustCompile(`("filename":")([^/][^"]+)(")`),
"hide": regexp.MustCompile(`("hide":\[")([^/][^"]+)(")`),
}
// load cache
func init() {
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
return
}
body, err := os.ReadFile(cachePath)
if err != nil {
return
}
json.Unmarshal(body, &cadderCache)
}
func main() {
which := "which"
if winos {
which = "where"
}
_, err := exec.Command(which, "caddy").Output()
if err != nil {
caddybin = os.Getenv("CADDY_BIN")
if len(caddybin) > 0 {
_, err = os.Stat(caddybin)
if !os.IsNotExist(err) {
err = nil
}
}
if err != nil {
log.Fatal("caddy server (https://caddyserver.com/) not install properly")
}
}
v, err := exec.Command(caddybin, "version").Output()
v = bytes.Trim(v, " \t\r\n")
if err != nil || len(v) < 2 {
log.Fatal(err)
}
i := bytes.Index(v, []byte(" "))
if i > 0 {
v = v[1:i]
vl := strings.Split(string(v), ".")
if len(vl) >= 3 {
major, _ = strconv.Atoi(vl[0])
midor, _ = strconv.Atoi(vl[1])
minor, _ = strconv.Atoi(string(regexp.MustCompile(`[^\d]+`).ReplaceAll([]byte(vl[2]), []byte{})))
}
}
if major < 2 {
log.Fatal("caddy2+ required")
}
if len(os.Args) < 2 {
fmt.Printf("cadder v%s (usage: cadder start|stop|reload|status), caddy v%s\n\n", ver, v)
current()
return
}
cmd := os.Args[1]
if cmd == "start" {
_, err := start()
if err != nil {
fmt.Println(err)
}
return
}
if cmd == "stop" {
if len(os.Args) > 2 {
todo := os.Args[2]
if todo == "all" {
stopByName(todo)
return
}
flpath, err := filepath.Abs(todo)
if err == nil && len(flpath) > 0 {
stopByName(flpath)
}
} else {
stop()
}
return
}
if cmd == "reload" {
_, err := reload()
if err != nil {
fmt.Println(err)
}
return
}
if cmd == "status" {
result, err := status()
if err != nil {
fmt.Println("caddy is NOT running")
} else {
fmt.Printf("%v\n", result)
}
fmt.Println()
return
}
out, err := exec.Command(caddybin, os.Args[1:]...).Output()
if err != nil {
fmt.Println(err)
} else {
fmt.Println(string(out))
}
fmt.Println()
}
var cadderAuto = ".Cadder-static.auto"
var names = []string{"Caddyfile", "caddy.json", "caddy.yaml", cadderAuto}
// conf gets caddy config in current directory
func conf() (string, error) {
var name string
for _, name = range names {
if _, err := os.Stat(fmt.Sprintf("./%s", name)); !os.IsNotExist(err) {
return filepath.Abs(name)
}
}
return "", fmt.Errorf("Caddyfile or caddy.json not found in current diretory")
}
func confAuto() (string, error) {
addr := fmt.Sprintf(":%d%d%s", 9, rand.Intn(9), time.Now().Format("2006")[2:])
err := os.WriteFile(cadderAuto, []byte(fmt.Sprintf(`{
auto_https off
}
%s {
encode zstd gzip
root * ./
file_server
}
`, addr)), 0644)
if err != nil {
return "", fmt.Errorf("Caddyfile or caddy.json not found in current diretory")
}
name, _ := filepath.Abs(cadderAuto)
fmt.Printf("listening on %s\n", addr)
return name, nil
}
func copyMap(in, out interface{}) {
body, _ := json.Marshal(in)
json.Unmarshal(body, out)
}
func confMap(flpath string) (result map[string]interface{}, err error) {
result = map[string]interface{}{}
if strings.HasSuffix(flpath, ".json") {
var body []byte
body, err = os.ReadFile(flpath)
if err == nil {
body = fixing(body, flpath)
json.Unmarshal(body, &result)
}
} else {
result, err = caddyfile(flpath)
}
cached := make(map[string]interface{})
copyMap(result, &cached)
cadderCache[flpath] = cached
body, _ := json.Marshal(cadderCache)
err = os.WriteFile(cachePath, body, 0600)
if err != nil {
fmt.Println(err)
}
return
}
// servers
func current() {
result, err := status()
if err != nil {
fmt.Println("caddy is NOT running")
return
}
xpaths := strings.Split("apps/http/servers", "/")
a := result
for _, x := range xpaths {
m, ok := a[x]
if !ok || m == nil {
log.Fatal(err)
break
}
a = m.(map[string]interface{})
}
servers := a
if cf, ok := result["@id"]; ok && len(cf.(string)) > 0 {
fmt.Printf("\n-- CONFIGS: --\n\n")
fmt.Println(strings.Join(strings.Split(cf.(string), "|"), "\n"))
}
fmt.Printf("\n-- SERVERS: --\n\n")
for _, v := range servers {
server := v.(map[string]interface{})
ports := server["listen"].([]interface{})
hosts := []interface{}{}
if nil != server["routes"] {
routes := server["routes"].([]interface{})
for _, route := range routes {
mr, ok := route.(map[string]interface{})["match"]
if !ok || mr == nil {
continue
}
ml := mr.([]interface{})
for _, m := range ml {
im := m.(map[string]interface{})
h, ok := im["host"]
if !ok || h == nil || len(h.([]interface{})) == 0 {
continue
}
hosts = append(hosts, h.([]interface{}))
}
}
}
if len(hosts) > 0 {
for i, host := range hosts {
fmt.Printf("%s%s\n", host, ports[i])
}
} else {
for _, port := range ports {
fmt.Printf("%s\n", port)
}
}
fmt.Printf("--------\n\n")
}
}
// status gets caddy servers' config
func status() (result map[string]interface{}, err error) {
result = map[string]interface{}{}
var body []byte
body, err = request("/config/", nil, nil)
if err != nil {
return result, err
}
err = json.Unmarshal(body, &result)
return result, err
}
func adapter(args *[]string, flpath string) string {
name := filepath.Base(flpath)
adn := ""
if strings.HasSuffix(name, ".yaml") {
adn = "yaml"
} else if strings.HasSuffix(name, ".json") {
adn = "json5"
} else if filepath.Base(flpath) == cadderAuto {
adn = "caddyfile"
}
if len(adn) > 0 {
*args = append(*args, "--adapter", adn)
}
return adn
}
// caddyfile reads Caddyfile to map
func caddyfile(flpath string) (result map[string]interface{}, err error) {
result = map[string]interface{}{}
var body []byte
flpath, _ = filepath.Abs(flpath)
_, err = status()
if true || err != nil || (major == 2 && (midor < 5 || midor == 5 && minor < 2)) {
args := []string{"adapt", "--config", flpath}
adapter(&args, flpath)
body, err = exec.Command(caddybin, args...).Output()
if err != nil {
return result, err
}
body = fixing(body, flpath)
err = json.Unmarshal(body, &result)
return result, err
}
// adapt api, 2.5.2+
body, err = os.ReadFile(flpath)
if err != nil {
return result, err
}
data := bytes.Buffer{}
data.Write(body)
body, err = request("/adapt", &data, &map[string]string{
"Content-Type": "text/caddyfile",
})
if err != nil {
return result, err
}
body = fixing(body, flpath)
err = json.Unmarshal(body, &result)
if err == nil {
result = result["result"].(map[string]interface{})
}
return result, err
}
// slicemerge to merge two slice
func slicemerge(a, b []interface{}) []interface{} {
switch b[0].(type) {
case []interface{}, map[string]interface{}:
bl := map[string]bool{}
for _, va := range a {
ab, _ := json.Marshal(va)
bl[string(ab)] = true
}
for _, v := range b {
b, _ := json.Marshal(v)
if _, ok := bl[string(b)]; ok {
continue
}
a = append(a, v)
}
default:
for _, v := range b {
found := false
for _, va := range a {
if va == v {
found = true
break
}
}
if !found {
a = append(a, v)
}
}
}
return a
}
var dynaKey = regexp.MustCompile(`^([a-z]+)(\d+)$`)
// merge two map, avoid key# overlay
func merge(a, b map[string]interface{}) (map[string]interface{}, error) {
var err error
for k, vb := range b {
if va, ok := a[k]; !ok {
a[k] = vb
} else if dynaKey.MatchString(k) {
m := dynaKey.FindStringSubmatch(k)
nk, _ := strconv.Atoi(m[2])
kn := k
for {
nk += 1
kn = fmt.Sprintf("%s%d", m[1], nk)
if _, ok = a[kn]; !ok {
break
}
}
a[kn] = vb
} else {
switch vb.(type) {
case []interface{}:
a[k] = slicemerge(va.([]interface{}), vb.([]interface{}))
if err != nil {
return a, err
}
case map[string]interface{}:
a[k], err = merge(va.(map[string]interface{}), vb.(map[string]interface{}))
if err != nil {
return a, err
}
default:
if va != vb {
return a, fmt.Errorf("conflict on %s: %v, %v", k, va, vb)
}
}
}
}
return a, nil
}
// fix paths with absolute paths
func fixing(body []byte, flpath string) []byte {
basedir := filepath.Dir(flpath)
for _, m := range abs {
body = m.ReplaceAllFunc(body, func(src []byte) []byte {
c := m.FindSubmatch(src)
if winos && winstyle.Match(c[2]) {
return src
}
b := bytes.Buffer{}
b.Write(c[1])
s, err := filepath.Abs(filepath.Join(basedir, string(c[2])))
if err == nil {
b.Write([]byte(s))
} else {
b.Write(c[2])
}
b.Write(c[3])
return b.Bytes()
})
}
return body
}
func run(paths []string) (current map[string]interface{}, err error) {
l := []string{}
for _, flpath := range paths {
if _, err := os.Stat(flpath); os.IsNotExist(err) {
continue
}
result, ok := cadderCache[flpath]
if !ok {
result, err = confMap(flpath)
}
if err != nil || len(result) == 0 {
continue
}
out := make(map[string]interface{})
copyMap(result, &out)
if current == nil {
current = out
} else {
current, err = merge(current, out)
if err != nil {
log.Fatal(err)
}
}
l = append(l, flpath)
}
if current != nil {
current["@id"] = strings.Join(l, "|")
} else if err == nil {
err = fmt.Errorf("no valid config")
}
return
}
// start current servers
func start() (string, error) {
flpath, err := conf()
if err != nil {
flpath, err = confAuto()
if err != nil {
return flpath, err
}
}
result, err := confMap(flpath)
if err != nil || len(result) == 0 {
return flpath, fmt.Errorf("invalid caddy config")
}
current, err := status()
if err != nil {
os.Remove(cachePath)
err = nil
args := []string{"start", "--config", flpath}
adapter(&args, flpath)
err = exec.Command(caddybin, args...).Run()
// update @id
result["@id"] = flpath
body, _ := json.Marshal(result)
data := bytes.Buffer{}
data.Write(body)
_, err = request("/load", &data, nil)
return flpath, err
}
pathstr := ""
if nil != current["@id"] {
pathstr = current["@id"].(string)
}
paths := []string{}
if len(pathstr) > 0 {
paths = strings.Split(pathstr, "|")
}
npaths := []string{}
found := false
for _, path := range paths {
if path == flpath {
found = true
continue
}
npaths = append(npaths, path)
}
if found {
if len(npaths) == 0 {
current = result
} else {
current, err = run(npaths)
if err == nil {
npaths = strings.Split(current["@id"].(string), "|")
current, err = merge(current, result)
}
if err != nil {
log.Fatal(err)
}
}
} else {
current, err = merge(current, result)
if err != nil {
log.Fatal(err)
}
}
current["@id"] = strings.Join(append(npaths, flpath), "|")
body, _ := json.Marshal(current)
data := bytes.Buffer{}
data.Write(body)
out, err := request("/load", &data, nil)
if err != nil && out != nil && len(out) > 0 {
fmt.Println(string(out))
}
return flpath, err
}
// confirm task
func confirm(s string) bool {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Printf("%s [y/n]: ", s)
response, err := reader.ReadString('\n')
if err != nil {
log.Fatal(err)
}
response = strings.ToLower(strings.TrimSpace(response))
if response == "y" || response == "yes" {
return true
} else if response == "n" || response == "no" {
return false
}
}
}
func clearAuto() {
for flpath, _ := range cadderCache {
if strings.HasSuffix(flpath, cadderAuto) {
os.Remove(flpath)
}
}
}
func stopAll() {
if confirm("stop all caddy servers?") {
err := exec.Command(caddybin, "stop").Run()
if err == nil {
defer clearAuto()
os.Remove(cachePath)
}
}
}
// stop current servers
func stop() {
name, err := conf()
if err != nil || len(name) == 0 {
_, err = status()
if err == nil {
stopAll()
}
return
}
stopByName(name)
}
func stopByName(name string) {
if name == "all" {
stopAll()
return
}
if filepath.Base(name) == cadderAuto {
os.Remove(name)
}
current, err := status()
paths := []string{}
if current["@id"] != nil {
paths = strings.Split(current["@id"].(string), "|")
}
npaths := []string{}
for _, flpath := range paths {
if flpath == name {
continue
}
npaths = append(npaths, flpath)
}
if len(npaths) == 0 {
exec.Command(caddybin, "stop").Run()
os.Remove(cachePath)
return
}
current, err = run(npaths)
if err != nil {
log.Fatal(err)
}
body, _ := json.Marshal(current)
data := bytes.Buffer{}
data.Write(body)
out, err := request("/load", &data, nil)
if err == nil {
delete(cadderCache, name)
body, _ := json.Marshal(cadderCache)
err = os.WriteFile(cachePath, body, 0600)
if err != nil {
fmt.Println(err)
}
} else if out != nil && len(out) > 0 {
fmt.Println(string(out))
}
}
// reload current servers
func reload() (string, error) {
_, err := status()
if err != nil && !confirm("caddy servers not running, start right now?") {
return "", nil
}
return start()
}
// request to make a request to url u with cond as data
// if u contains ?json using content-type application/json
// if cond is not empty, using POST method
func request(u string, cond *bytes.Buffer, headers *map[string]string) ([]byte, error) {
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
u = caddyadmin + u
}
client := &http.Client{}
method := "GET"
if cond == nil {
cond = &bytes.Buffer{}
}
if cond.Len() > 0 {
method = "POST"
}
req, err := http.NewRequest(method, u, cond)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
req.Header.Set("Connection", "close")
if headers != nil {
for name, value := range *headers {
req.Header.Set(name, value)
}
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp == nil || resp.Body == nil {
return nil, fmt.Errorf("invalid response")
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode >= 300 {
return body, fmt.Errorf("%d: %s", resp.StatusCode, http.StatusText(resp.StatusCode))
}
return body, err
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment