Last active
March 22, 2023 02:19
-
-
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+)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: [email protected] | |
// 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