feat: importing never takes a pool address, and one can be assigned later
Unifies what a transfer means on the receiving host, and fixes the gap that made the rule awkward to live with. Receiving is now decided entirely by the receiving host's own semantics and nothing else — the sending host's setup is irrelevant, and the metadata carries no trace of it. Prefix-mode and IPv4-only hosts give the container the address they give any other container of theirs. A pool-mode host gives it none: a pool address is a per-container choice its operator makes, so an imported container arrives as pure IPv4, on the single bridged NIC that layout uses, consuming nothing from the pool. That left a real hole in the panel, which is also fixed here: there was no way to give a pool address to a container that did not have one, even with the pool sitting empty, so a container imported into a pool-mode host could never get IPv6 at all. The quota form now offers the free pool addresses for exactly the containers that have none. Giving one back is deliberately not offered — an address handed to a customer stays theirs until the account is deleted — and the assignment reshapes the container (the IPv4 moves to a bridged eth1, the address goes on a routed eth0) and wires it on the host, rolling the whole thing back if any step fails. Incus decides an update device by device against the set it is replacing, so that reshape goes in two requests: adding eth1 while it already carries the IPv4 fails with "already defined on another NIC", because the eth0 being changed still holds it. The NIC is added bare first, and the address arrives as a change to a device that already exists. Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
1 parent
86cfaf87f7
commit
fc31a1e3d4
7 files changed
+309
-55
No files matched your search
@@ -691,6 +691,21 @@ func (s *Server) handleUserQuota(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirect(w, r, s.p(""), "error: "+err.Error())
|
||||
return
|
||||
}
|
||||
// A pool address is offered on this form only for a container that has none:
|
||||
// an address, once handed to a customer, is theirs until the account is
|
||||
// deleted, so this can give one but never take one away. "keep" is the
|
||||
// no-op choice, "auto" the first free address.
|
||||
if sel := strings.TrimSpace(r.FormValue("ipv6")); sel != "" && sel != "keep" {
|
||||
want := sel
|
||||
if sel == "auto" {
|
||||
want = ""
|
||||
}
|
||||
if _, err := s.mgr.AssignPoolIPv6(name, want); err != nil {
|
||||
s.redirect(w, r, s.p(""), "error: "+err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.db.AddAuditLog("000+"+name, "ipv6.assign")
|
||||
}
|
||||
_ = s.db.AddAuditLog("000+"+name, "quota.update")
|
||||
s.redirect(w, r, s.p(""), s.t(r, "quota_updated", name))
|
||||
}
|
||||
|
||||
@@ -292,7 +292,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
|
||||
{{else}}
|
||||
<form action="{{$.Prefix}}/power" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><input type="hidden" name="action" value="start"><button class="ok">{{if eq $.Lang "zh"}}启动{{else}}Start{{end}}</button></form>
|
||||
{{end}}
|
||||
<button onclick="quota('{{.Name}}','{{.QuotaCPU}}','{{.QuotaMem}}','{{.QuotaDisk}}','{{.BandwidthGB}}','{{.ExpiresAt}}',false)">{{if eq $.Lang "zh"}}配额{{else}}Quota{{end}}</button>
|
||||
<button onclick="quota('{{.Name}}','{{.QuotaCPU}}','{{.QuotaMem}}','{{.QuotaDisk}}','{{.BandwidthGB}}','{{.ExpiresAt}}',false,{{if .IPv6}}true{{else}}false{{end}})">{{if eq $.Lang "zh"}}配额{{else}}Quota{{end}}</button>
|
||||
<form action="{{$.Prefix}}/login-as" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><button class="primary neutral"{{if .Color}} style="--ubtn:{{.Color}};color:#fff"{{end}} title="{{if eq $.Lang "zh"}}以该用户身份登录用户面板{{else}}log in to this user's panel{{end}}">{{if eq $.Lang "zh"}}登录面板{{else}}Login{{end}}</button></form>
|
||||
<form action="{{$.Prefix}}/reset-panel-pass" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><button>{{if eq $.Lang "zh"}}重置密码{{else}}Reset pw{{end}}</button></form>
|
||||
<button class="danger" onclick="del('{{.Name}}')">{{if eq $.Lang "zh"}}删除{{else}}Delete{{end}}</button>
|
||||
@@ -423,6 +423,17 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
|
||||
<div class="field"><label>{{if eq .Lang "zh"}}内存 MiB{{else}}Memory MiB{{end}}</label><input name="mem" id="quotaMem" required></div>
|
||||
<div class="field"><label>{{if eq .Lang "zh"}}磁盘 GiB{{else}}Disk GiB{{end}}</label><input name="disk" id="quotaDisk" type="number" min="1" required></div>
|
||||
<div class="field"><label>{{if eq .Lang "zh"}}流量 GiB{{else}}Bandwidth GiB{{end}}</label><input name="bandwidth" id="quotaBandwidth" type="number" min="0" step="1" title="{{if eq .Lang "zh"}}每月流量上限(0 = 不限),超限后限速 1Mbps{{else}}monthly quota (0 = unlimited); throttled to 1Mbps when exceeded{{end}}"></div>
|
||||
{{if .IPv6PoolMode}}
|
||||
<!-- Offered only for a container that has no pool address: an address,
|
||||
once given to a customer, is theirs until the account is deleted. -->
|
||||
<div class="field" id="quotaIpv6Wrap" style="display:none"><label>{{if eq .Lang "zh"}}分配 IPv6{{else}}Assign IPv6{{end}}</label>
|
||||
<select name="ipv6" id="quotaIpv6" class="field-select">
|
||||
<option value="keep">{{if eq .Lang "zh"}}不分配{{else}}None{{end}}</option>
|
||||
<option value="auto">{{if eq .Lang "zh"}}自动分配(第一个空闲){{else}}Auto (first free){{end}}</option>
|
||||
{{range .PoolFree}}<option value="{{.}}">{{.}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
{{end}}
|
||||
<button type="submit" class="primary" id="quotaSaveBtn">{{if eq .Lang "zh"}}保存{{else}}Save{{end}}</button>
|
||||
<button type="button" onclick="closeQuota()">{{if eq .Lang "zh"}}取消{{else}}Cancel{{end}}</button>
|
||||
</form>
|
||||
@@ -597,7 +608,7 @@ function copyModal(){
|
||||
if (navigator.clipboard) { navigator.clipboard.writeText(b.textContent).then(done, done); }
|
||||
else { var ta=document.createElement('textarea'); ta.value=b.textContent; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); done(); }
|
||||
}
|
||||
function quota(name, cpu, mem, disk, bandwidth, expires, expired){
|
||||
function quota(name, cpu, mem, disk, bandwidth, expires, expired, hasIpv6){
|
||||
document.getElementById('quotaName').textContent = name;
|
||||
document.getElementById('quotaNameHidden').value = name;
|
||||
document.getElementById('quotaBwName').value = name;
|
||||
@@ -614,6 +625,9 @@ function quota(name, cpu, mem, disk, bandwidth, expires, expired){
|
||||
var el = document.getElementById(id); if(el) el.disabled = lock;
|
||||
});
|
||||
var save = document.getElementById('quotaSaveBtn'); if(save) save.disabled = lock;
|
||||
// A pool address can be given to a container that has none, and only then.
|
||||
var v6wrap = document.getElementById('quotaIpv6Wrap');
|
||||
if(v6wrap) v6wrap.style.display = (hasIpv6 || lock) ? 'none' : '';
|
||||
var note = document.getElementById('quotaExpiredNote'); if(note) note.style.display = lock ? 'block' : 'none';
|
||||
document.getElementById('quotaExtend').value = 'none';
|
||||
document.getElementById('quotaExtendDaysWrap').style.display = 'none';
|
||||
|
||||
@@ -276,6 +276,14 @@ func (d *DB) UpdateUserExpiry(id int64, expiresAt string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateUserIPv6 sets a user's pool-mode IPv6 address, or clears it with "".
|
||||
// The column carries a unique index, so an address can only ever belong to one
|
||||
// user.
|
||||
func (d *DB) UpdateUserIPv6(id int64, addr string) error {
|
||||
_, err := d.sql.Exec(`UPDATE users SET ipv6_address=? WHERE id=?`, nullIfEmpty(addr), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UsedIPv6Addresses returns the set of pool-mode IPv6 addresses currently
|
||||
// assigned to users, so the manager can pick a free one and the UI can list
|
||||
// the free remainder.
|
||||
|
||||
@@ -222,6 +222,17 @@ func RandomMAC() (string, error) {
|
||||
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b[0], b[1], b[2], b[3], b[4], b[5]), nil
|
||||
}
|
||||
|
||||
// PoolDriver returns the Incus storage driver backing pool (zfs, btrfs, dir…).
|
||||
func (c *Client) PoolDriver(pool string) (string, error) {
|
||||
var p struct {
|
||||
Driver string `json:"driver"`
|
||||
}
|
||||
if err := c.get("/1.0/storage-pools/"+url.PathEscape(pool), &p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p.Driver, nil
|
||||
}
|
||||
|
||||
// DiskUsage returns the number of bytes the container's root volume currently
|
||||
// occupies, used to size the temp file before an export starts. It reports 0
|
||||
// when the daemon has no figure to give (a stopped container on some drivers),
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package mgr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vpsmgr/internal/cfg"
|
||||
"vpsmgr/internal/db"
|
||||
"vpsmgr/internal/lx"
|
||||
"vpsmgr/internal/su"
|
||||
)
|
||||
|
||||
@@ -238,6 +241,99 @@ func (m *Manager) RemovePoolIPv6(addr string) error {
|
||||
return cfg.Save(m.cfg)
|
||||
}
|
||||
|
||||
// AssignPoolIPv6 gives an existing container a pool address it does not have.
|
||||
//
|
||||
// A pool address is a per-container choice its operator makes, so a container
|
||||
// can exist without one — a freshly imported one always does — and this is how
|
||||
// it gets one afterwards. The container changes shape for it: the private IPv4
|
||||
// moves from the single bridged eth0 to a bridged eth1, and the new address
|
||||
// goes on a routed eth0, so it is stopped, reconfigured and started again, and
|
||||
// the guest is told where its addresses now live.
|
||||
//
|
||||
// Handing an address back is deliberately not offered: an address that has been
|
||||
// given to a customer stays theirs until the account is deleted.
|
||||
func (m *Manager) AssignPoolIPv6(name, addr string) (string, error) {
|
||||
m.opMu.Lock()
|
||||
defer m.opMu.Unlock()
|
||||
|
||||
if m.cfg.IPv6ModeEffective() != cfg.IPv6ModePool {
|
||||
return "", errors.New("this host does not use an IPv6 pool")
|
||||
}
|
||||
u, err := m.db.GetUserByName(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if u.IPv6Address != "" {
|
||||
return "", fmt.Errorf("%s already has %s", u.Name, u.IPv6Address)
|
||||
}
|
||||
picked, err := m.pickPoolIPv6(strings.TrimSpace(addr))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if picked == "" {
|
||||
return "", errors.New("no free address in the IPv6 pool")
|
||||
}
|
||||
if err := m.db.UpdateUserIPv6(u.ID, picked); err != nil {
|
||||
return "", err
|
||||
}
|
||||
u.IPv6Address = picked
|
||||
|
||||
restore := func(err error) (string, error) {
|
||||
if rerr := m.db.UpdateUserIPv6(u.ID, ""); rerr != nil {
|
||||
fmt.Printf(" ! warn: could not undo the assignment for %s: %v\n", u.Name, rerr)
|
||||
}
|
||||
u.IPv6Address = ""
|
||||
spec, devices := m.instanceConfig(u)
|
||||
_ = m.lx.Stop(u.Name)
|
||||
if rerr := m.lx.ReplaceConfig(u.Name, spec, devices); rerr != nil {
|
||||
fmt.Printf(" ! warn: could not put %s back to how it was: %v\n", u.Name, rerr)
|
||||
}
|
||||
_ = m.lx.Start(u.Name)
|
||||
return "", err
|
||||
}
|
||||
|
||||
spec, devices := m.instanceConfig(u)
|
||||
// The new shape goes in two steps. Incus validates a device it is about to
|
||||
// ADD against the set it is replacing, so adding eth1 already carrying the
|
||||
// IPv4 fails with "already defined on another NIC" — the eth0 that is being
|
||||
// changed still holds it. Adding the NIC bare first means the address only
|
||||
// ever arrives as a change to a device that exists.
|
||||
bare := make(map[string]lx.Device, len(devices))
|
||||
for deviceName, dev := range devices {
|
||||
clone := make(lx.Device, len(dev))
|
||||
for k, v := range dev {
|
||||
clone[k] = v
|
||||
}
|
||||
bare[deviceName] = clone
|
||||
}
|
||||
if dev, ok := bare["eth1"]; ok {
|
||||
delete(dev, "ipv4.address")
|
||||
}
|
||||
|
||||
if err := m.lx.Stop(u.Name); err != nil {
|
||||
return restore(fmt.Errorf("stop container: %w", err))
|
||||
}
|
||||
if err := m.lx.ReplaceConfig(u.Name, spec, bare); err != nil {
|
||||
return restore(fmt.Errorf("reconfigure container: %w", err))
|
||||
}
|
||||
if err := m.lx.ReplaceConfig(u.Name, spec, devices); err != nil {
|
||||
return restore(fmt.Errorf("reconfigure container: %w", err))
|
||||
}
|
||||
if err := m.lx.Start(u.Name); err != nil {
|
||||
return restore(fmt.Errorf("start container: %w", err))
|
||||
}
|
||||
if err := m.lx.WaitReady(u.Name, 180*time.Second); err != nil {
|
||||
return restore(fmt.Errorf("wait for container: %w", err))
|
||||
}
|
||||
if err := m.ConfigureContainerIPv6(u.Name, picked); err != nil {
|
||||
return restore(fmt.Errorf("configure ipv6 in container: %w", err))
|
||||
}
|
||||
if err := m.WireIPv6Pool(u.Name, picked); err != nil {
|
||||
return restore(fmt.Errorf("route the address on the host: %w", err))
|
||||
}
|
||||
return picked, nil
|
||||
}
|
||||
|
||||
// IPv6Mode returns the effective IPv6 mode (none|prefix|pool) for the panel /
|
||||
// CLI.
|
||||
func (m *Manager) IPv6Mode() string { return m.cfg.IPv6ModeEffective() }
|
||||
|
||||
+157
-51
@@ -103,6 +103,35 @@ type TransferResult struct {
|
||||
BytesRead int64
|
||||
}
|
||||
|
||||
// requireSupportedDriver refuses a transfer on a storage driver the feature
|
||||
// does not cover.
|
||||
//
|
||||
// Only zfs is supported. The other two drivers in this project cannot carry a
|
||||
// container between hosts the way this works: a btrfs pool would need both
|
||||
// hosts to agree on a driver-specific stream, and a dir pool has no snapshots
|
||||
// and no quotas behind it at all — it exists as a test-box opt-in. Refusing
|
||||
// outright is the honest answer, and it removes any question of a driver
|
||||
// mismatch being discovered halfway through a migration.
|
||||
func (m *Manager) requireSupportedDriver() error {
|
||||
driver, err := m.lx.PoolDriver(m.cfg.Incus.Pool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if driver != "zfs" {
|
||||
return fmt.Errorf("moving a container needs the zfs storage driver; this host's pool %q uses %q",
|
||||
m.cfg.Incus.Pool, driver)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TransferHostSupported reports whether this host can take part in a transfer
|
||||
// at all. It is the receiving side's first question, asked before anything is
|
||||
// fetched, so a host the feature does not cover says so without touching the
|
||||
// network.
|
||||
func (m *Manager) TransferHostSupported() error {
|
||||
return m.requireSupportedDriver()
|
||||
}
|
||||
|
||||
// TransferEstimate returns the number of bytes the container's root disk
|
||||
// currently occupies, used to check the temp file will fit before a long export
|
||||
// is started, and to tell the far side how much room to find.
|
||||
@@ -143,6 +172,9 @@ func (m *Manager) TransferExport(ctx context.Context, name string, w io.Writer,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.requireSupportedDriver(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.requireStopped(u.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -219,17 +251,21 @@ func expiresDate(rfc3339 string) string {
|
||||
return rfc3339
|
||||
}
|
||||
|
||||
// TransferSpaceCheck reports whether this host can take the disk described by
|
||||
// meta, by the same rule that guards `vps add`: the pool must not already be
|
||||
// 90% full. It is run before the archive is fetched, so a host that cannot take
|
||||
// the container says so instead of spending an hour downloading it, and it uses
|
||||
// the sending host's own figure for the disk rather than the archive's size,
|
||||
// which says nothing about how much will be unpacked.
|
||||
func (m *Manager) TransferSpaceCheck(meta []byte) error {
|
||||
// TransferPrecheck reports whether this host can take the container described
|
||||
// by meta, before a byte of it is fetched: the storage driver has to be one
|
||||
// this feature covers, and the pool must not already be 90% full — the same
|
||||
// rule that guards `vps add`, applied here so a host that cannot take the
|
||||
// container says so instead of spending an hour downloading it. The disk figure
|
||||
// comes from the sending host's own measurement, since the archive's size says
|
||||
// nothing about how much will be unpacked.
|
||||
func (m *Manager) TransferPrecheck(meta []byte) error {
|
||||
var t TransferMeta
|
||||
if err := json.Unmarshal(meta, &t); err != nil {
|
||||
return fmt.Errorf("unreadable transfer metadata: %w", err)
|
||||
}
|
||||
if err := m.requireSupportedDriver(); err != nil {
|
||||
return err
|
||||
}
|
||||
usage, err := m.PoolUsage()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -284,6 +320,9 @@ func (m *Manager) TransferImport(ctx context.Context, name string, r io.Reader,
|
||||
if err := m.TransferTarget(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.requireSupportedDriver(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var meta TransferMeta
|
||||
if err := json.Unmarshal(metaBytes, &meta); err != nil {
|
||||
return nil, fmt.Errorf("unreadable transfer metadata: %w", err)
|
||||
@@ -291,7 +330,16 @@ func (m *Manager) TransferImport(ctx context.Context, name string, r io.Reader,
|
||||
started := time.Now()
|
||||
counting := &countingReader{r: r}
|
||||
|
||||
created, err := m.Add(name, meta.Account())
|
||||
account := meta.Account()
|
||||
if m.cfg.IPv6ModeEffective() == cfg.IPv6ModePool {
|
||||
// On a pool-mode host an address is a per-container choice its operator
|
||||
// makes, so an import never takes one: the container arrives as pure
|
||||
// IPv4, using no pool address, and one can be assigned later if the
|
||||
// owner wants it. What the container was given on the host it came from
|
||||
// is irrelevant here.
|
||||
account.IPv6Addr = "none"
|
||||
}
|
||||
created, err := m.Add(name, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -392,22 +440,24 @@ func (m *Manager) TransferImport(ctx context.Context, name string, r io.Reader,
|
||||
// this host's is appended rather than written, so it has to go first — and
|
||||
// if the container came from a pool-mode host, its bridged NIC has no DHCP
|
||||
// client at all and needs one before anything else.
|
||||
if err := m.restoreBridgedIPv4(u.Name); err != nil {
|
||||
if err := m.restoreBridgedIPv4(u); err != nil {
|
||||
return fail(fmt.Errorf("restore the container's IPv4 setup: %w", err))
|
||||
}
|
||||
if err := m.stripForeignIPv6(u.Name); err != nil {
|
||||
if err := m.dropSourceIPv6(u); err != nil {
|
||||
return fail(fmt.Errorf("clear the source host's ipv6 configuration: %w", err))
|
||||
}
|
||||
if m.cfg.IPv6ModeEffective() == cfg.IPv6ModePool {
|
||||
if u.IPv6Address != "" {
|
||||
if err := m.ConfigureContainerIPv6(u.Name, u.IPv6Address); err != nil {
|
||||
return fail(fmt.Errorf("config container ipv6: %w", err))
|
||||
}
|
||||
if err := m.WireIPv6Pool(u.Name, u.IPv6Address); err != nil {
|
||||
return fail(fmt.Errorf("wire ipv6 pool: %w", err))
|
||||
}
|
||||
switch {
|
||||
case m.usesTwoNICs(u):
|
||||
if err := m.ConfigureContainerIPv6(u.Name, u.IPv6Address); err != nil {
|
||||
return fail(fmt.Errorf("config container ipv6: %w", err))
|
||||
}
|
||||
} else {
|
||||
if err := m.WireIPv6Pool(u.Name, u.IPv6Address); err != nil {
|
||||
return fail(fmt.Errorf("wire ipv6 pool: %w", err))
|
||||
}
|
||||
case m.cfg.IPv6ModeEffective() == cfg.IPv6ModePool:
|
||||
// Pool mode, no address handed out: the container is pure IPv4 and
|
||||
// there is nothing to route.
|
||||
default:
|
||||
if err := m.ConfigureContainerIPv6(u.Name, ""); err != nil {
|
||||
return fail(fmt.Errorf("config container ipv6: %w", err))
|
||||
}
|
||||
@@ -473,40 +523,64 @@ func (m *Manager) applyTransferMeta(u *db.User, meta *TransferMeta) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// stripForeignIPv6 removes the IPv6 configuration that the host this container
|
||||
// came from left in its networkd file.
|
||||
// dropSourceIPv6 removes the IPv6 configuration the host this container came
|
||||
// from left inside it, so that what remains is this host's and nothing else.
|
||||
//
|
||||
// The re-key step appends this host's address, routed block and gateway
|
||||
// neighbour, and only checks for its OWN values before appending — which is
|
||||
// right for a container that was always here, but a container that arrives
|
||||
// from another host already carries that host's address and block. Left alone
|
||||
// it comes up with two global addresses, one of them belonging to a network it
|
||||
// is not on, and services can bind to the wrong one.
|
||||
// neighbour and only checks for its OWN values before appending, so a container
|
||||
// that arrives from another host still carries that host's address and block.
|
||||
// Left alone it comes up with two global addresses, one of them belonging to a
|
||||
// network it is not on, and services can bind to the wrong one. A host with no
|
||||
// IPv6 at all has to lose the whole lot instead — including the one-shot unit
|
||||
// the source host installed, which would otherwise put its local route back on
|
||||
// every boot.
|
||||
//
|
||||
// Only stanzas whose values are not this host's are dropped; the image's own
|
||||
// DHCP setup is kept. Pool mode rewrites the file outright, so it has nothing
|
||||
// to strip.
|
||||
func (m *Manager) stripForeignIPv6(name string) error {
|
||||
if !m.cfg.IPv6Enabled() || m.cfg.IPv6ModeEffective() == cfg.IPv6ModePool {
|
||||
// Only IPv6 stanzas are touched; the image's own DHCP setup is kept. Pool mode
|
||||
// rewrites the file outright and has nothing to drop here.
|
||||
func (m *Manager) dropSourceIPv6(u *db.User) error {
|
||||
has := m.containerHasIPv6(u)
|
||||
if m.cfg.IPv6ModeEffective() == cfg.IPv6ModePool && has {
|
||||
// Pool mode rewrites the guest's network config outright.
|
||||
return nil
|
||||
}
|
||||
ipv6, err := m.IPv6Addr(name)
|
||||
if err != nil || ipv6 == "" {
|
||||
return err
|
||||
}
|
||||
block := ""
|
||||
if b, _ := m.IPv6Block(name); b != nil {
|
||||
block = b.String()
|
||||
keep := "1"
|
||||
ipv6, block, mac := "", "", ""
|
||||
if has {
|
||||
var err error
|
||||
ipv6, err = m.IPv6Addr(u.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b, _ := m.IPv6Block(u.Name); b != nil {
|
||||
block = b.String()
|
||||
}
|
||||
mac = m.bridgeMAC()
|
||||
} else {
|
||||
// No IPv6 here: keep none of it.
|
||||
keep = "0"
|
||||
}
|
||||
script := `set -e
|
||||
KEEP=` + strconv.Quote(keep) + `
|
||||
if command -v nmcli >/dev/null 2>&1 && ! systemctl is-active systemd-networkd >/dev/null 2>&1; then
|
||||
# RHEL-family keeps its addresses in a NetworkManager profile, not a file.
|
||||
if [ "$KEEP" = 0 ]; then
|
||||
CONN=$(nmcli -t -f NAME,DEVICE con show 2>/dev/null | awk -F: '$2 == "eth0" {print $1; exit}')
|
||||
[ -z "$CONN" ] && CONN=$(nmcli -t -f NAME con show 2>/dev/null | grep -i eth0 | head -1)
|
||||
if [ -n "$CONN" ]; then
|
||||
nmcli con mod "$CONN" ipv6.method disabled >/dev/null 2>&1 || true
|
||||
nmcli con up "$CONN" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
CFG=/etc/systemd/network/eth0.network
|
||||
[ -f "$CFG" ] || exit 0
|
||||
awk -v keep_addr=` + strconv.Quote(ipv6+"/128") + ` -v keep_block=` + strconv.Quote(block) + ` -v keep_mac=` + strconv.Quote(m.bridgeMAC()) + ` '
|
||||
if [ -f "$CFG" ]; then
|
||||
awk -v keep_v6="$KEEP" -v keep_addr=` + strconv.Quote(ipv6+"/128") + ` -v keep_block=` + strconv.Quote(block) + ` -v keep_mac=` + strconv.Quote(mac) + ` '
|
||||
function reset() { hdr=""; body=""; addr=""; dest=""; lladdr=""; lcl=0 }
|
||||
function keepit() {
|
||||
if (hdr == "[Address]" && addr != "" && addr != keep_addr) return 0
|
||||
if (hdr == "[Route]" && lcl == 1 && dest != "" && dest != keep_block) return 0
|
||||
if (hdr == "[Neighbor]" && lladdr != "" && lladdr != keep_mac) return 0
|
||||
if (hdr == "[Address]" && addr != "") { if (keep_v6 == 0 || addr != keep_addr) return 0 }
|
||||
if (hdr == "[Route]" && dest != "") { if (keep_v6 == 0) return 0; if (lcl == 1 && dest != keep_block) return 0 }
|
||||
if (hdr == "[Neighbor]" && lladdr != "") { if (keep_v6 == 0 || lladdr != keep_mac) return 0 }
|
||||
return 1
|
||||
}
|
||||
function flush() { if (hdr != "" && keepit()) print body; reset() }
|
||||
@@ -519,9 +593,18 @@ BEGIN { ORS="" }
|
||||
/^LinkLayerAddress=/ { lladdr=substr($0,18) }
|
||||
END { flush() }
|
||||
' "$CFG" > "$CFG.new"
|
||||
mv "$CFG.new" "$CFG"
|
||||
mv "$CFG.new" "$CFG"
|
||||
fi
|
||||
# The source host's one-shot route unit belongs to the source host.
|
||||
rm -f /etc/systemd/system/vpsmgr-ipv6.service /etc/systemd/system/multi-user.target.wants/vpsmgr-ipv6.service
|
||||
if [ "$KEEP" = 0 ]; then
|
||||
ip -6 addr flush dev eth0 scope global 2>/dev/null || true
|
||||
ip -6 route flush dev eth0 2>/dev/null || true
|
||||
fi
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
systemctl restart systemd-networkd >/dev/null 2>&1 || true
|
||||
`
|
||||
_, err = m.lx.ExecSH(name, script)
|
||||
_, err := m.lx.ExecSH(u.Name, script)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -544,7 +627,7 @@ func (m *Manager) ensureStaticIPv4(u *db.User) error {
|
||||
// the public /128 on a routed eth0 and the private address on a bridged
|
||||
// eth1, while prefix mode has the single bridged eth0 do both.
|
||||
nic := "eth0"
|
||||
if m.cfg.IPv6ModeEffective() == cfg.IPv6ModePool {
|
||||
if m.usesTwoNICs(u) {
|
||||
nic = "eth1"
|
||||
}
|
||||
last := ""
|
||||
@@ -577,8 +660,29 @@ func (m *Manager) ensureStaticIPv4(u *db.User) error {
|
||||
nic, last, u.IP)
|
||||
}
|
||||
|
||||
// usesTwoNICs reports whether this host lays the container out with two NICs:
|
||||
// a routed eth0 carrying a public /128 from the pool, and a bridged eth1
|
||||
// carrying the private IPv4. Only a pool-mode container that was actually given
|
||||
// an address has that shape. Prefix mode, IPv4-only, and a pool-mode container
|
||||
// with no address all put both addresses on one bridged eth0.
|
||||
func (m *Manager) usesTwoNICs(u *db.User) bool {
|
||||
return m.cfg.IPv6ModeEffective() == cfg.IPv6ModePool && u.IPv6Address != ""
|
||||
}
|
||||
|
||||
// containerHasIPv6 reports whether this host gives the container a global IPv6
|
||||
// address of its own.
|
||||
func (m *Manager) containerHasIPv6(u *db.User) bool {
|
||||
if !m.cfg.IPv6Enabled() {
|
||||
return false
|
||||
}
|
||||
if m.cfg.IPv6ModeEffective() == cfg.IPv6ModePool {
|
||||
return u.IPv6Address != ""
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// restoreBridgedIPv4 gives a container's bridged NIC back the DHCPv4 setup this
|
||||
// host's prefix-mode containers have.
|
||||
// host's containers have.
|
||||
//
|
||||
// A container that arrives from a pool-mode host carries the network
|
||||
// configuration THAT host wrote for it, and there eth0 is a routed NIC with no
|
||||
@@ -588,9 +692,11 @@ func (m *Manager) ensureStaticIPv4(u *db.User) error {
|
||||
// exactly what the image ships, and the IPv6 step appends this host's addresses
|
||||
// on top of it.
|
||||
//
|
||||
// A container that was already prefix-mode has DHCP=ipv4 and is left alone.
|
||||
func (m *Manager) restoreBridgedIPv4(name string) error {
|
||||
if m.cfg.IPv6ModeEffective() == cfg.IPv6ModePool {
|
||||
// A container that already has that setup is left alone.
|
||||
func (m *Manager) restoreBridgedIPv4(u *db.User) error {
|
||||
if m.usesTwoNICs(u) {
|
||||
// Its IPv4 is on the second, bridged NIC, which the source cannot have
|
||||
// configured differently.
|
||||
return nil
|
||||
}
|
||||
script := `set -e
|
||||
@@ -620,7 +726,7 @@ ClientIdentifier=mac
|
||||
EOF
|
||||
fi
|
||||
`
|
||||
_, err := m.lx.ExecSH(name, script)
|
||||
_, err := m.lx.ExecSH(u.Name, script)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -329,10 +329,14 @@ func transferReceive(args []string) error {
|
||||
ctx, cancel := interruptible()
|
||||
defer cancel()
|
||||
|
||||
// Fail on a bad target now rather than after a long download.
|
||||
// Fail on a bad target, or on a host this feature does not cover, now
|
||||
// rather than after a long download.
|
||||
if err := m.TransferTarget(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.TransferHostSupported(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The account metadata is tiny and comes first: it says what will be
|
||||
// created and how much room the disk needs, so a host that cannot take the
|
||||
@@ -342,7 +346,7 @@ func transferReceive(args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.TransferSpaceCheck(meta); err != nil {
|
||||
if err := m.TransferPrecheck(meta); err != nil {
|
||||
return err
|
||||
}
|
||||
acct, from, err := mgr.TransferMetaAccount(meta)
|
||||
|
||||
Reference in new issue
Block a user