410 lines
12 KiB
Go
410 lines
12 KiB
Go
package netconfig
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
cpanelBin = "/usr/local/cpanel/bin/whmapi1"
|
|
cpanelJSON = "--output=json"
|
|
|
|
// Paths and scripts that define cPanel's main shared IPv4: mainip is the
|
|
// stored main IP, ADDR in wwwacct.conf is the shared IP, and
|
|
// mainipcheck/fixetchosts sync the stored main IP and /etc/hosts. IPv6 is
|
|
// handled as ranges (see removeIP).
|
|
cpanelWWWAcctConf = "/etc/wwwacct.conf"
|
|
cpanelMainIP = "/var/cpanel/mainip"
|
|
cpanelMainIPCheck = "/scripts/mainipcheck"
|
|
cpanelFixEtcHosts = "/scripts/fixetchosts"
|
|
)
|
|
|
|
type cpanelMetadata struct {
|
|
Command string `json:"command"`
|
|
Reason string `json:"reason"`
|
|
Result int `json:"result"`
|
|
}
|
|
type cpanelBase struct {
|
|
Metadata cpanelMetadata `json:"metadata"`
|
|
}
|
|
|
|
type cpanelIPInfo struct {
|
|
Active int `json:"active"`
|
|
Dedicated int `json:"dedicated"`
|
|
IP string `json:"ip"`
|
|
MainIP int `json:"mainaddr"`
|
|
PublicIP string `json:"public_ip"`
|
|
Removable int `json:"removable"`
|
|
Used int `json:"used"`
|
|
}
|
|
type cpanelListIPs struct {
|
|
Data struct {
|
|
IPs []cpanelIPInfo `json:"ip"`
|
|
} `json:"data"`
|
|
Metadata cpanelMetadata `json:"metadata"`
|
|
}
|
|
|
|
type cpanelAccountInfo struct {
|
|
User string `json:"user"`
|
|
Domain string `json:"domain"`
|
|
IP string `json:"ip"`
|
|
IPv6 []string `json:"ipv6"`
|
|
}
|
|
type cpanelListAccounts struct {
|
|
Data struct {
|
|
Accounts []cpanelAccountInfo `json:"acct"`
|
|
} `json:"data"`
|
|
Metadata cpanelMetadata `json:"metadata"`
|
|
}
|
|
|
|
type cpanel struct {
|
|
}
|
|
|
|
// Parse cpanel API json output.
|
|
func (*cpanel) parseJSON(out []string, v any) error {
|
|
if len(out) != 1 {
|
|
return fmt.Errorf("no json output.")
|
|
}
|
|
|
|
return json.Unmarshal([]byte(out[0]), v)
|
|
}
|
|
|
|
// Move sites off the old IP to the new IP.
|
|
func (p *cpanel) moveSites(ctx context.Context, oldAddr, newAddr net.IP) error {
|
|
// Get list of accounts in cPanel using the old IP.
|
|
out, err := runCommand(ctx, cpanelBin, cpanelJSON, "listaccts", fmt.Sprintf("search=%s", oldAddr.String()), "searchtype=ip")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var data cpanelListAccounts
|
|
err = p.parseJSON(out, &data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// On error, log.
|
|
if data.Metadata.Result != 1 {
|
|
return fmt.Errorf("%s", data.Metadata.Reason)
|
|
}
|
|
|
|
// Move the sites to new IP.
|
|
for _, account := range data.Data.Accounts {
|
|
// If the IP is not the one we're removing, skip.'
|
|
ip := net.ParseIP(account.IP)
|
|
if !ip.Equal(oldAddr) {
|
|
continue
|
|
}
|
|
|
|
// Update the IP address for the account.
|
|
out, err := runCommand(ctx, cpanelBin, cpanelJSON, "setsiteip", fmt.Sprintf("ip=%s", newAddr.String()), fmt.Sprintf("user=%s", account.User))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var data cpanelBase
|
|
err = p.parseJSON(out, &data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.Println("cPanel site move response:", data.Metadata.Reason)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Remove an IP address from cpanel. IPv4 and IPv6 are handled separately: the
|
|
// IPv4 main-IP pool (listips) is IPv4-only, so it must not be consulted when
|
|
// removing an IPv6 address.
|
|
func (p *cpanel) removeIP(ctx context.Context, addr net.IP) error {
|
|
if addr.To4() == nil {
|
|
return p.removeIPv6(ctx, addr)
|
|
}
|
|
return p.removeIPv4(ctx, addr)
|
|
}
|
|
|
|
// removeIPv4 moves accounts off addr onto cPanel's main shared IPv4, then
|
|
// releases addr.
|
|
func (p *cpanel) removeIPv4(ctx context.Context, addr net.IP) error {
|
|
// Get list of accounts in cPanel using the old IP.
|
|
out, err := runCommand(ctx, cpanelBin, cpanelJSON, "listaccts", fmt.Sprintf("search=%s", addr.String()), "searchtype=ip")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var accounts cpanelListAccounts
|
|
err = p.parseJSON(out, &accounts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if accounts.Metadata.Result != 1 {
|
|
return fmt.Errorf("%s", accounts.Metadata.Reason)
|
|
}
|
|
|
|
// Get list of IP addresses in cPanel.
|
|
out, err = runCommand(ctx, cpanelBin, cpanelJSON, "listips")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var data cpanelListIPs
|
|
err = p.parseJSON(out, &data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// On error, log.
|
|
if data.Metadata.Result != 1 {
|
|
return fmt.Errorf("%s", data.Metadata.Reason)
|
|
}
|
|
|
|
// Information to extract from IP list.
|
|
var mainIP net.IP
|
|
isMainIP := false
|
|
var publicIPs []net.IP
|
|
var privateIPs []net.IP
|
|
|
|
// Parse IP list info.
|
|
for _, ipInfo := range data.Data.IPs {
|
|
ip := net.ParseIP(ipInfo.IP)
|
|
|
|
// Depending on the ip, if the IP is one being removed we capture if its the main IP.
|
|
// If the IP is the main IP, but no the one being removed, capture that fact.
|
|
// If its an public IP, associate with public IP list, and if its private add as needed.
|
|
if ip.Equal(addr) {
|
|
isMainIP = ipInfo.MainIP == 1 && mainIP == nil
|
|
} else if ipInfo.MainIP == 1 {
|
|
mainIP = ip
|
|
isMainIP = false
|
|
} else if !ip.IsPrivate() {
|
|
publicIPs = append(publicIPs, ip)
|
|
} else {
|
|
pubIP := net.ParseIP(ipInfo.PublicIP)
|
|
if pubIP != nil && !pubIP.IsPrivate() {
|
|
publicIPs = append(publicIPs, ip)
|
|
} else {
|
|
privateIPs = append(privateIPs, ip)
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no main IP found, we should find a new main IP.
|
|
noMainIP := mainIP == nil
|
|
if noMainIP {
|
|
if len(publicIPs) != 0 {
|
|
mainIP = publicIPs[0]
|
|
} else if len(privateIPs) != 0 {
|
|
mainIP = privateIPs[0]
|
|
} else {
|
|
return fmt.Errorf("unable to find the main IP to move ip addresses to")
|
|
}
|
|
}
|
|
|
|
// Move sites to the main IP from the old IP.
|
|
err = p.moveSites(ctx, addr, mainIP)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Remove the IP, incase there is an IP alias for it. We don't really care about the output for this,
|
|
// it may fail if cPanel already picked up that the IP was removed from the system interface.
|
|
runCommand(ctx, cpanelBin, cpanelJSON, "delip", fmt.Sprintf("ip=%s", addr.String()))
|
|
|
|
// Rebuild the IP pool.
|
|
err = p.reload(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// If the main IP did not exist, or if the IP being removed was the main IP, tell cPanel to check.
|
|
if isMainIP || noMainIP {
|
|
out, err := runCommand(ctx, cpanelMainIPCheck)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.Println("cPanel main IP output:", strings.Join(out, "\n"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// removeIPv6 moves accounts off addr onto a replacement IPv6 address, then
|
|
// releases addr. cPanel's listips pool is IPv4-only, so the replacement is
|
|
// drawn from the other IPv6 addresses the affected accounts already hold; it is
|
|
// never an IPv4 address. When no other IPv6 is available the address is dropped
|
|
// from each account rather than substituted.
|
|
func (p *cpanel) removeIPv6(ctx context.Context, addr net.IP) error {
|
|
// cPanel tracks IPv6 as ranges; search accounts by the IPv6 address.
|
|
out, err := runCommand(ctx, cpanelBin, cpanelJSON, "listaccts", fmt.Sprintf("search=%s", addr.String()), "searchtype=ipv6")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var accounts cpanelListAccounts
|
|
err = p.parseJSON(out, &accounts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if accounts.Metadata.Result != 1 {
|
|
return fmt.Errorf("%s", accounts.Metadata.Reason)
|
|
}
|
|
|
|
// Choose a replacement IPv6 from the addresses the affected accounts already
|
|
// use, excluding the one being removed. Never fall back to an IPv4 address.
|
|
var replacement net.IP
|
|
for _, account := range accounts.Data.Accounts {
|
|
for _, ipStr := range account.IPv6 {
|
|
ip := net.ParseIP(ipStr)
|
|
if ip == nil || ip.To4() != nil || ip.Equal(addr) {
|
|
continue
|
|
}
|
|
replacement = ip
|
|
break
|
|
}
|
|
if replacement != nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Move the sites off the address (onto the replacement, or drop it when
|
|
// there is no other IPv6 to move to).
|
|
err = p.moveIPv6Sites(ctx, accounts, addr, replacement)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Remove the IP, incase there is an IP alias for it. We don't really care about the output for this,
|
|
// it may fail if cPanel already picked up that the IP was removed from the system interface.
|
|
runCommand(ctx, cpanelBin, cpanelJSON, "delip", fmt.Sprintf("ip=%s", addr.String()), "ipv6=1")
|
|
|
|
// Rebuild the IP pool.
|
|
return p.reload(ctx)
|
|
}
|
|
|
|
// moveIPv6Sites moves any accounts using oldAddr as an IPv6 address over to
|
|
// newAddr, or drops oldAddr from the account when newAddr is nil. cPanel
|
|
// exposes account IPv6 via listaccts, so we use that rather than the IPv4
|
|
// listips path.
|
|
func (p *cpanel) moveIPv6Sites(ctx context.Context, accounts cpanelListAccounts, oldAddr, newAddr net.IP) error {
|
|
for _, account := range accounts.Data.Accounts {
|
|
// Confirm the account actually has the address being removed.
|
|
found := false
|
|
for _, ip := range account.IPv6 {
|
|
if net.ParseIP(ip).Equal(oldAddr) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
continue
|
|
}
|
|
|
|
// Build the new IPv6 list, replacing oldAddr with newAddr. When there is
|
|
// no replacement (newAddr is nil), drop oldAddr from the list entirely
|
|
// rather than substituting an address of the wrong family.
|
|
var newIPv6 []string
|
|
for _, ip := range account.IPv6 {
|
|
if net.ParseIP(ip).Equal(oldAddr) {
|
|
if newAddr != nil {
|
|
newIPv6 = append(newIPv6, newAddr.String())
|
|
}
|
|
} else {
|
|
newIPv6 = append(newIPv6, ip)
|
|
}
|
|
}
|
|
|
|
out, err := runCommand(ctx, cpanelBin, cpanelJSON, "setsiteip", fmt.Sprintf("ip=%s", account.IP), fmt.Sprintf("user=%s", account.User), fmt.Sprintf("ipv6=%s", strings.Join(newIPv6, ",")))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var data cpanelBase
|
|
err = p.parseJSON(out, &data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.Println("cPanel IPv6 site move response:", data.Metadata.Reason)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// setWWWAcctAddr rewrites an address directive in a wwwacct.conf-style file to
|
|
// ip in place, preserving every other line and the file's permissions. If the
|
|
// directive is absent it is appended. directive is "ADDR" for the shared IPv4 or
|
|
// "ADDR6" for the shared IPv6; the two are distinct, so setting one leaves the
|
|
// other untouched.
|
|
func setWWWAcctAddr(path, directive, ip string) error {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
lines := strings.Split(string(data), "\n")
|
|
replaced := false
|
|
for i, line := range lines {
|
|
fields := strings.Fields(line)
|
|
if len(fields) >= 1 && fields[0] == directive {
|
|
lines[i] = directive + " " + ip
|
|
replaced = true
|
|
}
|
|
}
|
|
if !replaced {
|
|
// Insert before a trailing empty element (from a final newline) so the
|
|
// file keeps a single trailing newline rather than gaining a blank line.
|
|
if n := len(lines); n > 0 && lines[n-1] == "" {
|
|
lines = append(lines[:n-1], directive+" "+ip, "")
|
|
} else {
|
|
lines = append(lines, directive+" "+ip)
|
|
}
|
|
}
|
|
|
|
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), info.Mode().Perm())
|
|
}
|
|
|
|
// setMainIP repoints cPanel's main shared IP to addr: set the shared-IP
|
|
// directive in wwwacct.conf (ADDR for IPv4, ADDR6 for IPv6), then run
|
|
// mainipcheck and fixetchosts to sync the stored main IP and /etc/hosts. Only
|
|
// IPv4 has a stored-main-IP file (/var/cpanel/mainip); IPv6 is tracked as ranges
|
|
// and has no such file. The IP pool (/etc/ipaddrpool) is not written here
|
|
// because reload() runs /scripts/rebuildippool, which rebuilds the pool from the
|
|
// system's bound IPs.
|
|
func (p *cpanel) setMainIP(ctx context.Context, addr net.IP) error {
|
|
ip := addr.String()
|
|
|
|
// Point the shared IP at the new main address (ADDR6 for IPv6).
|
|
directive := "ADDR"
|
|
if addr.To4() == nil {
|
|
directive = "ADDR6"
|
|
}
|
|
if err := setWWWAcctAddr(cpanelWWWAcctConf, directive, ip); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Only IPv4 has a stored main IP file; record it without a trailing newline.
|
|
if addr.To4() != nil {
|
|
if err := os.WriteFile(cpanelMainIP, []byte(ip), 0644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Sync cPanel's stored main IP (/var/cpanel/mainip) and /etc/hosts.
|
|
out, err := runCommand(ctx, cpanelMainIPCheck)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.Println("cPanel main IP output:", strings.Join(out, "\n"))
|
|
runCommand(ctx, cpanelFixEtcHosts)
|
|
return nil
|
|
}
|
|
|
|
// Tell cpanel to reload the available IP addresses.
|
|
func (*cpanel) reload(ctx context.Context) error {
|
|
out, err := runCommand(ctx, "/scripts/rebuildippool")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.Println("cPanel IP pool output:", strings.Join(out, "\n"))
|
|
return nil
|
|
}
|